security-hardening-checklist.md — the chart's security controls

This is the chart's hardening posture — operator-facing controls that the chart toggles or enforces, with verification commands an operator can run post-install. It is not the regulator-facing SECURITY.md / GDPR retention-and-erasure documentation: those live with the compliance-docs track (docs/compliance/). The two docs serve distinct audiences: - This doc = SecOps reviewers and platform operators. "Did the chart ship the security control? Where do I see it? What's the override?" - SECURITY.md / GDPR docs = product leadership, customer security teams, EU/UK regulators. "What is the product's security posture? How does it handle personal data? What's the disclosure process?" Keep them separate. Do not let one substitute for the other.

This checklist enumerates every security-audit control that has a deployment-side toggle the chart either flips by default or exposes as a values.yaml key. Source-only fixes (refactors, removed deps, log redactions, internal newtypes) are intentionally omitted — they ship with the binary and an operator cannot meaningfully verify them post-install.

How to read this checklist

Each entry has the same shape:

If a control needs no operator action AND the chart enforces it unconditionally, the entry is short (4-5 lines). Operator-tunable controls run longer (10-15 lines).

In the verification commands, substitute <ns> with the install namespace, <release> with the Helm release name, and <svc-or-ingress> with the cluster-internal service URL or the public hostname of the operator's edge.


First-audit controls (deployment-side surface)

AuthContext::Disabled is fail-closed in OCI

Per-request deadline + slowloris timeout

/metrics requires authentication

SSRF redirect-hop revalidation

Connect-time DNS pinning (removed)

HTTPS-only realm + upstream URL scheme

Operating behind an egress (forward) proxy

Two-role Postgres model

Per-username brute-force lockout — REMOVED

The authenticate_local per-username + per-IP lockout was removed along with the HTTP-Basic-against-local-admin-row identity path it protected (see docs/auth-catalog.md Entry 8). The PAT-side bearer-path brute-force protection (PatValidationUseCase::pat_lockout via HORT_PAT_LOCKOUT_*, distinct mechanism) is unchanged.

Concurrency limit + load-shed

HSTS + bind-default + HORT_REQUIRE_HTTPS

Mounted-file secret containment + mode

Graceful shutdown deadline

Per-principal OCI upload-session cap

Pod-level securityContext (CIS K8s alignment)


Second-audit controls (2026-04-30) — deployment-side surface

IPv4-mapped IPv6 in SSRF predicate

Authz audit events on gitops apply

ArtifactReleased carries admin_id + justification

Streaming metadata fetch + parse-bomb cap

OCI manifest blob-reference cap

mTLS / custom CA / cert pinning per upstream

Upstream allowlist policy

OIDC algorithm gate

CI advisory gating + workspace MSRV


2026-05-03 audit controls — deployment-side surface

This round closed nine Medium-severity findings from the 2026-05-03 audit. The connect-time DNS pinning disposition flip is documented above under "Connect-time DNS pinning (removed)"; the remaining items appear below. Several are in-binary correctness gates with no operator-tunable surface.

is_routable range extension

Rightmost-untrusted X-Forwarded-For

GroupMappingUpdated audit event

Permission::Delete separated from Write


2026-05-15 audit controls — deployment-side surface

Three-tier topology + control-plane listener + default-on NetworkPolicy

HORT_EXTRA_CA_BUNDLE is an auth-critical asset

2026-06-02 audit controls — deployment-side surface

Schedule + observe the event-chain verifier


Quick verification command set

Aggregated post-install block. Overlaps with install.md § 7 Verify and adds the security-hardening checks specific to the controls above. Substitute <ns>, <release>, and <svc-or-ingress> as before.

# 1. Pods + migrations Job (install baseline).
kubectl get pods -n <ns>
kubectl get job -n <ns> <release>-hort-server-migrate \
  -o jsonpath='{.status.conditions[?(@.type=="Complete")].status}{"\n"}'

# 2. /healthz + /readyz.
curl -fsS http://<svc-or-ingress>/healthz
curl -fsS http://<svc-or-ingress>/readyz

# 3. /metrics requires auth — expect 401.
curl -i http://<svc-or-ingress>/metrics | head -1

# 4. Two-role Postgres — expect hort_app_role.
# The chart injects the DSN as HORT_DATABASE_URL.
kubectl exec -n <ns> deploy/<release>-hort-server -- \
  sh -c 'echo $HORT_DATABASE_URL' | grep -E '^postgres://hort_app_role@'

# 5. Pod securityContext (CIS K8s) — expect non-root, RO root, drop ALL.
kubectl get pod -n <ns> -l app.kubernetes.io/name=hort-server \
  -o jsonpath='{.items[0].spec.containers[0].securityContext}{"\n"}'

# 6. HSTS conditional emission.
curl -sI -H 'X-Forwarded-Proto: https' http://<svc-or-ingress>/healthz \
  | grep -i strict-transport-security

# 7. HTTP timeouts plumbed.
kubectl exec -n <ns> deploy/<release>-hort-server -- env \
  | grep -E '^HORT_HTTP_(HEADER_READ|REQUEST|OCI_UPLOAD)_TIMEOUT'

# 8. Mounted-file secrets.
kubectl exec -n <ns> deploy/<release>-hort-server -- \
  ls -la /etc/hort-server/secrets/ 2>/dev/null || echo "(no secrets mounted)"

# 9. NetworkPolicy presence when enabled.
kubectl get networkpolicy -n <ns> -l app.kubernetes.io/instance=<release>

# 10. OIDC admin request (install baseline) — expect 200.
curl -fsS -H "Authorization: Bearer $TOKEN" \
  http://<svc-or-ingress>/admin/repositories

If any check returns the unexpected outcome, consult the matching entry above for the relaxation story and the values.yaml keys involved.