# 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 ` | 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 ... ' || col` inside a function is injectable exactly like application code. Use `format('%I', col)` for identifiers and `%L` for literals in PostgreSQL, `QUOTENAME` in SQL Server. - **ORM escape hatches**: `.raw()`, `.whereRaw()`, `$queryRawUnsafe`, string-built `filter` arguments. 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: ```sql -- 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 `DELETE` only where the app deletes. Many applications only soft-delete, and discover the difference the day an injection tries a hard one. - Reserve `TRUNCATE`, `DROP`, and `ALTER` for the migration role. That single split turns "attacker drops the table" into "attacker cannot". ## Row-Level Security ```sql 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); ``` - `USING` filters rows that are read or targeted; `WITH CHECK` validates rows being written. A policy with only `USING` lets a tenant insert rows attributed to another tenant. - RLS is bypassed by superusers and, unless `FORCE` is 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 LOCAL` so 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`/`SHA` call, 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 NULL` is 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-full` on the client, `require_secure_transport` on 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 INSERT` only, no `UPDATE`, no `DELETE`. 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-only` for schema questions, and a targeted `--table` extract 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`, or `DROP`; a separate role owns the schema. - `DELETE` is granted only where the application deletes. - Multi-tenant tables enforce isolation in the database (RLS with `FORCE` and both `USING` and `WITH 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.