|
|
@@ -0,0 +1,280 @@
|
|
|
+# Security
|
|
|
+
|
|
|
+> Audience: operators evaluating IRDB for production, security
|
|
|
+> reviewers. This document describes the **as-built** posture — claims
|
|
|
+> here can be verified against the code in this repo. If you find a
|
|
|
+> claim that doesn't hold, that's a bug.
|
|
|
+
|
|
|
+## At a glance
|
|
|
+
|
|
|
+| Concern | Posture |
|
|
|
+|----------------------|-------------------------------------------------------------------------------|
|
|
|
+| Authentication | OIDC (Entra ID) + local admin (UI); Bearer tokens (api) |
|
|
|
+| Authorization | RBAC: `viewer` / `operator` / `admin`; enforced server-side on every endpoint |
|
|
|
+| Transport | Plain HTTP between containers; reverse-proxy TLS termination expected |
|
|
|
+| Data at rest | DB: unencrypted (host volume encryption recommended); tokens hashed (SHA-256) |
|
|
|
+| Secret storage | `.env` for runtime secrets; never persisted to the DB |
|
|
|
+| Logging | JSON to stdout; secret-scrubbing Monolog processor |
|
|
|
+| Rate limiting | Public API: 60 req/s/token (token-bucket); login: brute-force lockout |
|
|
|
+| Supply chain | `composer audit` + `npm audit` in CI; locked Dockerfile base image |
|
|
|
+| Audit | Append-only `audit_log`; one row per state-changing admin action |
|
|
|
+
|
|
|
+## Authentication
|
|
|
+
|
|
|
+The system has **two distinct authentication boundaries**.
|
|
|
+
|
|
|
+**Browser users** authenticate to the `ui` container, which owns:
|
|
|
+- The OIDC redirect/callback flow (Microsoft Entra ID via
|
|
|
+ `jumbojett/openid-connect-php`, code flow with PKCE).
|
|
|
+- The local-admin login form, validated against an Argon2id password
|
|
|
+ hash stored in the UI's environment variable
|
|
|
+ `LOCAL_ADMIN_PASSWORD_HASH` — never in the database.
|
|
|
+- File-backed PHP sessions, `HttpOnly`, `SameSite=Lax`, `Secure` when
|
|
|
+ `APP_ENV=production`.
|
|
|
+
|
|
|
+**Machine clients and the UI itself** authenticate to the `api` with a
|
|
|
+Bearer token. There are four token kinds:
|
|
|
+
|
|
|
+| Kind | Calls | Bound to |
|
|
|
+|------------|------------------------------------|---------------------------------|
|
|
|
+| `reporter` | `POST /api/v1/report` | a reporter row |
|
|
|
+| `consumer` | `GET /api/v1/blocklist` | a consumer row |
|
|
|
+| `admin` | `/api/v1/admin/*` | a configured RBAC role |
|
|
|
+| `service` | `/api/v1/admin/*`, `/api/v1/auth/*` | the UI; carries impersonation header |
|
|
|
+
|
|
|
+Token format: `irdb_<kind3>_<32 base32 chars>`. 160 bits of entropy from
|
|
|
+`random_bytes(20)` (PHP CSPRNG). Verified by
|
|
|
+`api/tests/Unit/Auth/TokenEntropyTest.php`. Tokens are stored as their
|
|
|
+SHA-256 digest plus an 8-character prefix used for log triage —
|
|
|
+verified by `api/tests/Integration/Auth/SchemaSecretsAtRestTest.php`.
|
|
|
+
|
|
|
+The raw token is shown **once** at creation time and never retrievable
|
|
|
+afterwards.
|
|
|
+
|
|
|
+### Brute-force lockout
|
|
|
+
|
|
|
+The local-admin sign-in is gated by `App\Auth\LoginThrottle` (UI
|
|
|
+container). Failures are bucketed by `(username, source_ip)` so a
|
|
|
+single attacker's flood doesn't lock the legitimate admin coming in
|
|
|
+from a different address. Progression:
|
|
|
+
|
|
|
+| Failures | Lockout |
|
|
|
+|----------|---------|
|
|
|
+| 1–4 | none |
|
|
|
+| 5 | 60 s |
|
|
|
+| 10 | 300 s |
|
|
|
+| 15+ | 1800 s |
|
|
|
+
|
|
|
+Successful login clears the bucket. The store is per-process in
|
|
|
+memory; restarting the UI container clears all locks (the documented
|
|
|
+"unlock the admin" path).
|
|
|
+
|
|
|
+OIDC sign-in has no equivalent throttle — Entra rate-limits failed
|
|
|
+authentication attempts at the IdP, which is the right layer for
|
|
|
+enterprise SSO.
|
|
|
+
|
|
|
+## Authorization
|
|
|
+
|
|
|
+Three roles, strictly hierarchical for read access:
|
|
|
+
|
|
|
+| Role | Can do |
|
|
|
+|----------|----------------------------------------------------------------|
|
|
|
+| viewer | Read everything; cannot mutate |
|
|
|
+| operator | viewer + manage manual blocks + allowlist |
|
|
|
+| admin | operator + tokens, policies, categories, users, jobs, settings |
|
|
|
+
|
|
|
+Enforcement is in the api: every admin route declares the required
|
|
|
+role, and `RbacMiddleware` rejects with `403` on mismatch. The UI
|
|
|
+hides UI elements the user can't use, but **does not enforce**
|
|
|
+security with hidden buttons — direct API access with a lower-role
|
|
|
+token is properly rejected. Every test in
|
|
|
+`tests/Integration/Admin/*ControllerTest.php` exercises this.
|
|
|
+
|
|
|
+The `service` token is special: it's only accepted **with** an
|
|
|
+`X-Acting-User-Id` header, which the api uses to look up the
|
|
|
+impersonated user and apply that user's role. The header is **only**
|
|
|
+trusted in combination with the service token — admin-token requests
|
|
|
+ignore it. Service tokens never appear in `/api/v1/admin/tokens` list
|
|
|
+output and cannot be created via the admin API.
|
|
|
+
|
|
|
+## Transport
|
|
|
+
|
|
|
+The Docker Compose stack runs both containers over plain HTTP. In
|
|
|
+production, **front both with a reverse proxy (Caddy / Traefik /
|
|
|
+nginx) that terminates TLS**, typically with one hostname per
|
|
|
+container. An example Caddy config is in `examples/reverse-proxy/`.
|
|
|
+
|
|
|
+When `APP_ENV=production`, both containers send
|
|
|
+`Strict-Transport-Security: max-age=31536000; includeSubDomains`. This
|
|
|
+is gated on `APP_ENV` because HSTS is sticky in browsers — turning it
|
|
|
+on for `localhost` development locks subsequent localhost work into
|
|
|
+HTTPS for a year.
|
|
|
+
|
|
|
+Other security headers are applied unconditionally (api +
|
|
|
+`api/docker/Caddyfile`, ui + `ui/docker/Caddyfile`):
|
|
|
+
|
|
|
+- `X-Content-Type-Options: nosniff`
|
|
|
+- `X-Frame-Options: DENY` (UI) / `SAMEORIGIN` (api)
|
|
|
+- `Referrer-Policy: strict-origin-when-cross-origin`
|
|
|
+- `Permissions-Policy: geolocation=(), microphone=(), camera=()`
|
|
|
+- `Content-Security-Policy` — strict on the api
|
|
|
+ (`default-src 'none'; frame-ancestors 'none'`); UI policy allows
|
|
|
+ `'self' 'unsafe-eval' 'unsafe-inline'` for scripts to accommodate
|
|
|
+ Alpine.js v3's `Function()` evaluator and inline style attributes;
|
|
|
+ documented trade-off in the Caddyfile.
|
|
|
+
|
|
|
+## Data at rest
|
|
|
+
|
|
|
+The api's DB carries:
|
|
|
+- Reports, scores, manual blocks, allowlist entries, policies,
|
|
|
+ categories, reporters, consumers — operational data, not sensitive.
|
|
|
+- Hashed API tokens (`api_tokens.token_hash`, SHA-256).
|
|
|
+- Audit log (`audit_log`), append-only at the application layer.
|
|
|
+- User identity records (`users`) — email, display name, OIDC subject;
|
|
|
+ no credentials.
|
|
|
+
|
|
|
+The DB itself is **not encrypted at rest** by the application. The
|
|
|
+recommended layer is host disk encryption (LUKS for SQLite-on-volume,
|
|
|
+managed encryption for MySQL).
|
|
|
+
|
|
|
+A regression test (`SchemaSecretsAtRestTest`) scans every column on
|
|
|
+every table for names that suggest plaintext credentials
|
|
|
+(`password`, `_secret`, `client_secret`, `license_key`, ...) and
|
|
|
+fails CI if any appear. The intent: a future migration that adds a
|
|
|
+column called `client_secret` will fail the build before it ships.
|
|
|
+
|
|
|
+## Secret storage
|
|
|
+
|
|
|
+Runtime secrets live in `.env` — read by Compose at boot, exported as
|
|
|
+container environment variables, never written to the DB. The list:
|
|
|
+
|
|
|
+- `UI_SERVICE_TOKEN` — shared between api and ui; the api reads it to
|
|
|
+ authenticate UI-originated calls.
|
|
|
+- `INTERNAL_JOB_TOKEN` — bearer for the scheduler's
|
|
|
+ `/internal/jobs/*` calls. Network-restricted in addition to the
|
|
|
+ token.
|
|
|
+- `APP_SECRET`, `UI_SECRET` — application-internal signing seeds.
|
|
|
+- `LOCAL_ADMIN_PASSWORD_HASH` — Argon2id hash; the raw password is
|
|
|
+ never on disk.
|
|
|
+- `OIDC_CLIENT_SECRET`, `MAXMIND_LICENSE_KEY`, `IPINFO_TOKEN`,
|
|
|
+ `DB_MYSQL_PASSWORD` — third-party credentials; passed straight
|
|
|
+ through.
|
|
|
+
|
|
|
+`GET /api/v1/admin/config` (admin-only) surfaces effective
|
|
|
+configuration with the sensitive values masked: `INTERNAL_JOB_TOKEN`,
|
|
|
+`MAXMIND_LICENSE_KEY`, `IPINFO_TOKEN`, `DB_MYSQL_PASSWORD`,
|
|
|
+`APP_SECRET` show as `***`; `UI_SERVICE_TOKEN` shows the first 8
|
|
|
+characters. Empty values are visible (so misconfiguration is
|
|
|
+debuggable).
|
|
|
+
|
|
|
+## Logging
|
|
|
+
|
|
|
+Both containers log JSON to stdout via Monolog. A
|
|
|
+`SecretScrubbingProcessor` runs on every record before it hits the
|
|
|
+handler, scrubbing:
|
|
|
+
|
|
|
+- Any context key whose name contains `password`, `authorization`,
|
|
|
+ `auth_token`, `bearer`, `secret`, `license_key`, `service_token`,
|
|
|
+ `job_token`, `cookie` — value replaced with `***`.
|
|
|
+- Any string value matching the Bearer + token-shaped pattern — the
|
|
|
+ kind prefix is preserved (`Bearer irdb_adm_***`) so triage logs
|
|
|
+ still tell you "an admin token failed" without leaking the secret
|
|
|
+ half.
|
|
|
+- Argon2 / bcrypt hashes embedded in messages.
|
|
|
+
|
|
|
+Tests:
|
|
|
+`api/tests/Unit/Logging/SecretScrubbingProcessorTest.php`,
|
|
|
+`ui/tests/Unit/Logging/SecretScrubbingProcessorTest.php`.
|
|
|
+
|
|
|
+## Rate limiting
|
|
|
+
|
|
|
+**Public API**: token-bucket per token, default 60 req/s with a burst
|
|
|
+of 120, configurable via `API_RATE_LIMIT_PER_SECOND`. Returns `429`
|
|
|
+with `Retry-After: 1` when exhausted. **In-process per replica** —
|
|
|
+multi-replica deployments will see N × the rate before exhaustion;
|
|
|
+this is acceptable for the documented topology (single api replica
|
|
|
+for SQLite, scaled api with shared MySQL).
|
|
|
+
|
|
|
+**Login**: brute-force lockout per `(username, ip)` (above).
|
|
|
+
|
|
|
+**Admin endpoints** are unrated. Real abuse on admin endpoints is
|
|
|
+rare (Bearer-authed humans/UI). Add a limit if you measure a problem.
|
|
|
+
|
|
|
+## Supply chain
|
|
|
+
|
|
|
+- `composer audit --no-dev` runs in CI for both subprojects, failing
|
|
|
+ on any reported advisory against production dependencies.
|
|
|
+- `npm audit --omit=dev --audit-level=high` runs in CI for the UI's
|
|
|
+ asset bundle. Moderate advisories are not gating but should be
|
|
|
+ reviewed during dependency bump passes.
|
|
|
+- The Dockerfile base image (`dunglas/frankenphp:1-php8.3-alpine`) is
|
|
|
+ pinned to a specific tag. Updating it is a deliberate operator
|
|
|
+ action.
|
|
|
+
|
|
|
+**Policy**: when an audit fails:
|
|
|
+1. The CI job blocks the merge.
|
|
|
+2. An admin reviews the advisory and either:
|
|
|
+ - patches the dependency, or
|
|
|
+ - documents an accepted exception (advisory ID, reason, expected
|
|
|
+ remediation date) in this section.
|
|
|
+
|
|
|
+No exceptions are currently accepted.
|
|
|
+
|
|
|
+## Audit
|
|
|
+
|
|
|
+Every state-changing admin action emits one row to `audit_log` via
|
|
|
+the `AuditEmitter` (M12). The actor recording rule:
|
|
|
+
|
|
|
+- service-token + `X-Acting-User-Id` → `actor_kind=user`,
|
|
|
+ `actor_id=<user_id>` (the impersonated user is the responsible
|
|
|
+ party — not the service token, even though the call carries it).
|
|
|
+- admin token → `actor_kind=admin-token`, `actor_id=<token_id>`.
|
|
|
+- reporter / consumer tokens → `actor_kind=reporter` / `consumer`.
|
|
|
+- system-internal (e.g. expired-block cleanup job) → `actor_kind=system`,
|
|
|
+ no `actor_id`.
|
|
|
+
|
|
|
+Failed validation paths (`400`s) don't emit. Only successful state
|
|
|
+changes do. The `audit_log` table is **append-only at the application
|
|
|
+layer** — there is no UI for editing or deleting rows — but a
|
|
|
+sufficiently privileged DB user can rewrite history. Tamper-evident
|
|
|
+audit (chained signing) is future work.
|
|
|
+
|
|
|
+## Out of scope
|
|
|
+
|
|
|
+The following are deliberately not built into IRDB; the stack
|
|
|
+expects them at higher layers:
|
|
|
+
|
|
|
+- **WAF / IPS / fail2ban-on-the-UI**. Run one in front of the
|
|
|
+ reverse proxy if you need it.
|
|
|
+- **2FA on the local admin path**. Use OIDC for that — Entra has
|
|
|
+ first-class MFA support. The local admin is a recovery channel,
|
|
|
+ not a daily driver.
|
|
|
+- **mTLS between api and ui**. Docker network isolation is the
|
|
|
+ trust boundary in the default compose deployment; document and
|
|
|
+ enforce that boundary at your network layer.
|
|
|
+- **Encryption at rest of the SQLite file**. Host disk encryption
|
|
|
+ is the right layer.
|
|
|
+- **Tamper-evident audit log**. Future work; the append-only
|
|
|
+ guarantee today is application-level only.
|
|
|
+- **Penetration test report**. Out of scope for the build.
|
|
|
+
|
|
|
+## Threat model snapshot
|
|
|
+
|
|
|
+A non-exhaustive list of threats and the controls that mitigate them.
|
|
|
+
|
|
|
+| Threat | Control |
|
|
|
+|---------------------------------------------------|------------------------------------------------------------------|
|
|
|
+| Stolen Bearer token in transit | Reverse-proxy TLS termination; HSTS in production |
|
|
|
+| Stolen Bearer token at rest in DB | Tokens stored as SHA-256; raw value never persisted |
|
|
|
+| Token leaked via logs | `SecretScrubbingProcessor` scrubs Bearer values before output |
|
|
|
+| Brute-force on local admin | Per-(user,ip) lockout: 5/60, 10/300, 15/1800 seconds |
|
|
|
+| Public API abuse | 60 req/s/token token-bucket; `429 Retry-After: 1` |
|
|
|
+| CSRF on UI form | Per-session CSRF token validated by `CsrfMiddleware` |
|
|
|
+| Click-jacking / framing | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` |
|
|
|
+| MIME confusion | `X-Content-Type-Options: nosniff` |
|
|
|
+| Internal jobs reachable from outside | Caddy 404s `/internal/*` outside RFC1918 + bearer required |
|
|
|
+| Privileged action without audit | All state-changing admin endpoints emit one `audit_log` row each |
|
|
|
+| Schema drift introducing plaintext secret column | `SchemaSecretsAtRestTest` regression scan in CI |
|
|
|
+| Vulnerable dependency in production deps | `composer audit` + `npm audit` in CI, fail on advisory |
|
|
|
+
|
|
|
+If you find a threat the table doesn't address, please open an issue.
|