12 KiB
Security — Injection, Privileges, and Personal Data
Three separate jobs that get conflated: keeping untrusted input out of the parser, keeping the application's database role from being able to do damage, and keeping personal data legible only where it must be.
Contents: Injection Surface · Beyond Placeholders · Dynamic Identifiers · Least Privilege · Role Layout · Row-Level Security · Personal Data · Erasure · Encryption · Auditing · Connection Secrets · Backup Exposure · Review Checklist
The Full Injection Surface
Placeholders bind values. Everything else in a statement is code, and every one of these positions has produced real incidents:
| Position | Bindable? | Safe approach |
|---|---|---|
WHERE col = ? |
Yes | Placeholder |
IN (?, ?, ?) |
Yes, one placeholder per element | Generate the exact count, or pass an array/= ANY($1) where supported |
| Table or column name | No | Allowlist of literal strings in code (SKILL.md rule 1) |
ORDER BY <col> <dir> |
No | Map an opaque client token ("newest") to a hardcoded fragment; never pass the column through |
LIMIT / OFFSET |
Usually yes | Placeholder; otherwise cast to integer and clamp to a maximum |
LIKE pattern |
Value is bindable, wildcards are not | Escape % and _ in user input, then add your own wildcards |
Interval/date arithmetic (NOW() - INTERVAL '? days') |
No, the literal is part of the syntax | Bind an integer and multiply an interval, or validate the number |
| Schema/database qualifier | No | Allowlist; never derive from a hostname or subdomain header |
| JSON path expression | Engine-dependent | Treat as an identifier: allowlist |
Beyond Placeholders
- Second-order injection: input stored safely, then concatenated into a later query (a report builder reading a saved "filter" column). The stored value is as untrusted as the original request.
- String building in stored procedures:
EXECUTE 'SELECT ... ' || colinside a function is injectable exactly like application code. Useformat('%I', col)for identifiers and%Lfor literals in PostgreSQL,QUOTENAMEin SQL Server. - ORM escape hatches:
.raw(),.whereRaw(),$queryRawUnsafe, string-builtfilterarguments. The ORM protects the paths you use through it, not around it. - Error messages as an oracle: returning the database error text to the client leaks table and column names and confirms injection attempts. Log the detail, return a generic message.
- Blind and time-based probes: an endpoint that returns different response times or row counts is enough. Correct escaping is the defense; hiding errors is not.
- Batch separators: drivers that allow multiple statements per call turn a single injection into arbitrary DDL. Disable multi-statement mode unless a migration path needs it.
Dynamic Identifiers, Done Correctly
ALLOWED_SORTS = {"newest": "created_at DESC", "oldest": "created_at ASC", "name": "name ASC"}
order_by = ALLOWED_SORTS.get(request.sort, "created_at DESC") # default, never the raw input
The allowlist maps an opaque token to a complete, hardcoded fragment. Validating with a regex (^[a-z_]+$) is weaker: it still permits any existing column name, so a client can sort by password_hash and read it one bit at a time through ordering.
Least Privilege for the Application Role
The default posture on most projects — the app connects as owner or superuser — means one injection is total compromise. Split the roles:
-- Owner role: owns objects, runs migrations, used only by the deploy pipeline
-- App role: no DDL, no ownership
CREATE ROLE app_rw LOGIN PASSWORD :'pw';
GRANT CONNECT ON DATABASE mydb TO app_rw;
GRANT USAGE ON SCHEMA public TO app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_rw;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_rw;
-- Future tables inherit the grant; without this, every new table breaks the app after deploy
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;
-- Read-only role for analytics, dashboards, and humans
CREATE ROLE app_ro LOGIN PASSWORD :'pw2';
GRANT CONNECT ON DATABASE mydb TO app_ro;
GRANT USAGE ON SCHEMA public TO app_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_ro;
- On PostgreSQL,
REVOKE CREATE ON SCHEMA public FROM PUBLIC(already the default from version 15) — otherwise any role can create objects in it. - MySQL grants are per
user@host:GRANT SELECT ON mydb.* TO 'app'@'10.0.%'. A grant to'app'@'%'undoes the network scoping you configured elsewhere. - Grant
DELETEonly where the app deletes. Many applications only soft-delete, and discover the difference the day an injection tries a hard one. - Reserve
TRUNCATE,DROP, andALTERfor the migration role. That single split turns "attacker drops the table" into "attacker cannot".
Row-Level Security
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY; -- also applies to the table owner
CREATE POLICY tenant_read ON documents FOR SELECT
USING (tenant_id = current_setting('app.tenant_id')::bigint);
CREATE POLICY tenant_write ON documents FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
USINGfilters rows that are read or targeted;WITH CHECKvalidates rows being written. A policy with onlyUSINGlets a tenant insert rows attributed to another tenant.- RLS is bypassed by superusers and, unless
FORCEis set, by the table owner. An app connecting as the owner gets no protection at all — this is the most common way RLS is deployed, and it then does nothing at all. - The setting must be applied per connection, inside the transaction, and cleared or overwritten on reuse. With a transaction-mode pooler, use
SET LOCALso it cannot leak into the next tenant's transaction. - Policies are predicates the planner applies: a policy over a non-indexed expression makes every query on the table slow. Index the policy column (it is usually
tenant_id, which already leads the composite indexes). - Cross-tenant references still need checking: a FK to another tenant's row passes RLS on insert unless the policy checks the parent too.
Personal Data: Classify Before You Design
- Tag columns holding personal data in the schema itself (a comment or naming convention) so exports, logs, and fixtures can be filtered mechanically rather than by memory.
- Do not log query parameters for statements touching those columns; slow-query logs are the classic accidental PII store.
- Test and staging environments should hold generated or masked data, never a production restore. If a production restore is unavoidable, mask as part of the restore job, not afterwards.
- Store the minimum: a birth year instead of a birth date, a hashed identifier instead of a national id, an age bucket instead of an age, when the product only needs the coarse value.
- Passwords are hashed with a slow, salted, memory-hard function by the application (bcrypt, scrypt, Argon2) — never a database
MD5/SHAcall, which is fast by design and often lands in the query log.
Deletion and Erasure
- An erasure request must reach every copy: the row, the audit log, soft-deleted rows, materialized views, rollup tables, backups, replicas, and any exported extract.
- Backups are the hard part. The workable policy is a bounded retention window (state it, e.g. 30 days) plus a documented rule that restores re-apply the erasure list; per-row deletion inside historical backups is not practical.
- Prefer crypto-shredding for data that must be unrecoverable on demand: encrypt each subject's sensitive fields with a per-subject key and delete the key. Every copy becomes unreadable at once, backups included.
- Anonymization must break linkability: replacing a name while keeping a unique id, an exact timestamp, and a postcode re-identifies the person. Generalize or drop the quasi-identifiers too.
- Distinguish erasure from soft delete.
deleted_at IS NOT NULLis still the data.
Encryption
| Layer | Protects against | Does not protect against |
|---|---|---|
| TLS in transit | Network interception | Anything with valid credentials |
| Disk / tablespace encryption at rest | Stolen disks, discarded hardware | Any authenticated query — the database decrypts transparently |
| Column-level encryption (app-side) | A dump, a read-only leak, an over-privileged analyst | Nothing if the key sits next to the data |
| Deterministic column encryption | Same, while allowing equality lookups | Frequency analysis; equal plaintexts produce equal ciphertexts |
- Choose per column: randomized encryption for anything you never search; deterministic only for a column you must look up by exact value, accepting the leak.
- Encrypted columns cannot be range-scanned, sorted, or pattern-matched. Design the query set first — retrofitting encryption onto a column with a range filter means changing the query.
- Keys live in a key management service, not in the database, not in the repository, not in an environment variable that is echoed into logs.
- Enforce TLS on the server side (
sslmode=verify-fullon the client,require_secure_transporton MySQL). A client that is allowed to fall back to plaintext will, on the day the certificate expires.
Auditing Access
- Two different needs: data change history (who changed which row to what — the audit table) and access logging (who read what, when, from where — the engine's own audit facility).
- Capture the application user, not just the database role: with a shared pooled role, every row says
app_rw. Propagate the end-user id in a session variable (SET LOCAL app.user_id) and read it in the audit trigger. - The audit table must be append-only for the app role:
GRANT INSERTonly, noUPDATE, noDELETE. An audit log the application can edit proves nothing. - Audit tables outgrow their source tables; partition and expire them on a stated retention window.
- Log failed authentication and privilege-denied events too — successful queries alone hide the reconnaissance.
Connection Secrets
- Credentials belong in a secret manager or the platform's secret store, injected at runtime. Never in the repository, never in a migration file, never in
~/Clawic/data/sql/. - Connection strings appear in process listings, crash dumps, ORM debug output, and error pages. Prefer environment-injected components over one URL string, and redact them in every log formatter.
- Rotate by supporting two valid credentials at once (add the new one, deploy, remove the old); rotation with a single credential is an outage.
- Use per-service credentials so one compromised service is one revocation, and so the audit log can attribute activity.
- Bind the database to a private network and require TLS. A managed database with a public endpoint and a strong password is one credential leak from open.
Backups Are a Copy of Everything
- A dump has the same sensitivity as the database, with none of its access control. Encrypt at rest, restrict who can download, and log every access.
- Restore drills into a scratch environment must use masked data or an isolated network — a restore drill is the most common way production data reaches a laptop.
- Verify what a dump contains before sharing it:
pg_dump --schema-onlyfor schema questions, and a targeted--tableextract instead of the whole database.
Review Checklist
- Every user value is a placeholder; every dynamic identifier comes from an allowlist mapping to hardcoded fragments.
- The application role cannot run DDL,
TRUNCATE, orDROP; a separate role owns the schema. DELETEis granted only where the application deletes.- Multi-tenant tables enforce isolation in the database (RLS with
FORCEand bothUSINGandWITH CHECK), not only in code. - Personal data columns are identified, minimized, and excluded from logs and non-production environments.
- Database errors are logged in full and returned to clients generically.
- Credentials come from a secret store, TLS is required, and the endpoint is not publicly reachable.
- The audit table is append-only for the application role and carries the end-user identity.