소스 검색

Hardened container and host configurations

Efren Yevale Varela 1 개월 전
부모
커밋
a9a6ec88f4

+ 51 - 1
.github/workflows/ci.yml

@@ -5,7 +5,8 @@
 #   fuzz-build (nightly leg inside `fuzz/`, so the pinned stable
 #   `rust-toolchain.toml` at the root is never violated).
 # Scheduled legs (not per-PR):
-#   mutants (weekly budgeted mutation run over the pure security modules).
+#   mutants (weekly budgeted mutation run over the pure security modules),
+#   fuzz-run (bounded run of every fuzz target; the per-PR leg only builds).
 #
 # Must NOT gate on miri for tokio/sqlx/redis; no lint suppressions anywhere.
 
@@ -158,6 +159,55 @@ jobs:
       - name: Build all fuzz targets
         run: cargo fuzz build
 
+  fuzz-run:
+    name: fuzz-run
+    # Scheduled bounded fuzzing. `fuzz-build` (above) only compiles the
+    # targets per PR; this leg actually executes them, so a crash is found
+    # without a human starting it. It is bounded and scheduled, not 24/7:
+    # continuous coverage is OSS-Fuzz/ClusterFuzzLite, tracked in docs/TODO.md.
+    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+    runs-on: ubuntu-latest
+    timeout-minutes: 70
+    strategy:
+      fail-fast: false
+      matrix:
+        target: [token_form, dcr_json, jar_claims]
+    defaults:
+      run:
+        working-directory: fuzz
+    steps:
+      - uses: actions/checkout@v4
+      # `fuzz/rust-toolchain.toml` selects nightly; the root stable pin is
+      # untouched, exactly as in `fuzz-build`.
+      - name: Install cargo-fuzz (nightly)
+        run: cargo install cargo-fuzz --locked
+      # `uses` steps resolve paths from the workspace root, not the job's
+      # `working-directory`; keep the fuzz/ prefix explicit.
+      - name: Restore corpus for ${{ matrix.target }}
+        uses: actions/cache/restore@v4
+        with:
+          path: fuzz/corpus/${{ matrix.target }}
+          key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
+          restore-keys: |
+            fuzz-corpus-${{ matrix.target }}-
+      - name: Fuzz ${{ matrix.target }} (bounded)
+        run: cargo fuzz run ${{ matrix.target }} -- -max_total_time=600 -rss_limit_mb=2048
+      # Save only on success; a per-run key grows the corpus while the prefix
+      # `restore-keys` always pick the newest entry.
+      - name: Save corpus for ${{ matrix.target }}
+        if: success()
+        uses: actions/cache/save@v4
+        with:
+          path: fuzz/corpus/${{ matrix.target }}
+          key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
+      - name: Upload crash artifacts
+        if: failure()
+        uses: actions/upload-artifact@v4
+        with:
+          name: fuzz-crash-${{ matrix.target }}
+          path: fuzz/artifacts/${{ matrix.target }}
+          if-no-files-found: ignore
+
   mutants:
     name: mutants
     # Scheduled budgeted job only: never blocks a PR.

+ 21 - 7
deploy/k8s/configmap.yaml

@@ -2,11 +2,13 @@
 #
 # Every key maps to a `D_OAUTH_*` environment variable read by
 # `Config::load` (prefix `D_OAUTH`, nested separator `__`). Secret material
-# (database/redis URLs, token pepper, TOTP key) lives in `secret.yaml` and
-# is NEVER added here: the pepper and TOTP key are file-mounted by the
-# Deployment, and their `*_FILE` paths are declared in `deployment.yaml`,
-# not in this ConfigMap (`scripts/check_manifests.py` rejects a
-# secret-shaped key here).
+# (the database/redis passwords, token pepper, TOTP key) lives in
+# `secret.yaml` and is NEVER added here. The passwordless DSNs below are not
+# secret and live here; the workloads add the passwords from mounted files
+# (`D_OAUTH_DATABASE__PASSWORD_FILE` / `D_OAUTH_REDIS__PASSWORD_FILE`), and
+# their `*_FILE` paths are declared in `deployment.yaml`, not in this
+# ConfigMap (`scripts/check_manifests.py` rejects a secret-shaped key here and
+# a DSN with an embedded password).
 apiVersion: v1
 kind: ConfigMap
 metadata:
@@ -20,6 +22,15 @@ data:
   D_OAUTH_ENVIRONMENT: "production"
   D_OAUTH_BIND_ADDR: "0.0.0.0:8080"
   D_OAUTH_HEALTH_ADDR: "0.0.0.0:8081"
+  # Passwordless DSN. Production requires `sslmode=verify-full`; the password
+  # comes from the mounted `database-password` file. `verify-full` also needs a
+  # trusted CA: put the Postgres CA at `/certs/db-ca.crt` (a `db-ca.crt` key on
+  # the `d-oauth-tls` Secret; see tls-secret.example.yaml) and the root_cert_path
+  # below pins it. A server with a publicly-trusted certificate can drop the
+  # root_cert_path and rely on system roots instead. Edit the host/user/database
+  # for the target environment.
+  D_OAUTH_DATABASE__URL: "postgres://oauth@postgres.d-oauth.svc.cluster.local:5432/oauth?sslmode=verify-full"
+  D_OAUTH_DATABASE__ROOT_CERT_PATH: "/certs/db-ca.crt"
   D_OAUTH_TLS__CERT_PATH: "/certs/tls.crt"
   D_OAUTH_TLS__KEY_PATH: "/certs/tls.key"
   # Client-CA enables in-process mTLS verification. While set, the cluster
@@ -27,6 +38,9 @@ data:
   # terminates TLS strips the client certificate. See docs/DEPLOYMENT.md and
   # scripts/check_manifests.py, which rejects the forbidden pairing.
   D_OAUTH_TLS__CLIENT_CA_PATH: "/certs/client-ca.crt"
-  # Single-Redis topology takes a plain URL; the contract the server asserts
-  # at startup is noeviction + AOF, configured on the Redis side.
+  # Single-Redis topology takes a bare URL (no credentials; the mounted
+  # `redis-password` file is injected at connect time). The durability
+  # contract the server asserts at startup is noeviction + AOF, configured on
+  # the Redis side.
+  D_OAUTH_REDIS__URL: "redis://redis.d-oauth.svc.cluster.local:6379"
   D_OAUTH_REDIS__DURABILITY: "waitaof"

+ 26 - 28
deploy/k8s/deployment.yaml

@@ -15,15 +15,16 @@
 #   `automountServiceAccountToken: false` (the server never calls the
 #   Kubernetes API). `networkpolicy.yaml` default-denies pod egress except DNS
 #   + Postgres + Redis.
-# - Secret material is file-mounted, never env-injected: the token pepper and
-#   TOTP key are projected from `d-oauth-secrets` into a tmpfs volume at
-#   `/run/secrets` (`defaultMode: 0400`, group 65532 via `fsGroup`) and read
-#   through `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`.
-#   The inline `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms
-#   remain supported and take precedence when both are set
-#   (`src/config/build.rs`), but this deployment never sets them, so no key
-#   material enters the process environment (`/proc/self/environ`, children,
-#   crash dumps, `docker inspect`).
+# - Secret material is file-mounted, never env-injected: the token pepper,
+#   TOTP key, and the Postgres/Redis passwords are projected from
+#   `d-oauth-secrets` into a tmpfs volume at `/run/secrets` (`defaultMode:
+#   0400`, group 65532 via `fsGroup`) and read through their `*_FILE`
+#   variables. The passwordless DSNs come from the ConfigMap. The inline
+#   `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain
+#   supported and take precedence when both are set (`src/config/build.rs`),
+#   but this deployment never sets them, so no secret enters the process
+#   environment (`/proc/self/environ`, children, crash dumps, `docker
+#   inspect`) — a `kubectl exec` reads nothing from the environment.
 # - The image is digest-pinned. The all-zero digest below is a deliberate
 #   placeholder: the `pin-digest` job in `.github/workflows/supply-chain.yml`
 #   writes the real signed-build digest into the `images` entry of
@@ -90,21 +91,14 @@ spec:
             - configMapRef:
                 name: d-oauth-config
           env:
-            - name: D_OAUTH_DATABASE__URL
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: database-url
-            - name: D_OAUTH_REDIS__URL
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: redis-url
-            # File-mounted key material: the values stay
-            # in the tmpfs secret volume, never in the process environment.
-            # The inline D_OAUTH_TOKEN_PEPPER / D_OAUTH_TOTP_ENCRYPTION_KEY
-            # forms are still supported and take precedence when both are set
-            # (`src/config/build.rs`); this deployment deliberately uses files.
+            # Every secret is a file, never an env value: code execution in
+            # the container cannot read the DB/Redis credentials or key
+            # material from the environment. The DSNs (non-secret) come from
+            # the ConfigMap above.
+            - name: D_OAUTH_DATABASE__PASSWORD_FILE
+              value: /run/secrets/database-password
+            - name: D_OAUTH_REDIS__PASSWORD_FILE
+              value: /run/secrets/redis-password
             - name: D_OAUTH_TOKEN_PEPPER_FILE
               value: /run/secrets/token-pepper
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
@@ -161,15 +155,19 @@ spec:
           secret:
             secretName: d-oauth-tls
             defaultMode: 0o400
-        # tmpfs-backed secret volume. `defaultMode:
-        # 0400` plus `fsGroup: 65532` leaves the projected files readable only
-        # by the runtime user/group. Only the two key files are projected;
-        # database-url and redis-url remain env secretKeyRefs by design.
+        # tmpfs-backed secret volume. `defaultMode: 0400` plus `fsGroup:
+        # 65532` leaves the projected files readable only by the runtime
+        # user/group. Every secret is projected here; nothing is injected
+        # through a `secretKeyRef` env.
         - name: secrets
           secret:
             secretName: d-oauth-secrets
             defaultMode: 0o400
             items:
+              - key: database-password
+                path: database-password
+              - key: redis-password
+                path: redis-password
               - key: token-pepper
                 path: token-pepper
               - key: totp-encryption-key

+ 15 - 16
deploy/k8s/migrate-job.yaml

@@ -10,10 +10,11 @@
 #
 # Hardened like the Deployment: non-root 65532,
 # read-only rootfs, ALL capabilities dropped, `automountServiceAccountToken:
-# false`, and the token pepper / TOTP key mounted as 0400 tmpfs files under
-# `/run/secrets` (read through the `*_FILE` variables) instead of env vars.
-# The image carries the same placeholder digest; `pin-digest` rewrites the
-# real digest in `kustomization.yaml` for every workload.
+# false`, and every secret (token pepper, TOTP key, DB/Redis passwords) mounted
+# as 0400 tmpfs files under `/run/secrets` (read through the `*_FILE`
+# variables) instead of env vars. The image carries the same placeholder
+# digest; `pin-digest` rewrites the real digest in `kustomization.yaml` for
+# every workload.
 apiVersion: batch/v1
 kind: Job
 metadata:
@@ -55,18 +56,12 @@ spec:
             - configMapRef:
                 name: d-oauth-config
           env:
-            - name: D_OAUTH_DATABASE__URL
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: database-url
-            - name: D_OAUTH_REDIS__URL
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: redis-url
-            # File-mounted key material, same pattern as the Deployment; the
-            # inline forms remain supported by `Config` but are not used here.
+            # Same file-only secret rule as the Deployment; the DSNs come from
+            # the ConfigMap.
+            - name: D_OAUTH_DATABASE__PASSWORD_FILE
+              value: /run/secrets/database-password
+            - name: D_OAUTH_REDIS__PASSWORD_FILE
+              value: /run/secrets/redis-password
             - name: D_OAUTH_TOKEN_PEPPER_FILE
               value: /run/secrets/token-pepper
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
@@ -101,6 +96,10 @@ spec:
             secretName: d-oauth-secrets
             defaultMode: 0o400
             items:
+              - key: database-password
+                path: database-password
+              - key: redis-password
+                path: redis-password
               - key: token-pepper
                 path: token-pepper
               - key: totp-encryption-key

+ 29 - 14
deploy/k8s/secret.yaml

@@ -1,12 +1,27 @@
 # App secrets (task 46). PLACEHOLDERS ONLY — never commit real values.
 #
-# Consumption: the Deployment and the migrate Job
-# project `token-pepper` and `totp-encryption-key` from this Secret into a
-# tmpfs volume at `/run/secrets` (`defaultMode: 0400`, group 65532 via the
-# pod `fsGroup`) and read them through `D_OAUTH_TOKEN_PEPPER_FILE` /
-# `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, so the key material never enters the
-# process environment. `database-url` and `redis-url` are still injected as
-# `secretKeyRef` environment variables.
+# Consumption: the Deployment and the migrate Job project every key below into
+# a tmpfs volume at `/run/secrets` (`defaultMode: 0400`, group 65532 via the
+# pod `fsGroup`) and read them through `*_FILE` variables, so no secret enters
+# the process environment (`/proc/self/environ`, children, crash dumps,
+# `docker inspect`):
+#
+#   token-pepper        -> D_OAUTH_TOKEN_PEPPER_FILE
+#   totp-encryption-key -> D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
+#   database-password   -> D_OAUTH_DATABASE__PASSWORD_FILE
+#   redis-password      -> D_OAUTH_REDIS__PASSWORD_FILE
+#
+# The passwordless DSNs (host/user/db/sslmode and the Redis address) are
+# non-secret and live in `configmap.yaml`; only the passwords are here. A
+# `kubectl exec` into a running pod therefore reads no credential from the
+# environment — only the 0400 files, which the container user already holds.
+# `scripts/check_manifests.py` fails the build if a workload injects any secret
+# through a `secretKeyRef` env instead.
+#
+# In-cluster Redis is expected to run with AUTH (`requirepass`); the password
+# is injected into the bare `redis://host:port` URL at connect time. A Redis
+# deployment without AUTH must enable it, or the workload fails closed on the
+# missing password file.
 #
 # Production rule: this file's keys are replaced by the secret manager
 # (external-secrets / sealed-secrets / vault agent). The manifests only ever
@@ -15,8 +30,9 @@
 # manifest, or if a secret-shaped key lands in the ConfigMap.
 #
 # To provision: `kubectl create secret generic d-oauth-secrets -n d-oauth
-#   --from-literal=database-url='postgres://...' ...` (or the external-secret
-#   equivalent), then delete this placeholder file from the applied set.
+#   --from-literal=database-password='...' --from-literal=redis-password='...'
+#   ...` (or the external-secret equivalent), then delete this placeholder
+# file from the applied set.
 apiVersion: v1
 kind: Secret
 metadata:
@@ -27,11 +43,10 @@ metadata:
     app.kubernetes.io/part-of: d-oauth
 type: Opaque
 stringData:
-  # Full Postgres URL. Production requires `sslmode=verify-full`.
-  database-url: "REPLACE_ME__postgres-url-with-sslmode-verify-full"
-  # Redis single-topology URL (no password embedded for cluster use; prefer
-  # the password-file mechanism when auth is enabled).
-  redis-url: "REPLACE_ME__redis-url"
+  # Postgres password, split out of the passwordless DSN in configmap.yaml.
+  database-password: "REPLACE_ME__postgres-password"
+  # Redis AUTH password, injected into the bare redis:// URL at connect time.
+  redis-password: "REPLACE_ME__redis-password"
   # 256-bit server-side pepper for opaque-token hashing. Losing it invalidates
   # every live token; rotate per docs/OPERATIONS.md.
   token-pepper: "REPLACE_ME__32-bytes-of-random"

+ 9 - 3
deploy/k8s/tls-secret.example.yaml

@@ -6,12 +6,17 @@
 #   kubectl create secret generic d-oauth-tls -n d-oauth \
 #     --from-file=tls.crt=./tls.crt \
 #     --from-file=tls.key=./tls.key \
-#     --from-file=client-ca.crt=./client-ca.crt
+#     --from-file=client-ca.crt=./client-ca.crt \
+#     --from-file=db-ca.crt=./db-ca.crt
 #
 # `client-ca.crt` is REQUIRED while `D_OAUTH_TLS__CLIENT_CA_PATH` is set in
 # the ConfigMap (in-process mTLS verification). Omit the file AND unset the
-# env key together if mTLS is not used. Never bake this material into the
-# image; `scripts/check_manifests.py` fails on PEM blocks inside manifests.
+# env key together if mTLS is not used.
+#
+# `db-ca.crt` is the Postgres CA for `sslmode=verify-full`, read through
+# `D_OAUTH_DATABASE__ROOT_CERT_PATH`. Omit it and that key together only if the
+# database serves a publicly-trusted certificate. Never bake this material into
+# the image; `scripts/check_manifests.py` fails on PEM blocks inside manifests.
 # ---
 # apiVersion: v1
 # kind: Secret
@@ -23,3 +28,4 @@
 #   tls.crt: "<server certificate PEM>"
 #   tls.key: "<server private key PEM>"
 #   client-ca.crt: "<client CA bundle PEM>"
+#   db-ca.crt: "<Postgres CA bundle PEM>"

+ 5 - 1
docs/CONFIGURATION.md

@@ -91,6 +91,9 @@ Production requires `sslmode=verify-full` in `database.url` unless
 | `database.require_verify_full` | `true` |
 
 `max_connections` must be greater than zero and at least `min_connections`.
+`database.root_cert_path` (or `sslrootcert` in the URL) is required in practice
+for `sslmode=verify-full` unless the server certificate chains to a system root;
+the cluster ConfigMap pins `/certs/db-ca.crt`.
 
 ## Redis keys
 
@@ -123,7 +126,8 @@ none, is rejected as ambiguous.
 
 Durability must match the topology. Single node requires `waitaof` or
 `appendfsync_always`; replicated topologies require `wait`. A mismatch is
-rejected.
+rejected. `redis.password_file` holds the raw password and is applied to the
+connection directly, so it may contain any bytes (no URL encoding required).
 
 ## Key-ring keys
 

+ 66 - 26
docs/DEPLOYMENT.md

@@ -230,16 +230,18 @@ A correct Deployment has these properties:
   grace budget.
 - A `PodDisruptionBudget` with `minAvailable: 2`, and
   `topologySpreadConstraints` so replicas spread across nodes.
-- A `Service`, a `ConfigMap` for non-secret settings, and a `Secret` for
-  secret material. Secrets are mounted or injected, never baked into the
-  image. The token pepper and TOTP key are file-mounted under `/run/secrets`
-  (see below), not injected into the environment.
+- A `Service`, a `ConfigMap` for non-secret settings (including the
+  passwordless DSNs), and a `Secret` for secret material. Every secret is
+  file-mounted under `/run/secrets` (see below), never injected into the
+  environment or baked into the image.
 - A `NetworkPolicy` that default-denies pod egress except DNS, Postgres, and
   Redis, and admits ingress only on the two pod ports (see below).
 - A digest-pinned image reference. The release pipeline replaces the committed
   placeholder digest in `kustomization.yaml` with the signed build digest;
   `:latest` is never a shippable reference.
-- Client-CA material mounted read-only if in-process mTLS is used.
+- Client-CA material mounted read-only if in-process mTLS is used. The Postgres
+  CA for `sslmode=verify-full` is mounted at `/certs/db-ca.crt` and pinned with
+  `database.root_cert_path` (`d-oauth-tls` also carries a `db-ca.crt` key).
 
 ### Migrations run once
 
@@ -254,26 +256,33 @@ rollout. See [DATABASE.md](DATABASE.md).
 
 ### Secret material as files
 
-The Deployment does not put the token pepper or the TOTP encryption key in the
-environment. It projects the two keys of the `d-oauth-secrets` Secret into a
-tmpfs volume at `/run/secrets`, `defaultMode: 0400`, with the pod's `fsGroup`
-set to `65532`, and points the loader at them:
+The Deployment puts no secret in the environment. It projects every key of the
+`d-oauth-secrets` Secret into a tmpfs volume at `/run/secrets`,
+`defaultMode: 0400`, with the pod's `fsGroup` set to `65532`, and points the
+loader at them:
 
 ```text
 D_OAUTH_TOKEN_PEPPER_FILE=/run/secrets/token-pepper
 D_OAUTH_TOTP_ENCRYPTION_KEY_FILE=/run/secrets/totp-encryption-key
+D_OAUTH_DATABASE__PASSWORD_FILE=/run/secrets/database-password
+D_OAUTH_REDIS__PASSWORD_FILE=/run/secrets/redis-password
 ```
 
-Only the runtime user/group can read the files, so the key material never
-appears in `/proc/self/environ`, in child processes, in `docker inspect` /
-`kubectl describe` output, or in a crash dump. `database.url` and `redis.url`
-are still `secretKeyRef` environment values; prefer `database.password_file`
-where the deployment can split the credential from the URL. The inline
-`D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain supported
-and take precedence when both are set (`src/config/build.rs`), so an existing
-environment-based deployment still starts; the shipped manifests deliberately
-never set them. The migrate Job mounts the same files. To rotate, replace the
-Secret and roll the replicas; see [CONFIGURATION.md](CONFIGURATION.md).
+The passwordless DSNs are non-secret and live in the ConfigMap
+(`D_OAUTH_DATABASE__URL` with `sslmode=verify-full` and `D_OAUTH_REDIS__URL`);
+only the passwords come from the Secret files. Only the runtime user/group can
+read the files, so no credential or key material ever appears in
+`/proc/self/environ`, in child processes, in `docker inspect` / `kubectl
+describe` output, or in a crash dump — a `kubectl exec` into the container
+reads nothing useful from the environment. `scripts/check_manifests.py` fails
+the build if a workload injects a secret through a `secretKeyRef` env, or if a
+ConfigMap DSN embeds a password. The inline `D_OAUTH_TOKEN_PEPPER` /
+`D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain supported and take precedence when
+both are set (`src/config/build.rs`), so an existing environment-based
+deployment still starts; the shipped manifests deliberately never set them.
+The migrate Job mounts the same files. To rotate a password, update it in
+Postgres/Redis and the Secret together, then roll the replicas; see
+[CONFIGURATION.md](CONFIGURATION.md) and [OPERATIONS.md](OPERATIONS.md).
 
 ### Network policy
 
@@ -297,6 +306,15 @@ manifest; verify the cluster CNI applies it.
   namespace is still admitted because the ingress rule constrains ports, not
   sources.
 
+The policy deliberately denies outbound HTTPS (443). The JAR `request_uri`
+fetch (`src/jose/jar/fetch.rs`) retrieves a pre-registered request object from
+a publicly routable HTTPS host, so it cannot reach its target under the shipped
+default-deny. A deployment that relies on remote request objects must add a
+scoped egress rule to that specific host on 443 — never a blanket 443
+allowance, which would restore arbitrary exfiltration. A deployment that does
+not can leave the default-deny in place and treat remote `request_uri` as
+unavailable. The tension is recorded in [TODO.md](TODO.md).
+
 ### Multi-replica validation
 
 A local compose file is the intended way to prove shared-state behavior: three
@@ -304,6 +322,20 @@ app replicas against one Postgres and one Redis, completing an
 authorization-code flow whose token issued by one replica is introspectable via
 another. The compose file is the local analogue of the cluster manifests.
 
+## Optional: sandboxed runtime class
+
+`RuntimeDefault` seccomp plus non-root, dropped capabilities, and a read-only
+rootfs already block most post-RCE paths. A deployment that also wants to
+contain a kernel or runtime escape can run under a sandboxed runtime class
+(gVisor or Kata Containers). The manifests do not set `runtimeClassName`
+because availability is cluster-specific: if the target cluster provides one,
+set it on the Deployment and the migrate Job and verify that the health probes
+and the Postgres/Redis connections still work under the sandboxed syscall
+surface. A custom seccomp allowlist is deliberately not shipped — maintaining
+one by hand across toolchain and dependency upgrades costs more than the
+marginal reduction over `RuntimeDefault`. This is an operational option, not a
+code change.
+
 ## Graceful shutdown
 
 On `SIGTERM` the server:
@@ -349,6 +381,8 @@ The image pipeline is gated on:
 - Digest pinning, with a periodic rebuild so base digests advance.
 - `cargo deny check` in CI, which enforces the ring-only TLS policy in
   `deny.toml`.
+- A per-PR fuzz build plus a scheduled bounded run of every fuzz target
+  (`.github/workflows/ci.yml`); a crash uploads its artifact and fails the run.
 
 ## Security properties
 
@@ -356,8 +390,13 @@ The image pipeline is gated on:
   surface to the binary.
 - Non-root with a read-only root filesystem.
 - Secrets and CA material are mounted, never baked into the image or manifests.
-- The token pepper and TOTP key are `0400` tmpfs files, not environment
-  variables, and the pod carries no service-account token.
+- Every secret — token pepper, TOTP key, and the database and Redis passwords —
+  is a `0400` tmpfs file, not an environment variable, and the pod carries no
+  service-account token.
+- `kubectl exec` / `docker exec` reach a non-root process with a read-only
+  rootfs, no capabilities, and no shell, but still bypass the application's own
+  auth; restrict `pods/exec` with RBAC and audit it (see
+  [OPERATIONS.md](OPERATIONS.md)).
 - A NetworkPolicy default-denies pod egress except DNS, Postgres, and Redis,
   and admits ingress only on the two pod ports.
 - Health routes are unreachable from the public listener, so the probe port is
@@ -412,11 +451,12 @@ d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
   cosign signing, digest pinning of `deploy/k8s/kustomization.yaml`, and the
   weekly rebuild.
 - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
-  (non-secret settings only), `secret.yaml` (placeholder keys, file-mounted by
-  the workloads and replaced by the secret manager), `service.yaml` (public
-  TLS port only, never the health port), `deployment.yaml` (3 stateless
-  replicas, probes on the plaintext health listener, PDB + topology spread,
-  `automountServiceAccountToken: false`, pepper/TOTP file mounts),
+  (non-secret settings only, including the passwordless DSNs), `secret.yaml`
+  (placeholder secrets — pepper, TOTP key, database and Redis passwords — all
+  file-mounted and replaced by the secret manager), `service.yaml` (public TLS
+  port only, never the health port), `deployment.yaml` (3 stateless replicas,
+  probes on the plaintext health listener, PDB + topology spread,
+  `automountServiceAccountToken: false`, every secret file-mounted),
   `migrate-job.yaml` (exactly-once schema migration, never per replica),
   `networkpolicy.yaml` (default-deny egress except DNS/Postgres/Redis, ingress
   to the two pod ports), `pdb.yaml` (`minAvailable: 2`), `kustomization.yaml`

+ 9 - 0
docs/OPERATIONS.md

@@ -243,6 +243,12 @@ survives a Redis loss.
 - A restricted session (forced password change or missing TOTP) cannot reach
   `/authorize`, the OAuth endpoints, or admin-gated actions.
 - Secrets stay out of argv, logs, and audit events.
+- The container is non-root with a read-only rootfs and no shell, and every
+  secret is a mounted file, so `kubectl exec` / `docker exec` reads no
+  credential from the environment. Exec still bypasses the application's own
+  authorization — the binary *is* the admin CLI — so it must be restricted.
+- Grant `pods/exec` only to a break-glass role and enable Kubernetes audit
+  logging for the `pods/exec` subresource.
 
 ## Edge cases
 
@@ -264,6 +270,9 @@ survives a Redis loss.
   entitlements; `user create` for users.
 - Key rotation: overlap the pepper for at least the refresh lifetime.
 - Backup: `pg_dump` Postgres; Redis is not backed up.
+- Access: prefer running admin commands as a one-shot Job (or the local CLI
+  against the database) over `exec` into a running replica. If `exec` is
+  needed, restrict `pods/exec` to a break-glass role and audit the subresource.
 - Health: liveness `GET /health/live`, readiness `GET /health/ready` on the
   plaintext health listener.
 

+ 7 - 5
docs/SECURITY.md

@@ -56,16 +56,18 @@ class, or runtime setting).
 | Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims | Fuzzing is not exhaustive. |
 | Dependency / supply-chain compromise | Committed `Cargo.lock`, `cargo deny`/`cargo audit` in CI, SBOM + provenance + keyless cosign, digest-pinned bases | A zero-day in a trusted crate. |
 | Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault` | A kernel LPE or runc bug, mitigated but not eliminated. |
-| Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP key live outside the database; the Deployment mounts the pepper and TOTP key as `0400` tmpfs files under `/run/secrets` and reads them through `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, so no key material enters the environment | `database-url` and `redis-url` are still `secretKeyRef` environment values; an attacker with code execution reads those. |
+| Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP key live outside the database; the Deployment mounts every secret — pepper, TOTP key, and the database/Redis passwords — as `0400` tmpfs files under `/run/secrets` (read through their `*_FILE` variables), and the passwordless DSNs come from the ConfigMap, so no secret enters the environment | An attacker with code execution can still read the mounted secret files under `/run/secrets`; that is the same capability the server has. |
 | Exposed management surface | The plaintext health listener (8081) is unauthenticated but not published in the Service (only 8080 is) | A misconfigured Service/Ingress could expose it; it must never be internet-facing. |
 | Host access via mounted volumes | `/certs` is mounted read-only at mode `0400`; no Docker socket is mounted | Operator misconfiguration. |
-| Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379), with link-local `169.254.0.0/16` excluded; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. |
+| Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379), with link-local `169.254.0.0/16` excluded; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. The default-deny also blocks the JAR `request_uri` HTTPS fetch — a deployment that needs it must allow the specific host on 443 (see `docs/DEPLOYMENT.md`). |
+| Operator `exec` into a running pod | No shell and a non-root, read-only-rootfs container limit what runs; every secret is a file the container user already reads, and no secret is in the environment | `pods/exec` (and `docker exec`) bypass the application's own authorization; restrict it with RBAC and audit it (`docs/OPERATIONS.md`). |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
 | Image/registry compromise | Digest pinning, cosign verification, and a committed digest reference with no shippable `:latest`; CI writes the signed build digest into `kustomization.yaml` | A registry compromise before the digest is recorded still ships; the pin step must run. |
 
-Last reviewed: 2026-10-04. File-mounted key material, digest pinning,
-NetworkPolicy, and `automountServiceAccountToken: false` are implemented in
-the shipped manifests; the remaining mitigations are tracked in `docs/TODO.md`.
+Last reviewed: 2026-10-04. File-mounted DB/Redis passwords, digest pinning,
+NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
+implemented in the shipped manifests and workflows; the remaining mitigations
+are tracked in `docs/TODO.md`.
 
 ## Toplevel rules
 

+ 34 - 39
docs/TODO.md

@@ -80,43 +80,34 @@ list is deliberately short and ordered by value.
 
 ## Container and host hardening
 
-The attack surface and the existing controls are documented in
-[SECURITY.md](SECURITY.md); this section lists only the mitigations to close the
-residual risks.
-
-This section covers a second threat-model question: if an attacker is already
-inside the image's trust boundary, how could they reach the container or the
-host, and how should we reduce that risk? The image is already hardened: it runs
-as non-root UID 65532, uses a read-only root filesystem, drops all capabilities,
-sets `allowPrivilegeEscalation: false` and `seccompProfile: RuntimeDefault`,
-ships no shell, builds a static musl binary from digest-pinned bases, and
-produces an SBOM, provenance, and a cosign signature in CI. The remaining vectors
-are therefore narrow, and none of them is a current defect. This section lists
-the mitigations to implement.
-
-### Mitigations to plan and implement
-
-### 5. Strengthen runtime isolation
-
-- **Risk.** A kernel or runtime escape is possible after code execution.
-- **Proposed change.** Consider a sandboxed runtime class (gVisor/Kata) and a
-  custom seccomp allowlist instead of `RuntimeDefault`.
-- **Files.** `deploy/k8s/deployment.yaml`, `docs/DEPLOYMENT.md`.
-
-### 6. Continuous fuzzing and dependency review
-
-- **Risk.** CI-only fuzzing and `cargo deny` do not catch a zero-day in a
-  trusted crate.
-- **Proposed change.** Run the fuzz targets continuously and add
-  `cargo vet`/`cargo crev` review.
-- **Files.** `.github/workflows/`, `fuzz/`, `deny.toml`.
-
-### 7. Restrict and audit container exec
-
-- **Risk.** `docker exec` / `kubectl exec` into the running container bypasses
-  every image control.
-- **Proposed change.** Restrict who may exec and audit it.
-- **Files.** `docs/OPERATIONS.md`, `docs/DEPLOYMENT.md`.
+The attack surface and the controls already in place are documented in
+[SECURITY.md](SECURITY.md); this section lists only the mitigations that remain.
+It answers a second threat-model question: if an attacker is already inside the
+image's trust boundary, how could they reach the container or the host, and how
+should we reduce that risk? The remaining vectors are narrow.
+
+### 5. NetworkPolicy versus remote `request_uri`
+
+- **Risk.** The default-deny egress blocks outbound HTTPS, so the JAR
+  `request_uri` fetch (`src/jose/jar/fetch.rs`) cannot reach a publicly routable
+  request-object host. A deployment either relies on a feature that silently
+  fails, or loosens egress further than intended.
+- **Proposed change.** Decide per deployment: allow the specific request-object
+  host on 443 with a scoped rule, or document that remote `request_uri` is
+  unavailable under the shipped policy. A blanket 443 allowance is not an
+  option because it restores arbitrary exfiltration.
+- **Files.** `deploy/k8s/networkpolicy.yaml`, `docs/DEPLOYMENT.md`.
+
+### 6. Narrow Postgres/Redis egress to their destination
+
+- **Risk.** `networkpolicy.yaml` allows egress to *any* address on ports 5432 and
+  6379 (link-local excluded), so those ports are an exfiltration channel to an
+  arbitrary internet host, not only the database and cache.
+- **Proposed change.** Where Postgres and Redis run in-cluster, replace the
+  `ipBlock: 0.0.0.0/0` peer with their `namespaceSelector`/`podSelector` so only
+  the real endpoints are reachable; keep a port-scoped rule only for an external
+  managed database.
+- **Files.** `deploy/k8s/networkpolicy.yaml`, `docs/DEPLOYMENT.md`.
 
 ## Priority
 
@@ -125,7 +116,8 @@ Ranked by value against effort:
 - **Medium:** items 1, 2, 3. Item 1 is medium/high effort because it needs a
   migration plus a blind index with uniqueness and rotation handling.
 - **Lower, mostly operational:** item 4.
-- **Container/host hardening:** items 5, 6, 7 are medium/operational.
+- **Container/host hardening:** item 5 is the concrete correctness risk; item 6
+  is the egress-narrowing follow-up.
 
 Recommendation: implement item 1 next. It closes the largest plaintext
 disclosure; items 2 and 3 then reduce the cost of a future key compromise.
@@ -136,4 +128,7 @@ disclosure; items 2 and 3 then reduce the cost of a future key compromise.
 - Is the token pepper and TOTP key held in mounted files or a secrets manager
   in the target deployment? This decides whether envelope encryption (item 2)
   is worth building at all.
-- Is a sandboxed runtime class (gVisor/Kata) available in the target cluster?
+- Which target environments rely on remote JAR `request_uri` objects? This
+  decides whether item 5 needs a scoped egress rule or only documentation.
+- Do Postgres and Redis run in-cluster? This decides whether item 6 can narrow
+  egress to their namespace/selector.

+ 71 - 9
scripts/check_manifests.py

@@ -12,11 +12,18 @@ contract WITHOUT a live cluster:
 - Migration Job: exactly one, runs `migrate`, `restartPolicy: Never`.
 - PDB: `minAvailable: 2`.
 - Service: never exposes the health port (8081).
-- Hardening: `automountServiceAccountToken: false` and `fsGroup: 65532`; the
-  token pepper and TOTP key must be file-mounted from `d-oauth-secrets` at
-  `/run/secrets` (`defaultMode: 0400`) and referenced with
-  `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`; the inline
-  `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` envs FAIL.
+- Hardening: `automountServiceAccountToken: false` and `fsGroup: 65532`; every
+  secret (token pepper, TOTP key, database and Redis passwords) must be
+  file-mounted from `d-oauth-secrets` at `/run/secrets` (`defaultMode: 0400`)
+  and referenced with its `*_FILE` variable; the inline
+  `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` envs FAIL, and any
+  workload env that injects a secret FAILS (`secretKeyRef`, `envFrom.secretRef`,
+  a literal value embedding a DSN password, or a workload redefinition of
+  `D_OAUTH_DATABASE__URL` / `D_OAUTH_REDIS__URL`).
+- ConfigMap: the ConfigMap referenced by a workload `envFrom` must provide the
+  passwordless `D_OAUTH_DATABASE__URL` / `D_OAUTH_REDIS__URL` DSNs, and neither
+  may embed a password (it belongs in the Secret, read through the
+  `*_PASSWORD_FILE` form).
 - NetworkPolicy: must select `app.kubernetes.io/name: d-oauth-server`, enable
   `Ingress` + `Egress`, admit ingress only on 8080/8081, and allow egress only
   on 53 (DNS), 5432 (Postgres), and 6379 (Redis) with `169.254.0.0/16`
@@ -54,6 +61,8 @@ SECRET_NAME_RE = re.compile(r"(?i)(pepper|password|secret|private[_-]?key|totp)"
 PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
 DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
 HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
+# A DSN that carries `user:password@` (or `:password@`) embeds a credential.
+EMBEDDED_PASSWORD_RE = re.compile(r"://[^/@?#]*:[^/@?#]*@")
 
 CLIENT_CERT_ANNOTATIONS = (
   "nginx.ingress.kubernetes.io/auth-tls-verify-client",
@@ -69,8 +78,11 @@ SECRET_VOLUME_MODE = 0o400
 FILE_SECRET_ENV_KEYS = {
   "D_OAUTH_TOKEN_PEPPER_FILE": "token-pepper",
   "D_OAUTH_TOTP_ENCRYPTION_KEY_FILE": "totp-encryption-key",
+  "D_OAUTH_DATABASE__PASSWORD_FILE": "database-password",
+  "D_OAUTH_REDIS__PASSWORD_FILE": "redis-password",
 }
 INLINE_SECRET_ENVS = ("D_OAUTH_TOKEN_PEPPER", "D_OAUTH_TOTP_ENCRYPTION_KEY")
+PASSWORDLESS_DSN_KEYS = ("D_OAUTH_DATABASE__URL", "D_OAUTH_REDIS__URL")
 REQUIRED_EGRESS_PORTS = (53, 5432, 6379)
 METADATA_CIDR = "169.254.0.0/16"
 
@@ -122,13 +134,39 @@ def check_pem_and_placeholders(docs: list[dict], fails: Failures) -> None:
       fails.add(f"{src}: placeholder belongs only in secret.yaml")
 
 
-def check_configmap(doc: dict, fails: Failures) -> None:
+def check_configmap(doc: dict, fails: Failures, requires_dsn: bool) -> None:
   data = doc.get("data") or {}
   for key in data:
     if SECRET_NAME_RE.search(key):
       fails.add(f"configmap: secret-shaped key {key!r} must live in the Secret")
-  if (data.get("D_OAUTH_TLS__CLIENT_CA_PATH") or "").strip():
-    pass  # mTLS on: L7 check below becomes load-bearing.
+  if not requires_dsn:
+    return
+  for key in PASSWORDLESS_DSN_KEYS:
+    value = (data.get(key) or "").strip()
+    if not value:
+      fails.add(
+        f"configmap: {key} is required; the workloads take the DSN from here "
+        "and the password from the mounted *_PASSWORD_FILE"
+      )
+    elif EMBEDDED_PASSWORD_RE.search(value):
+      fails.add(
+        f"configmap: {key} must not embed a password; move it to the Secret "
+        "and use the *_PASSWORD_FILE form"
+      )
+
+
+def referenced_configmaps(docs: list[dict]) -> set[str]:
+  """Names of ConfigMaps any workload pulls into the environment via `envFrom`."""
+  names: set[str] = set()
+  for kind in ("Deployment", "Job"):
+    for doc in by_kind(docs, kind):
+      tpl = (doc.get("spec") or {}).get("template", {}).get("spec", {})
+      for container in tpl.get("containers") or []:
+        for ref in container.get("envFrom") or []:
+          name = ((ref or {}).get("configMapRef") or {}).get("name")
+          if name:
+            names.add(name)
+  return names
 
 
 def mtls_enabled(docs: list[dict]) -> bool:
@@ -172,6 +210,28 @@ def check_file_mounted_secrets(
   """Item 7: pepper/TOTP are 0400 files under /run/secrets, never inline env."""
   cname = container.get("name", "?")
   envs = env_by_name(container)
+  for ref in container.get("envFrom") or []:
+    if isinstance(ref, dict) and "secretRef" in ref:
+      fails.add(
+        f"{workload}/{cname}: envFrom.secretRef injects every secret into the "
+        "environment; mount the keys as files instead"
+      )
+  for name, entry in envs.items():
+    if "secretKeyRef" in (entry.get("valueFrom") or {}):
+      fails.add(
+        f"{workload}/{cname}: env {name} injects a secret via secretKeyRef; "
+        "mount it as a file and reference the *_FILE form"
+      )
+    if name in PASSWORDLESS_DSN_KEYS:
+      fails.add(
+        f"{workload}/{cname}: {name} must come from the ConfigMap, not the "
+        "workload env (an inline value could embed a password)"
+      )
+    value = entry.get("value")
+    if isinstance(value, str) and EMBEDDED_PASSWORD_RE.search(value):
+      fails.add(
+        f"{workload}/{cname}: env {name} embeds a password; mount it as a file"
+      )
   for inline in INLINE_SECRET_ENVS:
     if inline in envs:
       fails.add(
@@ -404,8 +464,10 @@ def main() -> int:
       fails.add(f"missing required kind: {required}")
 
   check_pem_and_placeholders(docs, fails)
+  required_dsn_configmaps = referenced_configmaps(docs)
   for doc in by_kind(docs, "ConfigMap"):
-    check_configmap(doc, fails)
+    name = (doc.get("metadata") or {}).get("name")
+    check_configmap(doc, fails, requires_dsn=name in required_dsn_configmaps)
   for doc in by_kind(docs, "Deployment"):
     check_deployment(doc, fails, args.strict_digest)
   for doc in by_kind(docs, "Job"):

+ 2 - 2
src/cli/context.rs

@@ -8,7 +8,6 @@
 
 use std::{io::Read as _, path::Path, sync::Arc};
 
-use secrecy::ExposeSecret as _;
 use sqlx::PgPool;
 
 use crate::config::Config;
@@ -108,11 +107,12 @@ impl Context {
 /// Returns [`CliError::Database`] when the pool cannot connect.
 pub async fn open_pool(config: &Config) -> Result<PgPool, CliError> {
   let db = &config.database;
+  let options = crate::state::connect_options(db, config.environment)?;
   let pool = sqlx::postgres::PgPoolOptions::new()
     .max_connections(db.max_connections)
     .min_connections(0)
     .acquire_timeout(db.acquire_timeout)
-    .connect(db.url.expose_secret())
+    .connect_with(options)
     .await?;
   Ok(pool)
 }

+ 1 - 1
src/cli/test_support.rs

@@ -168,7 +168,7 @@ async fn build() -> Result<Harness, TestError> {
   })
 }
 
-async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
+pub(crate) async fn postgres_container() -> Result<(ContainerGuard, String), TestError> {
   let container = GenericImage::new("postgres", "16-alpine")
     .with_exposed_port(5432.tcp())
     .with_wait_for(WaitFor::message_on_stderr(

+ 35 - 0
src/cli/tests.rs

@@ -677,3 +677,38 @@ fn admin_bootstrap_runbook_matches_cli() -> Result<(), TestError> {
   }
   Ok(())
 }
+
+/// `open_pool` must honor the mounted database password: a passwordless DSN
+/// plus `password_file` connects (the cluster migrate-Job contract).
+#[tokio::test]
+async fn open_pool_uses_mounted_database_password() -> Result<(), TestError> {
+  let (_postgres, pg_url) = test_support::postgres_container().await?;
+  let user_prefix = "postgres://postgres";
+  let user_end = user_prefix.len();
+  assert!(pg_url.starts_with(&format!("{user_prefix}:")));
+  let at = pg_url
+    .find('@')
+    .ok_or(TestError::Missing("container url carries userinfo"))?;
+  let password = pg_url[user_end + 1..at].to_owned();
+  let passwordless = format!("{}{}", &pg_url[..user_end], &pg_url[at..]);
+  let pw_path = std::env::temp_dir().join(format!(
+    "d-oauth-cli-open-pool-pw-{}.txt",
+    uuid::Uuid::new_v4()
+  ));
+  std::fs::write(&pw_path, format!("{password}\n"))?;
+  let fixture = serde_json::json!({
+    "issuer": "https://auth.example.test",
+    "environment": "development",
+    "tls": {"cert_path": "/tmp/cli-test-cert.pem", "key_path": "/tmp/cli-test-key.pem"},
+    "database": {"url": passwordless, "password_file": pw_path.to_string_lossy()},
+    "redis": {"url": "redis://127.0.0.1:6379", "durability": "waitaof"},
+    "token_pepper": "cli-test-pepper-0123456789abcdef",
+    "totp_encryption_key": "cli-test-totp-key-0123456789abcdef",
+  });
+  let source = ::config::Config::try_from(&fixture)?;
+  let config = Config::from_source(source)?;
+  let pool = super::context::open_pool(&config).await?;
+  sqlx::query("SELECT 1").execute(&pool).await?;
+  let _ = std::fs::remove_file(&pw_path);
+  Ok(())
+}

+ 3 - 1
src/config.rs

@@ -326,7 +326,9 @@ pub(crate) fn read_secret_file(path: Option<&Path>) -> Result<Option<SecretStrin
         path: path.to_path_buf(),
         source,
       })?;
-      Ok(Some(SecretString::from(contents.trim().to_owned())))
+      Ok(Some(SecretString::from(
+        contents.trim_end_matches(['\r', '\n']).to_owned(),
+      )))
     }
   }
 }

+ 15 - 0
src/config/tests.rs

@@ -287,3 +287,18 @@ fn config_env_double_underscore_maps_to_nested_snake_keys() -> Result<(), Box<dy
   );
   Ok(())
 }
+
+#[test]
+fn read_secret_file_preserves_surrounding_spaces() -> Result<(), Box<dyn Error>> {
+  use secrecy::ExposeSecret as _;
+  let path = std::env::temp_dir().join(format!(
+    "d-oauth-read-secret-file-{}.txt",
+    std::process::id()
+  ));
+  std::fs::write(&path, "  spaced-secret  \n")?;
+  let secret = super::read_secret_file(Some(path.as_path()))?;
+  let _ = std::fs::remove_file(&path);
+  let secret = secret.ok_or("secret file returned None")?;
+  assert_eq!(secret.expose_secret(), "  spaced-secret  ");
+  Ok(())
+}

+ 1 - 0
src/state.rs

@@ -21,6 +21,7 @@ mod tests_resilience;
 mod tests_topology;
 
 pub use health::health_router;
+pub(crate) use pg::connect_options;
 pub use redis::RedisConn;
 pub use sentinel::SentinelConn;
 

+ 1 - 1
src/state/pg.rs

@@ -42,7 +42,7 @@ fn has_explicit_sslmode(url: &str) -> bool {
 }
 
 /// Build `PgConnectOptions` from [`DbConfig`] without opening connections.
-fn connect_options(
+pub(crate) fn connect_options(
   db: &DbConfig,
   environment: Environment,
 ) -> Result<PgConnectOptions, StateError> {

+ 21 - 17
src/state/redis.rs

@@ -18,7 +18,7 @@
 use std::fmt;
 
 use redis::{
-  AsyncConnectionConfig, Client, RedisFuture, Value,
+  AsyncConnectionConfig, Client, ConnectionInfo, IntoConnectionInfo, RedisFuture, Value,
   aio::{ConnectionLike, ConnectionManager, ConnectionManagerConfig},
   cluster::ClusterClientBuilder,
   cluster_async::ClusterConnection,
@@ -90,30 +90,34 @@ impl ConnectionLike for RedisConn {
   }
 }
 
-/// Inject `cfg.password` (mounted secret file) into a bare `redis://` URL.
+/// Apply `cfg.password` (mounted secret file) to a bare `redis://` URL.
 ///
-/// Passwords containing URL-significant bytes must already be percent-encoded
-/// in the URL itself; this only handles the common alphanumeric-secret case.
-fn single_url(url: &secrecy::SecretString, password: Option<&secrecy::SecretString>) -> String {
-  let url = url.expose_secret().to_owned();
-  let Some(password) = password else {
-    return url;
-  };
-  if url.contains('@') {
-    return url;
-  }
-  match url.split_once("://") {
-    Some((scheme, rest)) => format!("{scheme}://:{}@{rest}", password.expose_secret()),
-    None => url,
+/// The URL is parsed into a [`ConnectionInfo`] first and the password is set
+/// programmatically, so the secret never passes through URL string
+/// interpolation: any password bytes are safe (no percent-encoding needed),
+/// and a `Some` password is always applied even when the URL already carries
+/// userinfo without a password.
+fn single_connection_info(
+  url: &secrecy::SecretString,
+  password: Option<&secrecy::SecretString>,
+) -> Result<ConnectionInfo, redis::RedisError> {
+  let mut info: ConnectionInfo = url.expose_secret().into_connection_info()?;
+  if let Some(password) = password {
+    let redis_settings = info
+      .redis_settings()
+      .clone()
+      .set_password(password.expose_secret());
+    info = info.set_redis_settings(redis_settings);
   }
+  Ok(info)
 }
 
 async fn connect_single(
   config: &RedisConfig,
   url: &secrecy::SecretString,
 ) -> Result<RedisConn, StateError> {
-  let url = single_url(url, config.password.as_ref());
-  let client = Client::open(url.as_str()).map_err(StateError::Redis)?;
+  let info = single_connection_info(url, config.password.as_ref()).map_err(StateError::Redis)?;
+  let client = Client::open(info).map_err(StateError::Redis)?;
   let manager_config = ConnectionManagerConfig::new()
     .set_number_of_retries(config.retry_budget as usize)
     .set_min_delay(config.retry_min_delay)

+ 50 - 0
src/state/tests_topology.rs

@@ -103,6 +103,56 @@ async fn health_single_uses_password_from_config() -> TestResult {
   Ok(())
 }
 
+/// Single node with a URL-significant AUTH password: the mounted-file
+/// password is applied programmatically, so `/ ? # % @ :` never misroute.
+#[tokio::test]
+async fn health_redis_single_password_special_chars_connects() -> TestResult {
+  let secret = "p/a?s#s%e@c:d";
+  let (_pg, pg_url) = postgres_container().await?;
+  let (_redis, redis_url) = redis_single_container(Some(secret)).await?;
+  let pw_path = std::env::temp_dir().join("d-oauth-health-redis-pw-special.txt");
+  std::fs::write(&pw_path, format!("{secret}\n"))
+    .map_err(|_| TestError::Body("pw write".into()))?;
+  let redis_block = serde_json::json!({
+    "url": redis_url,
+    "durability": "waitaof",
+    "aof_required": false,
+    "password_file": pw_path.to_string_lossy(),
+  });
+  let config = test_config(&pg_url, redis_block)?;
+  let state = AppState::connect(Arc::new(config)).await?;
+  mark_migrated(&state).await?;
+  let (ready_status, _) = get_health(&state, "/health/ready").await?;
+  assert_eq!(ready_status, 200);
+  let _ = std::fs::remove_file(&pw_path);
+  Ok(())
+}
+
+/// Single node where the URL carries userinfo but no password: the
+/// mounted-file password must still be applied, never silently dropped.
+#[tokio::test]
+async fn health_redis_single_password_userinfo_url_connects() -> TestResult {
+  let secret = "health-redis-pw-userinfo-4d1";
+  let (_pg, pg_url) = postgres_container().await?;
+  let (_redis, redis_url) = redis_single_container(Some(secret)).await?;
+  let userinfo_url = redis_url.replacen("://", "://default@", 1);
+  let pw_path = std::env::temp_dir().join("d-oauth-health-redis-pw-userinfo.txt");
+  std::fs::write(&pw_path, format!("{secret}\n"))
+    .map_err(|_| TestError::Body("pw write".into()))?;
+  let redis_block = serde_json::json!({
+    "url": userinfo_url,
+    "durability": "waitaof",
+    "aof_required": false,
+    "password_file": pw_path.to_string_lossy(),
+  });
+  let config = test_config(&pg_url, redis_block)?;
+  let state = AppState::connect(Arc::new(config)).await?;
+  mark_migrated(&state).await?;
+  let (ready_status, _) = get_health(&state, "/health/ready").await?;
+  assert_eq!(ready_status, 200);
+  let _ = std::fs::remove_file(&pw_path);
+  Ok(())
+}
 /// Start a Sentinel pair: `master` serves data, `sentinel` monitors it.
 /// Returns the guards plus the sentinel bridge address.
 async fn sentinel_pair() -> Result<(ContainerGuard, ContainerGuard, String), TestError> {