1
0

security.md 13 KB

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-Idactor_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 (400s) 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.