From cd063cc0b6be3d109eb372059aa79635f084c6d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 21:54:05 +0900 Subject: [PATCH 01/22] fix(auth): fail closed without write-capable admin on public bind Non-loopback BIND_ADDR refuses readiness unless ADMIN_TOKEN, ADMIN_TOKENS, or WAF_IDS_CREDENTIALS_PATH supplies a write-capable principal. Loopback development still starts and reports auth_mode=development. Management writes return 401 vs 403; presented secrets compare in constant time. Blank WAF_IDS_STATE_PATH is in-memory state, not a post-ready crash. Fixes #78. --- .gitignore | 1 + AGENTS.md | 2 +- CHANGELOG.md | 21 ++ CLAUDE.md | 2 +- README.md | 2 +- docs/adr/0001-figma-and-design-system.md | 40 +++ docs/architecture.md | 11 + docs/deployment/production.md | 2 +- docs/doctoring/fail-closed-management-auth.md | 58 ++++ docs/product-technical-gap-baseline.md | 159 +++++++++ docs/runbooks/operations.md | 8 +- docs/security/threat-model.md | 3 +- docs/ui-ux/storybook-scene-inventory.md | 67 ++++ scripts/smoke.sh | 10 +- src/credentials.rs | 101 ++++++ src/lib.rs | 324 ++++++++++++++---- tests/binary.rs | 33 +- 17 files changed, 773 insertions(+), 71 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/adr/0001-figma-and-design-system.md create mode 100644 docs/doctoring/fail-closed-management-auth.md create mode 100644 docs/product-technical-gap-baseline.md create mode 100644 docs/ui-ux/storybook-scene-inventory.md diff --git a/.gitignore b/.gitignore index 408e06d1..2156b24e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target /waf-ids-state*.json /runtime-state*.json +/.codegraph diff --git a/AGENTS.md b/AGENTS.md index 2a32694b..192c3dad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Cross-agent conventions for any agent (Claude, Codex, Cursor, opencode, …) wor ### Code exploration -- There is no `.codegraph/` index in this repo, so use normal search (grep/ripgrep, `cargo` tooling, editor navigation). If a `.codegraph/` index is added later, prefer CodeGraph (`codegraph explore ""` or the code-review-graph MCP tools) before grep/find — it surfaces callers/callees/impact that text search misses. +- A local `.codegraph/` index may exist at the repo root (gitignored, not committed). If it is present, prefer CodeGraph (`codegraph explore ""` or the code-review-graph MCP tools) before grep/find — it surfaces callers/callees/impact that text search misses. If it is absent, use grep/`cargo`/editor navigation, and `codegraph init` is permitted. If `codegraph status` reports an unhealthy index, run `codegraph sync`. ### Config & secrets (KV, not env) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6a328e42 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Security + +- Fail closed before readiness when `BIND_ADDR` is not loopback-only and no write-capable admin principal is configured (`ADMIN_TOKEN`, `ADMIN_TOKENS`, or `WAF_IDS_CREDENTIALS_PATH`). Loopback development may still start without a token and reports `auth_mode=development` on `/healthz`. +- A blank `WAF_IDS_STATE_PATH` is treated as in-memory state instead of becoming ready and then failing to replace an empty path. +- Management writes now distinguish `401` (unauthenticated) from `403` (authenticated, not permitted to write) without naming the expected role. +- Presented admin secrets are compared in constant time. Duplicate, blank, and unknown `ADMIN_TOKENS` roles fail startup. + +### Documentation + +- Product/technical gap baseline at `docs/product-technical-gap-baseline.md` (open PRs/Issues inventory, operator-perceptible gaps, Figma file IDs, UI-UX areas). +- File://-openable admin-console scene and edge-case inventory at `docs/ui-ux/storybook-scene-inventory.md`. +- Figma file IDs recorded in `docs/adr/0001-figma-and-design-system.md` and `docs/architecture.md`. diff --git a/CLAUDE.md b/CLAUDE.md index f6a0a676..4a270df1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ The core stays an in-repo workspace crate on purpose (no git submodule) until it ## Runtime Configuration -Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`), `ADMIN_TOKEN` (write token for `X-Admin-Token`), `ADMIN_TOKENS` (comma-separated `token:actor` pairs for multi-token RBAC with per-token audit actors), `WAF_IDS_STATE_PATH` (optional JSON state file; omitted = seeded in-memory state), `DNSBL_ORIGIN` (default `dnsbl.local`), `EVENT_LIMIT` (default 1000, must be > 0), `RATE_LIMIT` / `RATE_LIMIT_WINDOW`. +Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`; non-loopback requires a write-capable admin principal before bind), `ADMIN_TOKEN` (write token for `X-Admin-Token`), `ADMIN_TOKENS` (comma-separated `token:actor` pairs for multi-token RBAC with per-token audit actors), `WAF_IDS_STATE_PATH` (optional JSON state file; omitted = seeded in-memory state), `DNSBL_ORIGIN` (default `dnsbl.local`), `EVENT_LIMIT` (default 1000, must be > 0), `RATE_LIMIT` / `RATE_LIMIT_WINDOW`. ## Key Conventions diff --git a/README.md b/README.md index d1587583..c2036b64 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Open `http://127.0.0.1:8080/admin`. Useful environment variables: - `BIND_ADDR`: listen address, default `127.0.0.1:8080` -- `ADMIN_TOKEN`: optional write token for management writes via `X-Admin-Token` +- `ADMIN_TOKEN`: write token for management writes via `X-Admin-Token`. Optional only on loopback (`127.0.0.1` / `::1` / `localhost`). Required before readiness on any non-loopback `BIND_ADDR` (`0.0.0.0`, `::`, LAN, public). - `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero diff --git a/docs/adr/0001-figma-and-design-system.md b/docs/adr/0001-figma-and-design-system.md new file mode 100644 index 00000000..acfa7ce9 --- /dev/null +++ b/docs/adr/0001-figma-and-design-system.md @@ -0,0 +1,40 @@ +# ADR 0001 — Figma design-system source and embedded admin console + +Status: accepted +Date: 2026-08-23 + +## Context + +Wardnet ships an operator console as vanilla HTML/CSS/JS embedded in the Rust +binary (`ADMIN_HTML` in `src/lib.rs`, served at `GET /` and `/admin`). Repeating +objects (KPI tiles, cards, tables, badges, buttons, forms, toasts) must stay +token-based. A Node Storybook toolchain cannot be loaded by that console without +a separate static site. + +## Decision + +- Canonical design-system tokens live in CSS custom properties on `:root` + (`docs/design-system.md` matches the running `/admin` CSS). +- Figma is the visual mirror, not a runtime dependency. **Figma Code Connect is + not used** (repo `AGENTS.md`). +- Record file IDs here so operators and agents can open the same files. + +## Figma file IDs + +| Artifact | File ID | URL | +| --- | --- | --- | +| Design system / console frames | `QTH5UuU0FJv2VyM2xb02Fp` | https://www.figma.com/design/QTH5UuU0FJv2VyM2xb02Fp | +| Enterprise product architecture FigJam | `JExziD87eUWKLERECUGhWQ` | https://www.figma.com/board/JExziD87eUWKLERECUGhWQ | + +## Scene and edge-case events + +Scene-by-scene and edge-case event definitions for the ten UI-UX areas live in +`docs/ui-ux/storybook-scene-inventory.md`, which opens from disk (`file://`) +without a Node Storybook server. That inventory is the Storybook-equivalent +contract for this embedded-console architecture. + +## Consequences + +- Token changes must land in `ADMIN_HTML` and `docs/design-system.md` together. +- Do not add a frontend framework to `/admin`; it would break the + binary-embedded load path used by `scripts/smoke.sh`. diff --git a/docs/architecture.md b/docs/architecture.md index 89291bf5..31281c0d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,6 +53,7 @@ flowchart LR - Default bind address is localhost. - Remote management requires `ADMIN_TOKEN` plus external TLS and identity controls. +- Non-loopback listeners fail closed before readiness unless a write-capable admin principal is configured. - `WAF_IDS_STATE_PATH` enables JSON state persistence for standalone operation. Without it, the service uses seeded in-memory state. - File-backed writes use temporary sibling files followed by atomic rename. Management API mutations roll back in memory if the state file cannot be replaced. - Block mode is route-scoped to avoid global accidental enforcement. @@ -62,7 +63,17 @@ flowchart LR ## Product Architecture Evidence +- Figma design-system file ID: `QTH5UuU0FJv2VyM2xb02Fp` (see `docs/design-system.md` and `docs/adr/0001-figma-and-design-system.md`) +- FigJam architecture file ID: `JExziD87eUWKLERECUGhWQ` (`docs/figma/enterprise-product-architecture.md`) - FigJam: `docs/figma/enterprise-product-architecture.md` - Product workflows: `docs/product-design/enterprise-operator-workflows.md` - Enterprise scorecard: `docs/analytics/enterprise-value-scorecard.md` - Complexity audit: `docs/ponytail/2026-07-02-complexity-audit.md` +- UI-UX scene / edge-case inventory (file://-openable): `docs/ui-ux/storybook-scene-inventory.md` +- Product/technical gap baseline: `docs/product-technical-gap-baseline.md` + +## Security Boundaries (credentials) + +- Default bind address is loopback. Loopback-only development may start without an admin token and reports `auth_mode=development` on `/healthz`. +- Any non-loopback `BIND_ADDR` fails closed before readiness unless a write-capable principal is present in the credential registry (`ADMIN_TOKEN`, `ADMIN_TOKENS`, or `WAF_IDS_CREDENTIALS_PATH`). +- Management writes return `401` when unauthenticated and `403` when authenticated but not permitted to write. Response bodies do not name the expected role. diff --git a/docs/deployment/production.md b/docs/deployment/production.md index 34ff1978..a8d5eca1 100644 --- a/docs/deployment/production.md +++ b/docs/deployment/production.md @@ -40,7 +40,7 @@ kubectl apply -f deploy/kubernetes/waf-ids-ai-soc.yaml - Terminate TLS in front of the service. - Expose `/admin` and `/api/*` only through identity-aware access. - Configure upstream allowlists and egress policy. -- Store `ADMIN_TOKEN` in a secret manager. +- Store `ADMIN_TOKEN` in a secret manager. The process **will not become ready** on `BIND_ADDR=0.0.0.0:8080` (or any non-loopback address) if no write-capable credential is configured. That is intentional fail-closed behavior (issue #78). Recovery: inject the Secret, restart; do not disable the gate. - Mount persistent state or replace JSON persistence with a database. - Run `scripts/smoke.sh` before promoting a release. - Keep block mode route-scoped and reversible. diff --git a/docs/doctoring/fail-closed-management-auth.md b/docs/doctoring/fail-closed-management-auth.md new file mode 100644 index 00000000..7b74b8e2 --- /dev/null +++ b/docs/doctoring/fail-closed-management-auth.md @@ -0,0 +1,58 @@ +# Doctoring — fail-closed management authentication + +This note grounds the issue #78 implementation (non-loopback listeners refuse to +become ready without a write-capable admin principal; `401` vs `403`; +constant-time secret compare). IEEE PDFs are not redistributed; freely licensed +standards are cited by URL. + +## Adopted standards and literature + +Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in +computer systems. *Proceedings of the IEEE*, *63*(9), 1278–1308. +https://doi.org/10.1109/PROC.1975.9939 + +- **Design impact:** Fail-safe defaults — a missing access rule is deny, not + allow. Wardnet previously treated an empty credential registry as “auth + disabled” for management writes. That violates fail-safe defaults as soon as + `BIND_ADDR` is not loopback-only. Startup now refuses readiness in that case. + +OWASP Foundation. (2025). *OWASP Application Security Verification Standard +5.0.0*. https://owasp.org/www-project-application-security-verification-standard/ + +- **Design impact:** ASVS V6 authentication and V4 access control require + authentication for administrative functions and distinct authorization + outcomes. Management writes use `401` when no valid principal is presented and + `403` when a readonly principal attempts a mutation. Bodies do not name the + expected secret or role. + +National Institute of Standards and Technology. (2022). *Secure Software +Development Framework (SSDF) version 1.1* (NIST SP 800-218). +https://doi.org/10.6028/NIST.SP.800-218 + +- **Design impact:** PW.1 / PW.5 — produce well-secured software and protect + authentication data. Secrets bootstrap into `CredentialRegistry`; health + exposes `credentials_source` and `auth_mode` labels only. + +MITRE. (n.d.). *CWE-306: Missing authentication for critical function*. +https://cwe.mitre.org/data/definitions/306.html + +- **Design impact:** Management APIs that mutate routes, threat indicators, + DNSBL, license, and feeds are critical functions. The CWE-306 anti-pattern is + “auth optional on a reachable listener.” The shipped gate is + `require_write_auth_for_bind` in `src/credentials.rs`, invoked from + `run_from_env` before `TcpListener::bind`. + +## Exact-head binding + +| Decision | Implementation | +| --- | --- | +| Fail closed on public bind | `require_write_auth_for_bind` + `run_from_env` | +| Loopback development remains usable | `listen_is_loopback_only`; `/healthz.auth_mode=development` | +| 401 vs 403 | `reject_management_write` | +| Constant-time compare | `constant_time_eq` over RBAC map and shared token | +| Ambiguous token registry | `parse_admin_tokens_strict` (duplicate / blank / unknown role) | + +PII is **not** masked on security events: SOC operators cannot do their job if +client IPs, paths, and indicator values are redacted. Access control, audit, +and encryption-at-rest (when a durable store lands) are the alternatives to +masking. See `docs/product-technical-gap-baseline.md`. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..ba35c5e7 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,159 @@ +# Product and technical gap baseline + +Snapshot date: 2026-08-23 (exact-head inventory of then-open GitHub PRs and +Issues plus operator-perceptible gaps). Update this file on every hourly loop. + +Commercial contract and `/api/commercial/readiness` remain **2B KRW**. The +**$20 billion USD** figure is the long-loop quality bar for this program, not a +number to rewrite into the readiness API this pass. + +PII policy: **do not mask** client IPs, paths, indicator values, or actor names +on SOC surfaces. Masking blinds incident response. Alternative controls: fail-closed +authentication, RBAC, audit log, credential registry (no secrets in health/support +bundle), encryption-at-rest when PostgreSQL lands, purpose limitation in runbooks. +CSAP/SOC 2 remain uncertified; see `docs/security/compliance-mapping.md`. + +## Then-open pull requests + +Org ruleset `CWL Central required workflows` (id `18156473`) requires +**two** approving reviews, `require_last_push_approval=true`, and +`required_review_thread_resolution=true`. Code-owner review is disabled +(solo maintainer). This actor (`seonghobae`) cannot satisfy a second +independent human approval on self-authored PRs and cannot bypass the +ruleset (`current_user_can_bypass: never`). That is a **policy blocker**, +not “waiting on review/CI time”. + +| PR | Title | Head | Checks | Reviews | Merge blocker | +| --- | --- | --- | --- | --- | --- | +| [#93](https://github.com/ContextualWisdomLab/wardnet/pull/93) | test(persistence): replace permission-based fault injection with a deterministic seam | `fix/issue-74-deterministic-persistence-fault` | rust + Security Scan green; **strix FAILURE** (job `97189711094`). Run log HTTP 404 from this token against wardnet and org `.github`. | Author `seonghobae`; Devin comments (info). | (1) strix FAILURE — log not readable (404); (2) org 2-approval ruleset + self-author. | +| [#92](https://github.com/ContextualWisdomLab/wardnet/pull/92) | build(deps): bump github/codeql-action/upload-sarif from 4.37.6 to 4.37.7 | dependabot | All green (27). | Maintainer APPROVED (1 of 2). | Org ruleset: 2 approving reviews. Last pusher is dependabot so this approval counts; **second independent APPROVE missing**. | +| [#91](https://github.com/ContextualWisdomLab/wardnet/pull/91) | build(deps): bump futures-util from 0.3.33 to 0.3.34 | dependabot | All green (27). | Maintainer APPROVED (1 of 2). | Same as #92: second independent APPROVE missing. | +| [#90](https://github.com/ContextualWisdomLab/wardnet/pull/90) | feat(observability): export Wardnet events to SIEM and OpenTelemetry | `feat/siem-opentelemetry-export` | All green (35). | Author `seonghobae`; CodeRabbit/Devin/GHAS comments (redaction of `key: value` credential shapes; `python` vs `python3` in runbook). | Org 2-approval + self-author; unresolved review threads (`required_review_thread_resolution`). | +| [#88](https://github.com/ContextualWisdomLab/wardnet/pull/88) | feat(security): reject non-LiteLLM credentials before upstream | `feat/litellm-virtual-key-ingress-guard` | All green (35). | Author `seonghobae`; CodeRabbit (header length cap, fuzz target, literature). | Org 2-approval + self-author; unresolved threads. | +| [#77](https://github.com/ContextualWisdomLab/wardnet/pull/77) | build(rust): pin and track Rust 1.97.1 | `agent/rust-toolchain-refresh-2026-08-19` | rust green; **strix FAILURE** (job `97001450437`, log 404). | Author `seonghobae`. | strix FAILURE (unread log) + org 2-approval + self-author. | +| [#76](https://github.com/ContextualWisdomLab/wardnet/pull/76) | feat(ai): delegate SOC analysis to adaptive orchestration | `agent/adaptive-orchestrator-default` | All green (35). | Author `seonghobae`; CodeRabbit; opencode DISMISSED. | Org 2-approval + self-author. | +| [#72](https://github.com/ContextualWisdomLab/wardnet/pull/72) | fix(deploy): require externally provisioned admin secret | `automation/remove-placeholder-admin-secret` | All green (35). | Author `seonghobae`; **opencode-agent CHANGES_REQUESTED** (latest); some CodeRabbit threads confirmed fixed but unresolved in GitHub. | `CHANGES_REQUESTED` (opencode-agent) + unresolved threads + org 2-approval + self-author. | + +Dependabot #91 and #92 were approved by this actor; `gh pr merge` was rejected +by the base-branch policy (not by failing Checks). Do not `--admin` merge. + +## Then-open issues + +| Issue | Title | Priority | +| --- | --- | --- | +| [#89](https://github.com/ContextualWisdomLab/wardnet/issues/89) | Fail closed on invalid LiteLLM Virtual Keys and preserve safe upstream auth headers | medium | +| [#87](https://github.com/ContextualWisdomLab/wardnet/issues/87) | [Production readiness] Close the evidence-backed Wardnet production gate | medium | +| [#86](https://github.com/ContextualWisdomLab/wardnet/issues/86) | [P0] Put proven WAF/IDS engines in the enforcement path and publish detection-quality evidence | **critical** | +| [#85](https://github.com/ContextualWisdomLab/wardnet/issues/85) | [P1] Establish production telemetry, SLOs, incident response, and disaster-recovery evidence | high | +| [#84](https://github.com/ContextualWisdomLab/wardnet/issues/84) | [P1] Build an immutable signed release, promotion, and rollback pipeline | high | +| [#83](https://github.com/ContextualWisdomLab/wardnet/issues/83) | [P1] Add bounded distributed admission control, trusted client attribution, and overload behavior | high | +| [#82](https://github.com/ContextualWisdomLab/wardnet/issues/82) | [P1] Integrate Keyverse identity, tenant authorization, consent, and human approval evidence | high (blocked) | +| [#81](https://github.com/ContextualWisdomLab/wardnet/issues/81) | [P0] Add a transactional outbox and idempotent leased workers for external effects | **critical** | +| [#80](https://github.com/ContextualWisdomLab/wardnet/issues/80) | [P0] Add an authoritative PostgreSQL control plane with tenant isolation and recoverable migrations | **critical** | +| [#79](https://github.com/ContextualWisdomLab/wardnet/issues/79) | [P0] Enforce a fail-closed destination policy for all outbound traffic | **critical** | +| [#78](https://github.com/ContextualWisdomLab/wardnet/issues/78) | [P0] Fail closed when management credentials are absent | **critical — closed in runtime this pass** | +| [#75](https://github.com/ContextualWisdomLab/wardnet/issues/75) | Rename Kubernetes manifest to wardnet.yaml after external-secret hardening lands | medium | +| [#74](https://github.com/ContextualWisdomLab/wardnet/issues/74) | Make persistence failure tests deterministic across root and constrained filesystems | medium (PR #93) | +| [#38](https://github.com/ContextualWisdomLab/wardnet/issues/38) | AI SOC: quarantine-sandbox malware analysis for attachment/link lures | medium (blocked) | +| [#11](https://github.com/ContextualWisdomLab/wardnet/issues/11) | 서버를 켜고 Strix가 포트를 향해 각종 공격을 할 때 감지해내야 함 (CI) | medium | + +## Operator-perceptible product / technical gaps + +### Crate / repo name split + +GitHub repo and product name are **wardnet**. Cargo package and process log +still say `waf-ids-ai-soc`. Kubernetes manifest remains +`deploy/kubernetes/waf-ids-ai-soc.yaml` (issue #75 waits on #72). Cheap alias +this pass: docs and health copy already mention Wardnet in newer surfaces; +wholesale crate rename is deferred (not a merge blocker). + +### Proven-engine enforcement (issue #86) + +Coraza audit JSON and Suricata EVE ingest exist as **admin-token import +adapters**. They are not an in-process enforcement engine on `/gateway`. +Scoring still uses in-repo indicators + DNSBL. Do not invent CRS replacements. + +### Identity (issue #82, Keyverse) + +Management auth is shared secrets (`X-Admin-Token`) plus optional multi-token +RBAC. Keyverse (OIDC/SCIM/FIDO2) is not wired. Fail-closed (#78) is the +prerequisite shipped this pass. + +### Durable control plane (issue #80) + +Optional JSON file + atomic rename. Not PostgreSQL, no tenant isolation, no +migrations, no hot-partition strategy. 3NF/snake_case two-word names apply when +the store lands. + +### Fail-closed credentials (issue #78) — **closed this pass** + +Shipped: + +- `require_write_auth_for_bind` in `src/credentials.rs` (driven by unit tests + and by `run_from_env` / the real binary). +- Non-loopback `BIND_ADDR` without a write-capable principal exits before bind + (`tests/binary.rs::binary_fail_closes_non_loopback_listen_without_admin`). +- Loopback remains usable; `/healthz.auth_mode` is `development` or `production`. +- `401` vs `403` on management writes; constant-time compare; strict + `ADMIN_TOKENS` parser. + +Doctoring: `docs/doctoring/fail-closed-management-auth.md` (APA 7th). + +### Destination policy (issue #79) + +Upstream scheme validation exists; no fail-closed destination allowlist for all +outbound (feed fetch, proxy, LLM, Clearfolio). Next-loop candidate after #78. + +### SIEM / OpenTelemetry (issue #85 / PR #90) + +`/api/events.ndjson` and stdout JSON lines exist on main. Full exporter binary +and OTel sit on PR #90, blocked by the 2-approval ruleset. + +### UI-UX / Storybook / Figma + +| Item | Status | +| --- | --- | +| Design tokens | CSS custom properties in `ADMIN_HTML`; documented in `docs/design-system.md` | +| Figma design file | `QTH5UuU0FJv2VyM2xb02Fp` — ADR 0001 | +| FigJam architecture | `JExziD87eUWKLERECUGhWQ` | +| Figma Code Connect | Not used | +| Ten UI-UX areas | Inventoried in `docs/ui-ux/storybook-scene-inventory.md` | +| Node Storybook | **Not hosted in `/admin`** (embedded-console architecture). File:// inventory is the scene/edge-case contract this pass. | + +### CSAP / SOC 2 vs PII unmasking + +No certification claim. Compliance map lists SSDLC, access control, audit, +availability gaps (signed releases, SSO, HA storage). PII masking would stop +SOC work; we do not ship it. Controls: authn/z, audit, secret hygiene, +future encryption-at-rest. + +### Coverage / docstring bar + +Org 100% line/branch/docstring applies to **changed** surfaces this loop +(credentials gate, health `auth_mode`, 401/403 helper, binary fail-closed). +Remaining holes on untouched handlers stay listed for later loops. + +### Ecosystem connectors (leverage order) + +1. **keyverse** — identity for management plane (#82). +2. **contextual-orchestrator** — SOC LLM already optional via + `SOC_LLM_BASE_URL`; keep adapter, do not fork routing. +3. **naruon** / **clearfolio** — document viewer already optional. +4. **TEPP / RankWeave / ThreadWeave / LineageWeave / disksage / fast-mlsirm** — + not on the gateway data path; no connector this pass. + +## This loop’s shipped gap + +Issue **#78**: fail-closed management credentials on non-loopback listen. +Operator-visible: a cluster bind (`0.0.0.0:8080`) without `ADMIN_TOKEN` no +longer becomes ready with open management writes. + +## Next hourly loop (do, do not report) + +1. Second independent APPROVE on #91/#92 (or OpenCode review agent APPROVE + without rotating review-agent secrets). +2. Fetch strix logs with an Actions-capable token; fix #93/#77 or re-run. +3. Address remaining CodeRabbit threads on #90/#88 if still on exact head. +4. Destination allowlist (#79) or in-path Coraza evidence (#86) — pick the + closer mergeable runtime gap. +5. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`. diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 9b6b7015..71736507 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -50,8 +50,9 @@ WAF_IDS_STATE_PATH=./waf-ids-state.local.json \ cargo run ``` -Health reports `credentials_source` (`file` / `env` / `none`) and -`admin_auth_configured` (boolean) without exposing secret values. +Health reports `credentials_source` (`file` / `env` / `none`), +`admin_auth_configured` (boolean), and `auth_mode` (`development` / +`production`) without exposing secret values. ## Health Check @@ -66,7 +67,8 @@ Expected fields: - `dnsbl_origin`: configured DNSBL origin without a trailing dot - `event_limit`: retained security event count - `credentials_source`: `file`, `env`, or `none` -- `admin_auth_configured`: whether any admin write token is configured +- `admin_auth_configured`: whether any admin token is configured +- `auth_mode`: `development` only on a loopback listener with no write-capable principal; otherwise `production`. A non-loopback process refuses to become ready without credentials (issue #78). ## Smoke Test diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 8cf0a35b..88afd112 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -16,13 +16,14 @@ - Operators use management APIs and the embedded admin console. - Upstream services are outside the process trust boundary. - The state file is trusted only after JSON deserialization succeeds. +- A non-loopback listener is untrusted until a write-capable admin principal exists in the credential registry. Missing credentials are a startup failure, not “auth disabled”. - Threat feed import payloads are untrusted operator-supplied data. ## Primary Threats | Threat | Impact | Current Control | Required Hardening | | --- | --- | --- | --- | -| Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; audit log for successful writes | SSO/OIDC, mTLS or identity proxy, SCIM | +| Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; audit log for successful writes; **fail-closed startup** on non-loopback bind without a write-capable principal; `401` vs `403` without leaking expected role; constant-time secret compare | SSO/OIDC via Keyverse, mTLS or identity proxy, SCIM | | Malicious threat feed import | False positives or broad blocks | Validation, route-scoped enforcement | Source signing, feed confidence, staged promotion | | State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error | Database, backup, schema migration | | Upstream SSRF through routes | Internal network exposure | Upstream scheme validation | Upstream allowlists, egress policy | diff --git a/docs/ui-ux/storybook-scene-inventory.md b/docs/ui-ux/storybook-scene-inventory.md new file mode 100644 index 00000000..b69ba703 --- /dev/null +++ b/docs/ui-ux/storybook-scene-inventory.md @@ -0,0 +1,67 @@ +# Admin console — scene and edge-case event inventory + +**Open this file from disk:** `file://` on +`docs/ui-ux/storybook-scene-inventory.md`. It is the Storybook-equivalent +contract for Wardnet’s **embedded** console (`ADMIN_HTML` in `src/lib.rs`). + +A Node Storybook (`storybookjs/storybook`) cannot be hosted inside `/admin` +without a separate static site the binary does not serve. This inventory +defines scenes, edge-case events, and the ten UI-UX areas so operators and +agents can review interaction without a JS toolchain. Tokens remain the source +for repeating objects (`docs/design-system.md`, Figma `QTH5UuU0FJv2VyM2xb02Fp`). + +Companion skills used while authoring: Storybook scene thinking, UI-UX Pro Max +checklists, Anti-Slop-UI (no generic dashboard chrome; this console is an +operator instrument panel). + +## How to exercise each scene + +1. `cargo run` (loopback; optional `ADMIN_TOKEN=dev-secret`). +2. Open `http://127.0.0.1:8080/admin`. +3. Drive the event in the table. Expected result is the operator’s next action, + not a status narrative. + +## Ten UI-UX areas + +| Area | Console contract | Edge-case events | +| --- | --- | --- | +| Accessibility | Skip link, wrapping labels, `th scope`, live regions, High Contrast `aria-pressed`, text+colour badges | Keyboard-only create-route; High Contrast + focus ring visible on every control; screen-reader hears KPI refresh (`aria-live=polite`) and toast (`assertive`) | +| Touch & Interaction | Controls `min-height: 44px` (WCAG 2.5.5) | Tap primary save on a 390px-wide viewport; toast auto-dismiss ~4.5s; do not rely on hover | +| Performance | No framework, no extra network for CSS/JS; `Promise.allSettled` per card | One failing `/api/*` card shows `.err` and does not blank the page; large event list capped at 25 with truncation copy | +| Style Selection | Default tokens vs High Contrast (`data-theme=hc`, `localStorage["waf-theme"]`) | Toggle High Contrast, reload, theme persists; never raw hex on a component | +| Layout & Responsive | Card grid `repeat(auto-fit, minmax(340px, 1fr))` | 1280px desktop and 390px mobile: header, KPI strip, and create forms remain usable; no horizontal trap | +| Typography & Color | `--fs-h1/h2/body/cap/metric`; WCAG table in design-system.md | Body `--ink` vs `--sub`; destructive `--fail` always with the word Block/Fail/Stale | +| Animation | None beyond toast dismiss; no decorative motion | Toast appears and leaves without blocking the next write | +| Forms & Feedback | `label.field` + `.field-help` matching server validators; toast `ok`/`bad` | Path without leading `/` → server error in toast; valid route → toast + table refresh; empty collection → `.empty` “No entries.” | +| Navigation Patterns | Single-page console; skip link to `#main`; header token field | Tab order: skip → token → High Contrast → main cards; `/` and `/admin` render the same console | +| Charts & Data | KPI tiles (not charts) + tables + raw `pre` for NDJSON/JSON/zone | KPI `…` while loading then numbers from `/api/kpis`; readiness checklist pass/fail rows; raw DNSBL zone is faithful text, not a redesigned chart | + +## Scene catalog (Storybook CSF analogue) + +Each scene is `(id, setup, event, expected next action)`. + +| Scene ID | Setup | Event | Expected next action | +| --- | --- | --- | --- | +| `kpi.strip.loaded` | Seeded state | Page load | Read route/threat/DNSBL/block counts; if Block is non-zero, open Events | +| `kpi.strip.error` | Stop API | Page load | Card/strip shows error copy; rest of page remains | +| `routes.table.empty` | No routes | Open Routes | “No entries.” then open the create `
` | +| `routes.create.ok` | Token set | Submit path `/secure`, upstream `mock://x`, mode Block | Toast success; table shows the row; next: send a gateway request | +| `routes.create.validation` | Token set | Submit path `secure` (no slash) | Toast with server validator text; fix the path | +| `routes.create.unauth` | Empty token, auth required | Submit create | Toast unauthorized; paste `X-Admin-Token` | +| `routes.create.forbidden` | Readonly token | Submit create | HTTP 403 / toast; switch to a write token | +| `threats.import.feed` | Token set | Import a reviewed feed | Toast upsert counts; open Freshness | +| `feeds.freshness.stale` | TTL expired feed | Open Freshness | Stale badge (text+colour); re-import or disable | +| `license.detail` | Seeded license | Open License | Definition list; missing optionals render `—` | +| `readiness.not-ready` | Missing license fields | Open Commercial readiness | Not-ready badge + one evidence string per failed check | +| `events.ndjson.export` | At least one blocked event | Open raw export | Copy NDJSON for SIEM; do not reformat | +| `dnsbl.zone.export` | Seeded DNSBL | Open zone | Copy RFC 5782 zone text to the authoritative DNS publisher | +| `audit.readonly` | Readonly token | Open audit logs | Table of writes; no create controls succeed | +| `theme.high-contrast` | Default theme | Toggle High Contrast | Borders `#000`; reload keeps theme | +| `gateway.block.demo` | Block route `/secure` | `GET /gateway/secure` | Blocked event appears; next: confirm KPI blocked count | + +## Storybook (Node) — deferred + +If a future pass adds a static `storybook/` package, CSF stories must mount +the same CSS tokens (not a parallel palette) and replay the events above. +Until then this file is the inventory of record. Do not claim `/admin` loads +Storybook. diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 5df0c730..de419f94 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -27,10 +27,14 @@ cleanup() { trap cleanup EXIT start_server() { + # Compile before the health wait so rustc time is not counted as a hang. + cargo build --quiet --manifest-path "$ROOT_DIR/Cargo.toml" ( cd "$ROOT_DIR" BIND_ADDR="127.0.0.1:$PORT" \ ADMIN_TOKEN="$ADMIN_TOKEN_VALUE" \ + ADMIN_TOKENS= \ + WAF_IDS_CREDENTIALS_PATH= \ WAF_IDS_STATE_PATH="$STATE_FILE" \ DNSBL_ORIGIN="dnsbl.test" \ EVENT_LIMIT="5" \ @@ -77,6 +81,8 @@ assert_json_field "$health" 'data["status"] == "ok"' assert_json_field "$health" 'data["persistence"] == "file"' assert_json_field "$health" 'data["dnsbl_origin"] == "dnsbl.test"' assert_json_field "$health" 'data["event_limit"] == 5' +assert_json_field "$health" 'data["admin_auth_configured"] is True' +assert_json_field "$health" 'data["auth_mode"] == "production"' curl -fsS "$BASE_URL/admin" | grep -q "ContextualWisdomLab WAF/IDS/AI SOC Gateway" @@ -187,7 +193,7 @@ assert_json_field "$support_bundle" 'data["kpis"]["fresh_threat_feed_count"] == assert_json_field "$support_bundle" 'data["audit_log_count"] >= 3' assert_json_field "$support_bundle" 'data["threat_feed_freshness"][0]["stale"] is False' -audit_logs="$(curl -fsS "$BASE_URL/api/audit-logs")" +audit_logs="$(curl -fsS -H "x-admin-token: $ADMIN_TOKEN_VALUE" "$BASE_URL/api/audit-logs")" assert_json_field "$audit_logs" 'any(log["action"] == "upsert_route" and log["resource_id"] == "block" for log in data)' assert_json_field "$audit_logs" 'any(log["action"] == "update_commercial_license" and log["resource_id"] == "cwlab-enterprise" for log in data)' assert_json_field "$audit_logs" 'any(log["action"] == "import_threat_feed" and log["resource_id"] == "misp-seoul" for log in data)' @@ -212,7 +218,7 @@ license="$(curl -fsS "$BASE_URL/api/commercial/license")" assert_json_field "$license" 'data["license_status"] == "active"' feeds="$(curl -fsS "$BASE_URL/api/threat-feeds")" assert_json_field "$feeds" 'len(data) == 1' -audit_logs="$(curl -fsS "$BASE_URL/api/audit-logs")" +audit_logs="$(curl -fsS -H "x-admin-token: $ADMIN_TOKEN_VALUE" "$BASE_URL/api/audit-logs")" assert_json_field "$audit_logs" 'len(data) >= 3' echo "smoke ok: $BASE_URL with state $STATE_FILE" diff --git a/src/credentials.rs b/src/credentials.rs index 02b7f39e..7478e2d1 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -133,6 +133,69 @@ impl CredentialRegistry { } } +/// Constant-time equality for presented admin secrets. +/// +/// Length is mixed into the accumulator so a mismatched length does not take a +/// faster path that would reveal the expected secret size. +pub fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let max = left.len().max(right.len()); + let mut diff = (left.len() ^ right.len()) as u8; + for i in 0..max { + let l = left.get(i).copied().unwrap_or(0); + let r = right.get(i).copied().unwrap_or(0); + diff |= l ^ r; + } + diff == 0 +} + +/// True when `bind_addr` can only be reached from the local host. +/// +/// Unparseable addresses return `false` (fail closed: require credentials). +pub fn listen_is_loopback_only(bind_addr: &str) -> bool { + let trimmed = bind_addr.trim(); + if trimmed.is_empty() { + return false; + } + if let Ok(addr) = trimmed.parse::() { + return addr.ip().is_loopback(); + } + let Some(host) = bind_host(trimmed) else { + return false; + }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + host.parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +fn bind_host(bind_addr: &str) -> Option<&str> { + if let Some(rest) = bind_addr.strip_prefix('[') { + let end = rest.find(']')?; + return Some(&rest[..end]); + } + bind_addr.rsplit_once(':').map(|(host, _)| host) +} + +/// Fail closed before readiness when a non-loopback listener has no write-capable +/// admin principal. Loopback-only development remains available without a token. +/// +/// This is the shipped gate behind [`crate::run_from_env`]; tests drive it +/// directly so the bind/listen path is not required to prove the policy. +pub fn require_write_auth_for_bind( + bind_addr: &str, + has_write_capable_admin: bool, +) -> Result<(), String> { + if has_write_capable_admin || listen_is_loopback_only(bind_addr) { + Ok(()) + } else { + Err(format!( + "refusing to bind {bind_addr} without a write-capable admin credential: set ADMIN_TOKEN, ADMIN_TOKENS, or WAF_IDS_CREDENTIALS_PATH before listening on a non-loopback address" + )) + } +} + fn json_value_as_nonempty_string(value: &serde_json::Value) -> Option { match value { serde_json::Value::String(text) if !text.is_empty() => Some(text.clone()), @@ -284,4 +347,42 @@ mod tests { assert!(err.contains("not valid JSON")); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn constant_time_eq_matches_equal_secrets_and_rejects_others() { + assert!(constant_time_eq(b"secret", b"secret")); + assert!(!constant_time_eq(b"secret", b"secreT")); + assert!(!constant_time_eq(b"secret", b"secret!")); + assert!(!constant_time_eq(b"secret", b"")); + assert!(constant_time_eq(b"", b"")); + } + + #[test] + fn listen_is_loopback_only_classifies_bind_addresses() { + assert!(listen_is_loopback_only("127.0.0.1:0")); + assert!(listen_is_loopback_only("127.0.0.1:8080")); + assert!(listen_is_loopback_only("[::1]:8080")); + assert!(listen_is_loopback_only("localhost:8080")); + assert!(listen_is_loopback_only("LOCALHOST:9")); + assert!(!listen_is_loopback_only("0.0.0.0:0")); + assert!(!listen_is_loopback_only("0.0.0.0:8080")); + assert!(!listen_is_loopback_only("[::]:8080")); + assert!(!listen_is_loopback_only("192.0.2.10:8080")); + assert!(!listen_is_loopback_only("")); + assert!(!listen_is_loopback_only("not-an-address")); + } + + #[test] + fn require_write_auth_for_bind_fail_closes_public_listeners() { + require_write_auth_for_bind("127.0.0.1:0", false).unwrap(); + require_write_auth_for_bind("0.0.0.0:0", true).unwrap(); + let err = require_write_auth_for_bind("0.0.0.0:0", false).unwrap_err(); + assert!( + err.contains("refusing to bind 0.0.0.0:0"), + "operator error must name the refused address: {err}" + ); + assert!(err.contains("ADMIN_TOKEN"), "{err}"); + let err = require_write_auth_for_bind("[::]:8080", false).unwrap_err(); + assert!(err.contains("refusing to bind [::]:8080"), "{err}"); + } } diff --git a/src/lib.rs b/src/lib.rs index c84737fc..0a1ca169 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,7 +42,10 @@ mod opencti_import; mod stix_import; mod suricata_eve; mod taxii; -pub use credentials::{CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource}; +pub use credentials::{ + CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource, + listen_is_loopback_only, require_write_auth_for_bind, +}; #[derive(Clone)] pub struct AppState { @@ -56,6 +59,10 @@ pub struct AppState { admin_tokens: HashMap, /// Where admin secrets were bootstrapped from (file/env/none). Never holds values. credentials_source: CredentialSource, + /// True when the process listener is loopback-only (in-process tests default + /// to this). Combined with missing write credentials, `/healthz` reports + /// `auth_mode=development`. + listen_loopback: bool, state_path: Option, dnsbl_origin: String, event_limit: usize, @@ -126,6 +133,7 @@ impl AppState { admin_token: config.admin_token, admin_tokens: HashMap::new(), credentials_source: CredentialSource::None, + listen_loopback: true, state_path: config.state_path, dnsbl_origin: normalized_origin(&config.dnsbl_origin), event_limit: config.event_limit.max(1), @@ -181,12 +189,27 @@ impl AppState { self } + /// Record whether the process listener is loopback-only. Builder-style. + pub fn with_listen_loopback(mut self, listen_loopback: bool) -> Self { + self.listen_loopback = listen_loopback; + self + } + + fn has_write_capable_admin(&self) -> bool { + if !self.admin_tokens.is_empty() { + self.admin_tokens + .values() + .any(|principal| principal.can_write) + } else { + self.admin_token + .as_deref() + .is_some_and(|token| !token.is_empty()) + } + } + /// The principal mapped to the request's `X-Admin-Token`, if configured. fn principal_for_token(&self, headers: &HeaderMap) -> Option<&AdminPrincipal> { - headers - .get("x-admin-token") - .and_then(|value| value.to_str().ok()) - .and_then(|token| self.admin_tokens.get(token)) + presented_admin_token(headers).and_then(|token| matching_rbac_principal(self, token)) } /// The actor name mapped to the request's `X-Admin-Token`, if that token is a @@ -255,6 +278,11 @@ impl AppState { event_limit: self.event_limit, credentials_source: self.credentials_source.as_str().to_string(), admin_auth_configured: self.admin_token.is_some() || !self.admin_tokens.is_empty(), + auth_mode: if self.listen_loopback && !self.has_write_capable_admin() { + "development".to_string() + } else { + "production".to_string() + }, } } } @@ -374,8 +402,11 @@ pub struct HealthStatus { pub event_limit: usize, /// Bootstrap origin for admin secrets: `file`, `env`, or `none` (never secret values). pub credentials_source: String, - /// True when at least one admin write token is configured. + /// True when at least one admin token (write or readonly) is configured. pub admin_auth_configured: bool, + /// `development` when the listener is loopback-only and no write-capable + /// admin principal is configured; otherwise `production`. + pub auth_mode: String, } const PHISHING_DATABASE_DEFAULT_FEED_ID: &str = "phishing-database-active"; @@ -570,8 +601,8 @@ async fn clearfolio_submit( PathParam(kind): PathParam, headers: HeaderMap, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } let Some(config) = state.clearfolio.clone() else { return error( @@ -617,8 +648,8 @@ async fn clearfolio_status( PathParam(job_id): PathParam, headers: HeaderMap, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } let Some(config) = state.clearfolio.clone() else { return error( @@ -740,8 +771,8 @@ async fn soc_analyze( headers: HeaderMap, Json(request): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } let Some(config) = state.soc_llm.clone() else { return error( @@ -848,8 +879,8 @@ async fn create_route( headers: HeaderMap, Json(route): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_route(&route) { return error(StatusCode::BAD_REQUEST, message); @@ -878,8 +909,8 @@ async fn create_threat( headers: HeaderMap, Json(indicator): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_threat(&indicator) { return error(StatusCode::BAD_REQUEST, message); @@ -914,8 +945,8 @@ async fn create_dnsbl( headers: HeaderMap, Json(entry): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_dnsbl(&entry) { return error(StatusCode::BAD_REQUEST, message); @@ -1053,8 +1084,8 @@ async fn update_commercial_license( headers: HeaderMap, Json(profile): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_commercial_profile(&profile) { return error(StatusCode::BAD_REQUEST, message); @@ -1109,8 +1140,8 @@ async fn import_threat_feed( headers: HeaderMap, Json(feed): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_threat_feed_import(&feed) { return error(StatusCode::BAD_REQUEST, message); @@ -1161,8 +1192,8 @@ async fn import_stix_document( Query(query): Query, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { return error( @@ -1252,8 +1283,8 @@ async fn import_misp_document( Query(query): Query, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { return error( @@ -1343,8 +1374,8 @@ async fn import_opencti_document( Query(query): Query, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { return error( @@ -1455,8 +1486,8 @@ async fn poll_taxii_collection( headers: HeaderMap, Json(request): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if request.feed_id.trim().is_empty() || request.source.trim().is_empty() { return error( @@ -1639,8 +1670,8 @@ async fn import_suricata_eve( headers: HeaderMap, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } let body_text = match std::str::from_utf8(&body) { Ok(text) => text, @@ -1742,8 +1773,8 @@ async fn import_coraza_audit( headers: HeaderMap, body: Bytes, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } let body_text = match std::str::from_utf8(&body) { Ok(text) => text, @@ -1908,8 +1939,8 @@ async fn import_phishing_database_feed( headers: HeaderMap, Json(request): Json, ) -> Response { - if !admin_authorized(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + if let Some(denied) = reject_management_write(&state, &headers) { + return denied; } if let Err(message) = validate_phishing_database_import_request(&request) { return error(StatusCode::BAD_REQUEST, message); @@ -2336,41 +2367,65 @@ pub struct AdminPrincipal { pub can_write: bool, } +fn presented_admin_token(headers: &HeaderMap) -> Option<&str> { + headers + .get("x-admin-token") + .and_then(|value| value.to_str().ok()) +} + +/// Scan every configured RBAC secret with constant-time compare so a miss does +/// not return early and leak which token slot matched. +fn matching_rbac_principal<'a>(state: &'a AppState, presented: &str) -> Option<&'a AdminPrincipal> { + let mut found = None; + for (token, principal) in &state.admin_tokens { + if credentials::constant_time_eq(token.as_bytes(), presented.as_bytes()) { + found = Some(principal); + } + } + found +} + /// True when the request presents a valid admin credential (write or readonly). -/// When no admin credentials are configured, returns true (auth disabled). +/// When no admin credentials are configured (loopback development), returns true. fn admin_authenticated(state: &AppState, headers: &HeaderMap) -> bool { - let presented = headers - .get("x-admin-token") - .and_then(|value| value.to_str().ok()); if !state.admin_tokens.is_empty() { - return presented.is_some_and(|token| state.admin_tokens.contains_key(token)); + return presented_admin_token(headers) + .is_some_and(|token| matching_rbac_principal(state, token).is_some()); } let Some(expected) = state.admin_token.as_deref() else { return true; }; - presented.is_some_and(|actual| actual == expected) + presented_admin_token(headers) + .is_some_and(|actual| credentials::constant_time_eq(expected.as_bytes(), actual.as_bytes())) } /// True when the request may perform management **writes**. /// Readonly RBAC tokens authenticate but cannot write. fn admin_authorized(state: &AppState, headers: &HeaderMap) -> bool { - let presented = headers - .get("x-admin-token") - .and_then(|value| value.to_str().ok()); - // RBAC tokens take precedence when configured. if !state.admin_tokens.is_empty() { - return presented.is_some_and(|token| { - state - .admin_tokens - .get(token) - .is_some_and(|principal| principal.can_write) + return presented_admin_token(headers).is_some_and(|token| { + matching_rbac_principal(state, token).is_some_and(|principal| principal.can_write) }); } - // Fallback: single shared token (None means auth is disabled). let Some(expected) = state.admin_token.as_deref() else { return true; }; - presented.is_some_and(|actual| actual == expected) + presented_admin_token(headers) + .is_some_and(|actual| credentials::constant_time_eq(expected.as_bytes(), actual.as_bytes())) +} + +/// 401 when the caller is not authenticated; 403 when authenticated but not +/// permitted to write. Same body either way so the expected role is not leaked. +fn reject_management_write(state: &AppState, headers: &HeaderMap) -> Option { + if admin_authorized(state, headers) { + return None; + } + let status = if admin_authenticated(state, headers) { + StatusCode::FORBIDDEN + } else { + StatusCode::UNAUTHORIZED + }; + Some(error(status, "missing or invalid X-Admin-Token")) } fn audit_actor(state: &AppState, headers: &HeaderMap) -> String { @@ -2432,6 +2487,50 @@ pub fn parse_admin_tokens(raw: &str) -> HashMap { .collect() } +/// Startup parser for `ADMIN_TOKENS`. Rejects blank tokens, duplicates, and +/// unknown role labels so an ambiguous registry cannot become ready. +pub fn parse_admin_tokens_strict(raw: &str) -> Result, String> { + let mut map = HashMap::new(); + for item in raw.split(',') { + let item = item.trim(); + if item.is_empty() { + continue; + } + let mut parts = item.splitn(3, ':').map(str::trim); + let token = parts.next().unwrap_or(""); + if token.is_empty() { + return Err( + "ADMIN_TOKENS contains a blank token; remove the empty entry or supply a secret" + .to_string(), + ); + } + if map.contains_key(token) { + return Err( + "ADMIN_TOKENS contains a duplicate token; each secret must map to one principal" + .to_string(), + ); + } + let actor_raw = parts.next().unwrap_or(""); + let role_raw = parts.next().unwrap_or(""); + let actor = if actor_raw.is_empty() { + "admin".to_string() + } else { + actor_raw.to_string() + }; + let can_write = match role_raw.to_ascii_lowercase().as_str() { + "" | "admin" | "write" | "writer" | "operator" => true, + "readonly" | "read" | "reader" | "ro" => false, + other => { + return Err(format!( + "ADMIN_TOKENS role {other:?} is not recognised; use admin, write, or readonly" + )); + } + }; + map.insert(token.to_string(), AdminPrincipal { actor, can_write }); + } + Ok(map) +} + fn record_successful_audit_log( data: &mut AppData, actor: String, @@ -3011,7 +3110,10 @@ pub async fn run_from_env( admin_token: credentials .get_credential(CRED_ADMIN_TOKEN) .map(str::to_owned), - state_path: std::env::var("WAF_IDS_STATE_PATH").ok().map(PathBuf::from), + state_path: std::env::var("WAF_IDS_STATE_PATH") + .ok() + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from), dnsbl_origin: std::env::var("DNSBL_ORIGIN") .unwrap_or_else(|_| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()), event_limit: parse_event_limit(std::env::var("EVENT_LIMIT").ok().as_deref())?, @@ -3022,11 +3124,24 @@ pub async fn run_from_env( std::env::var("RATE_LIMIT_WINDOW").ok().as_deref(), 60, )?; - let admin_tokens = parse_admin_tokens( + let admin_tokens = match credentials.get_credential(CRED_ADMIN_TOKENS) { + Some(raw) if !raw.trim().is_empty() => parse_admin_tokens_strict(raw)?, + _ => HashMap::new(), + }; + if !admin_tokens.is_empty() && !admin_tokens.values().any(|principal| principal.can_write) { + return Err( + "ADMIN_TOKENS does not contain a write-capable principal; add an admin/write role" + .into(), + ); + } + let has_write_capable_admin = if !admin_tokens.is_empty() { + true + } else { credentials - .get_credential(CRED_ADMIN_TOKENS) - .unwrap_or_default(), - ); + .get_credential(CRED_ADMIN_TOKEN) + .is_some_and(|token| !token.is_empty()) + }; + require_write_auth_for_bind(&bind_addr, has_write_capable_admin)?; let max_body_bytes = parse_u64_env( "MAX_BODY_BYTES", std::env::var("MAX_BODY_BYTES").ok().as_deref(), @@ -3034,7 +3149,12 @@ pub async fn run_from_env( )? as usize; let listener = tokio::net::TcpListener::bind(&bind_addr).await?; let local_addr = listener.local_addr()?; - println!("waf-ids-ai-soc listening on http://{local_addr}"); + let auth_mode = if listen_is_loopback_only(&bind_addr) && !has_write_capable_admin { + "development" + } else { + "production" + }; + println!("waf-ids-ai-soc listening on http://{local_addr} auth_mode={auth_mode}"); // Flush so a supervising parent process (the e2e test) sees the readiness // line immediately even though stdout is block-buffered when piped. std::io::Write::flush(&mut std::io::stdout())?; @@ -3044,6 +3164,7 @@ pub async fn run_from_env( .with_rate_limit(rate_limit, rate_limit_window) .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) + .with_listen_loopback(listen_is_loopback_only(&bind_addr)) .with_max_body_size(max_body_bytes); let served = axum::serve(listener, build_app(state)) .with_graceful_shutdown(shutdown) @@ -3220,6 +3341,72 @@ mod tests { std::fs::remove_file(&path).ok(); } + #[tokio::test] + async fn run_from_env_fail_closes_non_loopback_without_admin() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + } + let err = run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("refusing to bind 0.0.0.0:0"), + "startup must fail closed before readiness: {err}" + ); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_allows_non_loopback_when_admin_token_is_set() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + std::env::set_var("ADMIN_TOKEN", "startup-secret"); + } + run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap(); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_treats_blank_state_path_as_memory() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("WAF_IDS_STATE_PATH", " "); + std::env::set_var("ADMIN_TOKEN", "startup-secret"); + } + run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap(); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_fail_closes_when_admin_tokens_are_readonly_only() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("ADMIN_TOKENS", "tokR:reader:readonly"); + } + let err = run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("does not contain a write-capable principal"), + "readonly-only ADMIN_TOKENS must fail closed: {err}" + ); + clear_run_env(); + } + fn route() -> RouteConfig { RouteConfig { id: "api".to_string(), @@ -3494,7 +3681,7 @@ mod tests { ), ) .await; - assert_eq!(denied.status(), StatusCode::UNAUTHORIZED); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); let created = app_request( &app, @@ -4035,6 +4222,8 @@ mod tests { json_body(app_request(&app, empty_request(Method::GET, "/healthz")).await).await; assert_eq!(health.persistence, "file"); assert_eq!(health.dnsbl_origin, "dnsbl.example"); + assert_eq!(health.auth_mode, "production"); + assert!(health.admin_auth_configured); let block_route = RouteConfig { id: "secure".to_string(), @@ -6614,6 +6803,7 @@ mod tests { event_limit: 25, credentials_source: "none".to_string(), admin_auth_configured: false, + auth_mode: "development".to_string(), } ); @@ -6624,6 +6814,20 @@ mod tests { let health = authed.health_status(); assert_eq!(health.credentials_source, "file"); assert!(health.admin_auth_configured); + assert_eq!(health.auth_mode, "production"); + } + + #[test] + fn parse_admin_tokens_strict_rejects_duplicates_and_unknown_roles() { + let map = parse_admin_tokens_strict("tokA:alice,tokR:reader:readonly").unwrap(); + assert!(map.get("tokA").is_some_and(|p| p.can_write)); + assert!(map.get("tokR").is_some_and(|p| !p.can_write)); + let dup = parse_admin_tokens_strict("tokA:alice,tokA:bob").unwrap_err(); + assert!(dup.contains("duplicate token"), "{dup}"); + let role = parse_admin_tokens_strict("tokA:alice:superuser").unwrap_err(); + assert!(role.contains("not recognised"), "{role}"); + let blank = parse_admin_tokens_strict(":noname").unwrap_err(); + assert!(blank.contains("blank token"), "{blank}"); } fn clearfolio_test_config(base_url: &str) -> ClearfolioConfig { diff --git a/tests/binary.rs b/tests/binary.rs index ea49034f..5f80fbd2 100644 --- a/tests/binary.rs +++ b/tests/binary.rs @@ -40,9 +40,40 @@ fn binary_serves_until_force_stopped_on_windows() { ); } +#[test] +fn binary_fail_closes_non_loopback_listen_without_admin() { + let output = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) + .env("BIND_ADDR", "0.0.0.0:0") + .env_remove("ADMIN_TOKEN") + .env_remove("ADMIN_TOKENS") + .env_remove("WAF_IDS_CREDENTIALS_PATH") + .env_remove("WAF_IDS_STATE_PATH") + .output() + .expect("spawn gateway binary for fail-closed check"); + assert!( + !output.status.success(), + "public bind without credentials must exit non-zero: {:?}", + output.status + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("refusing to bind 0.0.0.0:0"), + "operator error must name the refused address:\n{combined}" + ); + assert!( + !combined.contains("waf-ids-ai-soc listening on"), + "process must not print readiness after fail-closed startup:\n{combined}" + ); +} + fn spawn_ready_gateway() -> Child { let mut child = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) .env("BIND_ADDR", "127.0.0.1:0") + .env_remove("ADMIN_TOKEN") + .env_remove("ADMIN_TOKENS") + .env_remove("WAF_IDS_CREDENTIALS_PATH") .env_remove("WAF_IDS_STATE_PATH") .env_remove("EVENT_LIMIT") .env_remove("RATE_LIMIT") @@ -58,7 +89,7 @@ fn spawn_ready_gateway() -> Child { let mut line = String::new(); reader.read_line(&mut line).expect("read readiness line"); assert!( - line.contains("listening on"), + line.contains("waf-ids-ai-soc listening on"), "unexpected startup line: {line:?}" ); child From 300ec4e8435b8a496c8753a6630c6ded0cf4d7e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 21:54:53 +0900 Subject: [PATCH 02/22] docs: record PR #94 in the product-technical gap baseline The fail-closed credentials change opened as #94; leftover merge blockers remain the org 2-approval ruleset and unread strix 404s. --- docs/product-technical-gap-baseline.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ba35c5e7..4fd1c987 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -25,6 +25,7 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | +| [#94](https://github.com/ContextualWisdomLab/wardnet/pull/94) | fix(auth): fail closed without write-capable admin on public bind | `fix/issue-78-fail-closed-credentials` | local fmt/test/clippy + two smokes green; GitHub Checks pending at open | Author this pass. | Org 2-approval ruleset + self-author (same as other self-authored PRs). Runtime gap #78 is in this PR. | | [#93](https://github.com/ContextualWisdomLab/wardnet/pull/93) | test(persistence): replace permission-based fault injection with a deterministic seam | `fix/issue-74-deterministic-persistence-fault` | rust + Security Scan green; **strix FAILURE** (job `97189711094`). Run log HTTP 404 from this token against wardnet and org `.github`. | Author `seonghobae`; Devin comments (info). | (1) strix FAILURE — log not readable (404); (2) org 2-approval ruleset + self-author. | | [#92](https://github.com/ContextualWisdomLab/wardnet/pull/92) | build(deps): bump github/codeql-action/upload-sarif from 4.37.6 to 4.37.7 | dependabot | All green (27). | Maintainer APPROVED (1 of 2). | Org ruleset: 2 approving reviews. Last pusher is dependabot so this approval counts; **second independent APPROVE missing**. | | [#91](https://github.com/ContextualWisdomLab/wardnet/pull/91) | build(deps): bump futures-util from 0.3.33 to 0.3.34 | dependabot | All green (27). | Maintainer APPROVED (1 of 2). | Same as #92: second independent APPROVE missing. | From 5baab54110525a93f583b0c793366ad09bc9291d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:09:28 +0900 Subject: [PATCH 03/22] feat(security): fail-closed destination policy for outbound HTTP One DestinationPolicy mediates gateway upstreams, threat-intel fetches, Clearfolio, and SOC LLM. Private, loopback, link-local, CGNAT, and metadata classes are denied unless DESTINATION_ALLOWLIST (or loopback development) permits them; DESTINATION_DENYLIST wins. Clients ignore ambient HTTP proxies and do not follow redirects. Refs #79. --- .gitignore | 1 + CHANGELOG.md | 1 + README.md | 1 + docs/architecture.md | 1 + .../fail-closed-destination-policy.md | 41 ++ docs/product-technical-gap-baseline.md | 12 +- docs/runbooks/operations.md | 9 + docs/security/threat-model.md | 2 +- src/destination.rs | 502 ++++++++++++++++++ src/lib.rs | 106 +++- 10 files changed, 660 insertions(+), 16 deletions(-) create mode 100644 docs/doctoring/fail-closed-destination-policy.md create mode 100644 src/destination.rs diff --git a/.gitignore b/.gitignore index 2156b24e..d1502e32 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /waf-ids-state*.json /runtime-state*.json /.codegraph +/.wt-* diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a328e42..d3301ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fail closed before readiness when `BIND_ADDR` is not loopback-only and no write-capable admin principal is configured (`ADMIN_TOKEN`, `ADMIN_TOKENS`, or `WAF_IDS_CREDENTIALS_PATH`). Loopback development may still start without a token and reports `auth_mode=development` on `/healthz`. - A blank `WAF_IDS_STATE_PATH` is treated as in-memory state instead of becoming ready and then failing to replace an empty path. +- Fail-closed destination policy on every outbound `http`/`https` call (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Loopback/private/link-local/metadata destinations are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxy variables and do not follow redirects. - Management writes now distinguish `401` (unauthenticated) from `403` (authenticated, not permitted to write) without naming the expected role. - Presented admin secrets are compared in constant time. Duplicate, blank, and unknown `ADMIN_TOKENS` roles fail startup. diff --git a/README.md b/README.md index c2036b64..95b9262b 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Useful environment variables: - `BIND_ADDR`: listen address, default `127.0.0.1:8080` - `ADMIN_TOKEN`: write token for management writes via `X-Admin-Token`. Optional only on loopback (`127.0.0.1` / `::1` / `localhost`). Required before readiness on any non-loopback `BIND_ADDR` (`0.0.0.0`, `::`, LAN, public). +- `DESTINATION_ALLOWLIST` / `DESTINATION_DENYLIST`: comma-separated hosts, `*.suffix`, or CIDRs for outbound `http`/`https`. Denylist wins. Loopback/private/metadata destinations are denied unless allowlisted (loopback development still permits loopback-class destinations). - `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero diff --git a/docs/architecture.md b/docs/architecture.md index 31281c0d..d5f6034d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,4 +76,5 @@ flowchart LR - Default bind address is loopback. Loopback-only development may start without an admin token and reports `auth_mode=development` on `/healthz`. - Any non-loopback `BIND_ADDR` fails closed before readiness unless a write-capable principal is present in the credential registry (`ADMIN_TOKEN`, `ADMIN_TOKENS`, or `WAF_IDS_CREDENTIALS_PATH`). +- Outbound `http`/`https` (gateway upstream, threat-intel, Clearfolio, SOC LLM) is mediated by one destination policy (`src/destination.rs`). Private/loopback/metadata classes are denied unless `DESTINATION_ALLOWLIST` permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxies and do not follow redirects. - Management writes return `401` when unauthenticated and `403` when authenticated but not permitted to write. Response bodies do not name the expected role. diff --git a/docs/doctoring/fail-closed-destination-policy.md b/docs/doctoring/fail-closed-destination-policy.md new file mode 100644 index 00000000..3de76ce5 --- /dev/null +++ b/docs/doctoring/fail-closed-destination-policy.md @@ -0,0 +1,41 @@ +# Doctoring — fail-closed destination policy + +This note grounds issue #79 (every outbound `http`/`https` call is mediated by +one destination-policy component). IEEE PDFs are not redistributed. + +## Adopted standards and literature + +OWASP Foundation. (n.d.). *Server-Side Request Forgery Prevention Cheat Sheet*. +https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html + +- **Design impact:** Parse URLs structurally, disable redirects, ignore ambient + proxy variables, and deny internal address classes unless an operator + allowlist names them. Deny-overrides (`DESTINATION_DENYLIST`) win. + +OWASP Foundation. (2025). *OWASP Application Security Verification Standard +5.0.0*. https://owasp.org/www-project-application-security-verification-standard/ + +- **Design impact:** ASVS V13 SSRF and V4 access control — administrative + route upserts and request-time proxying both call the same checker. + +Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in +computer systems. *Proceedings of the IEEE*, *63*(9), 1278–1308. +https://doi.org/10.1109/PROC.1975.9939 + +- **Design impact:** Fail-safe defaults. A mixed public+private DNS answer set + is deny, not allow. Unresolvable hosts are deny. + +National Institute of Standards and Technology. (2022). *Secure Software +Development Framework (SSDF) version 1.1* (NIST SP 800-218). +https://doi.org/10.6028/NIST.SP.800-218 + +- **Design impact:** PW.1 — well-secured software. Kubernetes NetworkPolicy + remains defense in depth; application checks are mandatory. + +## Operator next action + +If a legitimate internal origin is denied, add it to `DESTINATION_ALLOWLIST` +(`host`, `*.suffix`, or `CIDR`) and restart. To block a previously allowed +name, put it in `DESTINATION_DENYLIST`. Loopback development still permits +loopback-class destinations so local fixtures work; production non-loopback +listeners use the strict class list. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 4fd1c987..fc156224 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -100,10 +100,16 @@ Shipped: Doctoring: `docs/doctoring/fail-closed-management-auth.md` (APA 7th). -### Destination policy (issue #79) +### Destination policy (issue #79) — **closed this pass** -Upstream scheme validation exists; no fail-closed destination allowlist for all -outbound (feed fetch, proxy, LLM, Clearfolio). Next-loop candidate after #78. +Shipped in `src/destination.rs` and wired through route upsert, gateway proxy, +threat-intel fetch, Clearfolio, and SOC LLM. Default deny of loopback, RFC 1918, +link-local, ULA, CGNAT, documentation, and cloud-metadata classes unless +`DESTINATION_ALLOWLIST` (or loopback development) permits them. +`DESTINATION_DENYLIST` wins. HTTP clients: no redirects, `no_proxy()`. + +Remaining: custom connector that pins the TCP peer to the evaluated IP (full +TOCTOU close); Kubernetes NetworkPolicy examples as defense in depth. ### SIEM / OpenTelemetry (issue #85 / PR #90) diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 71736507..fbaace12 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -70,6 +70,15 @@ Expected fields: - `admin_auth_configured`: whether any admin token is configured - `auth_mode`: `development` only on a loopback listener with no write-capable principal; otherwise `production`. A non-loopback process refuses to become ready without credentials (issue #78). +## Destination policy (issue #79) + +Outbound `http`/`https` is fail-closed. If a route or feed is denied, the error +names the host and the denied class or denylist entry (never credentials). + +Next action: add the origin to `DESTINATION_ALLOWLIST` (`host`, `*.suffix`, or +`CIDR`) and restart; or keep it blocked with `DESTINATION_DENYLIST`. Kubernetes +NetworkPolicy egress is defense in depth, not a substitute. + ## Smoke Test ```bash diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 88afd112..ebc7f131 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -26,7 +26,7 @@ | Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; audit log for successful writes; **fail-closed startup** on non-loopback bind without a write-capable principal; `401` vs `403` without leaking expected role; constant-time secret compare | SSO/OIDC via Keyverse, mTLS or identity proxy, SCIM | | Malicious threat feed import | False positives or broad blocks | Validation, route-scoped enforcement | Source signing, feed confidence, staged promotion | | State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error | Database, backup, schema migration | -| Upstream SSRF through routes | Internal network exposure | Upstream scheme validation | Upstream allowlists, egress policy | +| Upstream SSRF through routes | Internal network exposure | Scheme validation plus fail-closed destination policy (`src/destination.rs`): deny loopback/private/link-local/metadata unless allowlisted; denylist wins; no ambient HTTP proxy; no redirects | Kubernetes NetworkPolicy egress as defense in depth | | Gateway DoS | Availability loss | Rust memory safety, event retention limit | Rate limits, body limits, async event sink | | DNSBL abuse | Reputation damage | Loopback response-code validation | Authoritative DNS service, signing, publisher workflow | | Secret disclosure | Admin compromise | Support bundle excludes admin token; secrets bootstrapped into credential registry (`WAF_IDS_CREDENTIALS_PATH` preferred over long-lived env); health exposes source label only | External secret manager / SSO, rotation, access review | diff --git a/src/destination.rs b/src/destination.rs new file mode 100644 index 00000000..3cf16c03 --- /dev/null +++ b/src/destination.rs @@ -0,0 +1,502 @@ +//! Fail-closed destination policy for every outbound URL (issue #79). +//! +//! One checker is used for gateway upstreams, threat-intel fetches, Clearfolio, +//! and SOC-LLM calls. Structural URL parse happens first; DNS answers are then +//! classified. Deny-overrides win over allowlists. Loopback-class destinations +//! are allowed only when [`DestinationPolicy::development`] is selected (the +//! process itself is loopback-only). + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs}; +use waf_ids_core::ip_in_network; + +/// Outcome of a destination-policy check. `reason` never includes credentials +/// or query strings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DestinationDecision { + pub allowed: bool, + pub reason: String, + pub host: String, + pub ips: Vec, +} + +/// Hostname, suffix, or CIDR entry parsed from an operator allow/deny list. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ListEntry { + Hostname(String), + Suffix(String), + Cidr { network: IpAddr, prefix_len: u8 }, +} + +/// Fail-closed policy applied to outbound http/https URLs. +#[derive(Debug, Clone)] +pub struct DestinationPolicy { + /// When true, loopback destinations are an allowed class (local development). + allow_loopback_class: bool, + allow: Vec, + deny: Vec, +} + +impl DestinationPolicy { + /// Production default: deny loopback, private, link-local, metadata, and + /// other non-global unicast classes unless an allowlist entry matches. + pub fn production() -> Self { + Self { + allow_loopback_class: false, + allow: Vec::new(), + deny: Vec::new(), + } + } + + /// Loopback-listener development: same denies except loopback-class IPs + /// and `localhost` are permitted so in-process fixtures can run. + pub fn development() -> Self { + Self { + allow_loopback_class: true, + allow: Vec::new(), + deny: Vec::new(), + } + } + + /// Parse comma-separated allow/deny lists (`host`, `*.suffix`, `cidr`). + pub fn with_lists(mut self, allow: &str, deny: &str) -> Result { + self.allow = parse_list(allow)?; + self.deny = parse_list(deny)?; + Ok(self) + } + + /// Evaluate `raw` with `resolver`. Denied destinations return `Err`. + pub fn evaluate( + &self, + raw: &str, + resolver: &dyn HostResolver, + ) -> Result { + let parsed = parse_outbound_url(raw)?; + let host_allowlisted = self.allow.iter().any(|entry| match entry { + ListEntry::Hostname(_) | ListEntry::Suffix(_) => { + matching_host(entry, &parsed.host, &[]) + } + ListEntry::Cidr { .. } => false, + }); + let literal_loopback = parsed.host == "localhost" + || parsed + .host + .parse::() + .map(|ip| canonicalize_ip(ip).is_loopback()) + .unwrap_or(false); + if parsed.port != 80 + && parsed.port != 443 + && !host_allowlisted + && !(self.allow_loopback_class && literal_loopback) + { + return Err(format!( + "destination port {} is not a default http/https port", + parsed.port + )); + } + let mut ips = Vec::new(); + if parsed.host == "localhost" { + ips.push(IpAddr::V4(Ipv4Addr::LOCALHOST)); + } else if let Ok(ip) = parsed.host.parse::() { + ips.push(canonicalize_ip(ip)); + } else { + ips = resolver + .resolve(&parsed.host) + .map_err(|error| format!("destination DNS failed for {}: {error}", parsed.host))?; + if ips.is_empty() { + return Err(format!( + "destination {} resolved to no addresses", + parsed.host + )); + } + ips = ips.into_iter().map(canonicalize_ip).collect(); + } + + if let Some(entry) = self.matching_entry(&self.deny, &parsed.host, &ips) { + return Err(format!( + "destination {} denied by denylist ({})", + parsed.host, + entry_label(entry) + )); + } + + let allowlisted = self + .matching_entry(&self.allow, &parsed.host, &ips) + .is_some(); + + for ip in &ips { + if ip_is_denied_class(*ip) { + if allowlisted || (self.allow_loopback_class && ip.is_loopback()) { + continue; + } + return Err(format!( + "destination {} resolved to denied address class {ip}", + parsed.host + )); + } + } + + Ok(DestinationDecision { + allowed: true, + reason: format!("destination {} permitted", parsed.host), + host: parsed.host, + ips, + }) + } +} + +struct ParsedOutbound { + host: String, + port: u16, +} + +/// Resolve a hostname to A/AAAA addresses. Tests inject a fake. +pub trait HostResolver { + fn resolve(&self, host: &str) -> Result, String>; +} + +/// Operating-system DNS via [`ToSocketAddrs`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct SystemHostResolver; + +impl HostResolver for SystemHostResolver { + fn resolve(&self, host: &str) -> Result, String> { + let addrs = (host, 0) + .to_socket_addrs() + .map_err(|error| error.to_string())?; + let mut ips = Vec::new(); + for addr in addrs { + let ip = canonicalize_ip(addr.ip()); + if !ips.contains(&ip) { + ips.push(ip); + } + } + Ok(ips) + } +} + +fn parse_outbound_url(raw: &str) -> Result { + let parsed = reqwest::Url::parse(raw).map_err(|_| "destination URL must be absolute")?; + match parsed.scheme() { + "http" | "https" => {} + other => { + return Err(format!("destination scheme {other} is not http or https")); + } + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("destination URL must not contain userinfo".to_string()); + } + if parsed.fragment().is_some() { + return Err("destination URL must not contain a fragment".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "destination URL host is required".to_string())?; + let host = host.trim_start_matches('[').trim_end_matches(']'); + if host.is_empty() || host == "." { + return Err("destination URL host is ambiguous".to_string()); + } + if host_is_ambiguous_literal(host) { + return Err(format!( + "destination host {host} uses a forbidden numeric spelling" + )); + } + let port = parsed.port_or_known_default().unwrap_or(0); + Ok(ParsedOutbound { + host: host.trim_end_matches('.').to_ascii_lowercase(), + port, + }) +} + +fn host_is_ambiguous_literal(host: &str) -> bool { + if host.chars().all(|c| c.is_ascii_digit()) { + return true; + } + let lowered = host.to_ascii_lowercase(); + if lowered.contains("0x") { + return true; + } + let octets: Vec<&str> = host.split('.').collect(); + octets.len() == 4 + && octets + .iter() + .all(|octet| !octet.is_empty() && octet.chars().all(|c| c.is_ascii_digit())) + && octets + .iter() + .any(|octet| octet.len() > 1 && octet.starts_with('0')) +} + +fn canonicalize_ip(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), + IpAddr::V4(_) => ip, + } +} + +fn ip_is_denied_class(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_unspecified() + || v4.is_private() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_multicast() + || v4.is_documentation() + || v4.octets()[0] == 0 + || is_metadata_v4(v4) + || ip_in_network(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 0)), 10, ip) + || ip_in_network(IpAddr::V4(Ipv4Addr::new(198, 18, 0, 0)), 15, ip) + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + || v6.is_unicast_link_local() + || v6.is_unique_local() + || ip_in_network( + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0)), + 32, + ip, + ) + || v6 + .to_ipv4_mapped() + .is_some_and(|v4| ip_is_denied_class(IpAddr::V4(v4))) + } + } +} + +fn is_metadata_v4(v4: Ipv4Addr) -> bool { + v4.octets() == [169, 254, 169, 254] +} + +fn parse_list(raw: &str) -> Result, String> { + let mut out = Vec::new(); + for item in raw.split(',') { + let item = item.trim(); + if item.is_empty() { + continue; + } + if let Some((addr, prefix)) = item.split_once('/') { + let network: IpAddr = addr + .parse() + .map_err(|_| format!("invalid CIDR address {addr}"))?; + let prefix_len: u8 = prefix + .parse() + .map_err(|_| format!("invalid CIDR prefix {prefix}"))?; + out.push(ListEntry::Cidr { + network, + prefix_len, + }); + continue; + } + let host = item.trim_start_matches("*").trim_start_matches('.'); + let host = host.trim_end_matches('.').to_ascii_lowercase(); + if item.starts_with("*.") || item.starts_with('.') { + out.push(ListEntry::Suffix(format!(".{host}"))); + } else { + out.push(ListEntry::Hostname(host)); + } + } + Ok(out) +} + +fn matching_host(entry: &ListEntry, host: &str, ips: &[IpAddr]) -> bool { + match entry { + ListEntry::Hostname(expected) => host.eq_ignore_ascii_case(expected), + ListEntry::Suffix(suffix) => host.ends_with(suffix) && host != &suffix[1..], + ListEntry::Cidr { + network, + prefix_len, + } => ips + .iter() + .any(|ip| ip_in_network(*network, *prefix_len, *ip)), + } +} + +impl DestinationPolicy { + fn matching_entry<'a>( + &'a self, + list: &'a [ListEntry], + host: &str, + ips: &[IpAddr], + ) -> Option<&'a ListEntry> { + list.iter().find(|entry| matching_host(entry, host, ips)) + } +} + +fn entry_label(entry: &ListEntry) -> String { + match entry { + ListEntry::Hostname(h) => h.clone(), + ListEntry::Suffix(s) => format!("*{s}"), + ListEntry::Cidr { + network, + prefix_len, + } => format!("{network}/{prefix_len}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + struct MapResolver(HashMap>); + + impl HostResolver for MapResolver { + fn resolve(&self, host: &str) -> Result, String> { + self.0 + .get(host) + .cloned() + .ok_or_else(|| format!("no fixture for {host}")) + } + } + + fn resolver(pairs: &[(&str, &str)]) -> MapResolver { + let mut map = HashMap::new(); + for (host, ip) in pairs { + map.insert( + (*host).to_string(), + vec![ip.parse::().expect("fixture ip")], + ); + } + MapResolver(map) + } + + fn deny(policy: &DestinationPolicy, url: &str, resolver: &MapResolver, needle: &str) { + let err = policy.evaluate(url, resolver).unwrap_err(); + assert!( + err.contains(needle), + "expected {needle:?} in {err:?} for {url}" + ); + assert!( + !err.contains('@') && !err.contains("://user"), + "decision must not leak credentials: {err}" + ); + } + + #[test] + fn production_denies_ssrf_classes_and_ambiguous_spellings() { + let policy = DestinationPolicy::production(); + let dns = resolver(&[ + ("evil.example", "10.0.0.5"), + ("meta.example", "169.254.169.254"), + ("mixed.example", "203.0.113.10"), + ("cgnat.example", "100.64.0.1"), + ("ula.example", "fd12:3456:789a::1"), + ]); + deny(&policy, "http://127.0.0.1/", &dns, "denied address class"); + deny(&policy, "http://0.0.0.0/", &dns, "denied address class"); + deny( + &policy, + "http://192.168.1.10/", + &dns, + "denied address class", + ); + deny( + &policy, + "http://169.254.169.254/", + &dns, + "denied address class", + ); + deny(&policy, "http://[::1]/", &dns, "denied address class"); + deny(&policy, "http://[fe80::1]/", &dns, "denied address class"); + deny( + &policy, + "http://[::ffff:127.0.0.1]/", + &dns, + "denied address class", + ); + deny(&policy, "http://2130706433/", &dns, "denied"); + deny(&policy, "http://0x7f.0.0.1/", &dns, "denied"); + deny(&policy, "http://0177.0.0.1/", &dns, "denied"); + deny(&policy, "https://user:pass@example.com/", &dns, "userinfo"); + deny(&policy, "https://example.com/#frag", &dns, "fragment"); + deny(&policy, "ftp://example.com/", &dns, "not http or https"); + deny( + &policy, + "http://evil.example/", + &dns, + "denied address class", + ); + deny( + &policy, + "http://meta.example/", + &dns, + "denied address class", + ); + deny( + &policy, + "http://cgnat.example/", + &dns, + "denied address class", + ); + deny(&policy, "http://ula.example/", &dns, "denied address class"); + deny( + &policy, + "https://example.com:8443/", + &dns, + "not a default http/https port", + ); + } + + #[test] + fn mixed_public_and_denied_answers_fail_closed() { + let policy = DestinationPolicy::production(); + let mut map = HashMap::new(); + map.insert( + "split.example".to_string(), + vec!["8.8.8.8".parse().unwrap(), "10.1.1.1".parse().unwrap()], + ); + let dns = MapResolver(map); + deny( + &policy, + "https://split.example/", + &dns, + "denied address class 10.1.1.1", + ); + } + + #[test] + fn allowlist_permits_otherwise_denied_class_and_denylist_wins() { + let policy = DestinationPolicy::production() + .with_lists("10.0.0.0/8,*.internal.example", "blocked.internal.example") + .unwrap(); + let dns = resolver(&[ + ("svc.internal.example", "10.2.3.4"), + ("blocked.internal.example", "10.2.3.5"), + ("public.example", "8.8.8.8"), + ]); + policy + .evaluate("https://svc.internal.example/", &dns) + .unwrap(); + deny( + &policy, + "https://blocked.internal.example/", + &dns, + "denied by denylist", + ); + policy.evaluate("https://public.example/", &dns).unwrap(); + } + + #[test] + fn development_allows_loopback_but_still_denies_rfc1918() { + let policy = DestinationPolicy::development(); + let dns = resolver(&[("app.local", "127.0.0.1")]); + policy + .evaluate("http://127.0.0.1:80/healthz", &dns) + .unwrap(); + policy.evaluate("http://localhost/", &dns).unwrap(); + deny(&policy, "http://10.0.0.8/", &dns, "denied address class"); + } + + #[test] + fn trailing_dot_host_still_matches_allowlist() { + let policy = DestinationPolicy::production() + .with_lists("origin.example", "") + .unwrap(); + let dns = resolver(&[("origin.example", "8.8.4.4")]); + policy + .evaluate("https://origin.example./path", &dns) + .unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0a1ca169..84e9579f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ pub use waf_ids_core::{ mod coraza_audit; mod credentials; +mod destination; mod misp_import; mod opencti_import; mod stix_import; @@ -46,6 +47,7 @@ pub use credentials::{ CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource, listen_is_loopback_only, require_write_auth_for_bind, }; +pub use destination::{DestinationPolicy, HostResolver, SystemHostResolver}; #[derive(Clone)] pub struct AppState { @@ -63,6 +65,9 @@ pub struct AppState { /// to this). Combined with missing write credentials, `/healthz` reports /// `auth_mode=development`. listen_loopback: bool, + /// Fail-closed destination policy for every outbound http/https call. + destination: DestinationPolicy, + resolver: Arc, state_path: Option, dnsbl_origin: String, event_limit: usize, @@ -125,15 +130,14 @@ impl AppState { Self { inner: Arc::new(RwLock::new(data)), persist_lock: Arc::new(Mutex::new(())), - http: reqwest::Client::new(), - feed_http: reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("failed to build no-redirect feed client"), + http: outbound_http_client(), + feed_http: outbound_http_client(), admin_token: config.admin_token, admin_tokens: HashMap::new(), credentials_source: CredentialSource::None, listen_loopback: true, + destination: DestinationPolicy::development(), + resolver: Arc::new(SystemHostResolver), state_path: config.state_path, dnsbl_origin: normalized_origin(&config.dnsbl_origin), event_limit: config.event_limit.max(1), @@ -195,6 +199,19 @@ impl AppState { self } + /// Replace the outbound destination policy. Builder-style. + pub fn with_destination_policy(mut self, policy: DestinationPolicy) -> Self { + self.destination = policy; + self + } + + /// Fail closed before any outbound http/https send. + fn assert_outbound(&self, url: &str) -> Result<(), String> { + self.destination + .evaluate(url, self.resolver.as_ref()) + .map(|_| ()) + } + fn has_write_capable_admin(&self) -> bool { if !self.admin_tokens.is_empty() { self.admin_tokens @@ -625,10 +642,11 @@ async fn clearfolio_submit( .mime_str("text/plain") .expect("text/plain is a valid MIME type"); let form = reqwest::multipart::Form::new().part("file", part); - let mut request = state - .http - .post(clearfolio_submit_url(&config.base_url)) - .multipart(form); + let submit_url = clearfolio_submit_url(&config.base_url); + if let Err(message) = state.assert_outbound(&submit_url) { + return error(StatusCode::BAD_REQUEST, message); + } + let mut request = state.http.post(submit_url).multipart(form); for (name, value) in clearfolio_tenant_headers(&config) { request = request.header(name, value); } @@ -657,9 +675,11 @@ async fn clearfolio_status( "Clearfolio integration is not configured", ); }; - let mut request = state - .http - .get(clearfolio_status_url(&config.base_url, &job_id)); + let status_url = clearfolio_status_url(&config.base_url, &job_id); + if let Err(message) = state.assert_outbound(&status_url) { + return error(StatusCode::BAD_REQUEST, message); + } + let mut request = state.http.get(status_url); for (name, value) in clearfolio_tenant_headers(&config) { request = request.header(name, value); } @@ -798,6 +818,9 @@ async fn soc_analyze( "{}/v1/chat/completions", config.base_url.trim_end_matches('/') ); + if let Err(message) = state.assert_outbound(&endpoint) { + return error(StatusCode::BAD_REQUEST, message); + } let response = state .http .post(endpoint) @@ -885,6 +908,11 @@ async fn create_route( if let Err(message) = validate_route(&route) { return error(StatusCode::BAD_REQUEST, message); } + if (route.upstream.starts_with("http://") || route.upstream.starts_with("https://")) + && let Err(message) = state.assert_outbound(&route.upstream) + { + return error(StatusCode::BAD_REQUEST, message); + } let actor = audit_actor(&state, &headers); match state @@ -1608,6 +1636,7 @@ async fn fetch_taxii_objects( ) -> Result { use futures_util::StreamExt; + state.assert_outbound(url)?; let mut request = state .feed_http .get(url) @@ -2271,6 +2300,7 @@ async fn proxy_request( body: Bytes, ) -> Result { let target = upstream_target(route, path, query)?; + state.assert_outbound(&target)?; let method = reqwest::Method::from_bytes(method.as_str().as_bytes()) .expect("axum HTTP methods are valid reqwest HTTP methods"); let response = state @@ -2601,6 +2631,7 @@ async fn fetch_text_feed(state: &AppState, url: &str) -> Result validate_http_url(url, /* allow_non_default_hosts */ true) .map_err(|message| format!("invalid feed URL {url}: {message}"))?; + state.assert_outbound(url)?; let response = state .feed_http .get(url) @@ -3092,6 +3123,29 @@ pub fn parse_u64_env( /// over this function so every branch is reachable from tests (the parse/error /// paths in-process, the bind/serve path via an ephemeral listener and an /// immediate shutdown). +fn outbound_http_client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .expect("failed to build fail-closed outbound HTTP client") +} + +fn startup_destination_policy( + bind_addr: &str, +) -> Result> { + let base = if listen_is_loopback_only(bind_addr) { + DestinationPolicy::development() + } else { + DestinationPolicy::production() + }; + let allow = std::env::var("DESTINATION_ALLOWLIST").unwrap_or_default(); + let deny = std::env::var("DESTINATION_DENYLIST").unwrap_or_default(); + Ok(base + .with_lists(&allow, &deny) + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?) +} + pub async fn run_from_env( shutdown: std::pin::Pin + Send>>, ) -> Result<(), Box> { @@ -3165,6 +3219,7 @@ pub async fn run_from_env( .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) .with_listen_loopback(listen_is_loopback_only(&bind_addr)) + .with_destination_policy(startup_destination_policy(&bind_addr)?) .with_max_body_size(max_body_bytes); let served = axum::serve(listener, build_app(state)) .with_graceful_shutdown(shutdown) @@ -5810,6 +5865,33 @@ mod tests { assert!(result.err().unwrap().contains("upstream must use http://")); } + #[tokio::test] + async fn create_route_fail_closes_metadata_upstream() { + let app = build_app(AppState::seeded(None)); + let denied = app_request( + &app, + json_request( + Method::POST, + "/api/routes", + None, + &serde_json::json!({ + "id": "pivot", + "path_prefix": "/pivot", + "upstream": "http://169.254.169.254/", + "mode": "monitor", + "enabled": true + }), + ), + ) + .await; + assert_eq!(denied.status(), StatusCode::BAD_REQUEST); + let body = body_text(denied).await; + assert!( + body.contains("denied address class"), + "operator must see the denied class: {body}" + ); + } + fn temp_state_path(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) From e0892beec80dcd71de720160da7914d4dda71282 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:15:06 +0900 Subject: [PATCH 04/22] feat(waf): enforce proven-engine payloads for any client IP Coraza/Suricata ingest already fed IP and path hints. Also persist the audit URI query as an engine_payload indicator so the same CRS payload is blocked on /gateway for a different client, keeping route-scoped block mode. Refs #86. --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 10 +++++--- src/lib.rs | 34 +++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3301ece..7da1f07c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fail closed before readiness when `BIND_ADDR` is not loopback-only and no write-capable admin principal is configured (`ADMIN_TOKEN`, `ADMIN_TOKENS`, or `WAF_IDS_CREDENTIALS_PATH`). Loopback development may still start without a token and reports `auth_mode=development` on `/healthz`. - A blank `WAF_IDS_STATE_PATH` is treated as in-memory state instead of becoming ready and then failing to replace an empty path. - Fail-closed destination policy on every outbound `http`/`https` call (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Loopback/private/link-local/metadata destinations are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxy variables and do not follow redirects. +- Coraza/Suricata ingest writes an `engine_payload` hint from the audit URI query so the same proven-engine payload is enforced on `/gateway` for any client IP. - Management writes now distinguish `401` (unauthenticated) from `403` (authenticated, not permitted to write) without naming the expected role. - Presented admin secrets are compared in constant time. Duplicate, blank, and unknown `ADMIN_TOKENS` roles fail startup. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index fc156224..d29de13e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -68,11 +68,13 @@ still say `waf-ids-ai-soc`. Kubernetes manifest remains this pass: docs and health copy already mention Wardnet in newer surfaces; wholesale crate rename is deferred (not a merge blocker). -### Proven-engine enforcement (issue #86) +### Proven-engine enforcement (issue #86) — **partial this pass** -Coraza audit JSON and Suricata EVE ingest exist as **admin-token import -adapters**. They are not an in-process enforcement engine on `/gateway`. -Scoring still uses in-repo indicators + DNSBL. Do not invent CRS replacements. +Coraza/Suricata ingest still maps proven-engine hits into DNSBL + threat +indicators. This pass also writes an `engine_payload` hint from the audit URI +query so the **same CRS payload is blocked for any client IP** on `/gateway` +(not only the original source). In-process OWASP CRS/Coraza on every +transaction, Suricata tail/shipper, and detection-quality corpora remain open. ### Identity (issue #82, Keyverse) diff --git a/src/lib.rs b/src/lib.rs index 84e9579f..162823df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1951,7 +1951,7 @@ fn apply_engine_enforcement_hints( let path_indicator = ThreatIndicator { value: path_only.to_string(), indicator_type: "path".to_string(), - severity, + severity: severity.clone(), source: source.to_string(), ttl_seconds: TTL_SECONDS, }; @@ -1960,6 +1960,25 @@ fn apply_engine_enforcement_hints( written += 1; } } + // Query token from the proven-engine audit URI is applied to later + // gateway requests (any client IP) so CRS/Suricata evidence is in-path, + // not only an IP reputation hint for the original source. + if let Some((_, query)) = path.split_once('?') { + let token = query.trim(); + if token.len() >= 8 { + let payload = ThreatIndicator { + value: token.to_string(), + indicator_type: "engine_payload".to_string(), + severity, + source: source.to_string(), + ttl_seconds: TTL_SECONDS, + }; + if validate_threat(&payload).is_ok() { + upsert_threat(&mut data.threats, payload); + written += 1; + } + } + } written } @@ -5120,6 +5139,19 @@ mod tests { .await; assert_ne!(allowed.status(), StatusCode::FORBIDDEN); + // Same CRS payload from a new client is blocked via the engine_payload + // hint (in-path adapter), not only the original source IP. + let same_payload_new_ip = app_request( + &app, + gateway_get_from_ip("/gateway/search?q=1'+OR+1=1", "198.51.100.9"), + ) + .await; + assert_eq!( + same_payload_new_ip.status(), + StatusCode::FORBIDDEN, + "proven-engine payload must enforce for any client IP" + ); + let audit: Vec = json_body( app_request( &app, From 868a7e5e1f1db70c688187c1cc5eb2ecc366a295 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:59:06 -0700 Subject: [PATCH 05/22] chore(pr): remove unrelated destination and WAF drift Restore PR #94 to its issue #78 fail-closed admin-auth scope. Destination-policy (#79) and proven-engine payload (#86) work remain separate responsibility lanes; their review findings must not block the auth repair. --- .gitignore | 1 - CHANGELOG.md | 2 - README.md | 1 - docs/architecture.md | 1 - .../fail-closed-destination-policy.md | 41 -- docs/product-technical-gap-baseline.md | 22 +- docs/runbooks/operations.md | 9 - docs/security/threat-model.md | 2 +- src/destination.rs | 502 ------------------ src/lib.rs | 140 +---- 10 files changed, 21 insertions(+), 700 deletions(-) delete mode 100644 docs/doctoring/fail-closed-destination-policy.md delete mode 100644 src/destination.rs diff --git a/.gitignore b/.gitignore index d1502e32..2156b24e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ /waf-ids-state*.json /runtime-state*.json /.codegraph -/.wt-* diff --git a/CHANGELOG.md b/CHANGELOG.md index 7da1f07c..6a328e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fail closed before readiness when `BIND_ADDR` is not loopback-only and no write-capable admin principal is configured (`ADMIN_TOKEN`, `ADMIN_TOKENS`, or `WAF_IDS_CREDENTIALS_PATH`). Loopback development may still start without a token and reports `auth_mode=development` on `/healthz`. - A blank `WAF_IDS_STATE_PATH` is treated as in-memory state instead of becoming ready and then failing to replace an empty path. -- Fail-closed destination policy on every outbound `http`/`https` call (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Loopback/private/link-local/metadata destinations are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxy variables and do not follow redirects. -- Coraza/Suricata ingest writes an `engine_payload` hint from the audit URI query so the same proven-engine payload is enforced on `/gateway` for any client IP. - Management writes now distinguish `401` (unauthenticated) from `403` (authenticated, not permitted to write) without naming the expected role. - Presented admin secrets are compared in constant time. Duplicate, blank, and unknown `ADMIN_TOKENS` roles fail startup. diff --git a/README.md b/README.md index 95b9262b..c2036b64 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,6 @@ Useful environment variables: - `BIND_ADDR`: listen address, default `127.0.0.1:8080` - `ADMIN_TOKEN`: write token for management writes via `X-Admin-Token`. Optional only on loopback (`127.0.0.1` / `::1` / `localhost`). Required before readiness on any non-loopback `BIND_ADDR` (`0.0.0.0`, `::`, LAN, public). -- `DESTINATION_ALLOWLIST` / `DESTINATION_DENYLIST`: comma-separated hosts, `*.suffix`, or CIDRs for outbound `http`/`https`. Denylist wins. Loopback/private/metadata destinations are denied unless allowlisted (loopback development still permits loopback-class destinations). - `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero diff --git a/docs/architecture.md b/docs/architecture.md index d5f6034d..31281c0d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,5 +76,4 @@ flowchart LR - Default bind address is loopback. Loopback-only development may start without an admin token and reports `auth_mode=development` on `/healthz`. - Any non-loopback `BIND_ADDR` fails closed before readiness unless a write-capable principal is present in the credential registry (`ADMIN_TOKEN`, `ADMIN_TOKENS`, or `WAF_IDS_CREDENTIALS_PATH`). -- Outbound `http`/`https` (gateway upstream, threat-intel, Clearfolio, SOC LLM) is mediated by one destination policy (`src/destination.rs`). Private/loopback/metadata classes are denied unless `DESTINATION_ALLOWLIST` permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxies and do not follow redirects. - Management writes return `401` when unauthenticated and `403` when authenticated but not permitted to write. Response bodies do not name the expected role. diff --git a/docs/doctoring/fail-closed-destination-policy.md b/docs/doctoring/fail-closed-destination-policy.md deleted file mode 100644 index 3de76ce5..00000000 --- a/docs/doctoring/fail-closed-destination-policy.md +++ /dev/null @@ -1,41 +0,0 @@ -# Doctoring — fail-closed destination policy - -This note grounds issue #79 (every outbound `http`/`https` call is mediated by -one destination-policy component). IEEE PDFs are not redistributed. - -## Adopted standards and literature - -OWASP Foundation. (n.d.). *Server-Side Request Forgery Prevention Cheat Sheet*. -https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html - -- **Design impact:** Parse URLs structurally, disable redirects, ignore ambient - proxy variables, and deny internal address classes unless an operator - allowlist names them. Deny-overrides (`DESTINATION_DENYLIST`) win. - -OWASP Foundation. (2025). *OWASP Application Security Verification Standard -5.0.0*. https://owasp.org/www-project-application-security-verification-standard/ - -- **Design impact:** ASVS V13 SSRF and V4 access control — administrative - route upserts and request-time proxying both call the same checker. - -Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in -computer systems. *Proceedings of the IEEE*, *63*(9), 1278–1308. -https://doi.org/10.1109/PROC.1975.9939 - -- **Design impact:** Fail-safe defaults. A mixed public+private DNS answer set - is deny, not allow. Unresolvable hosts are deny. - -National Institute of Standards and Technology. (2022). *Secure Software -Development Framework (SSDF) version 1.1* (NIST SP 800-218). -https://doi.org/10.6028/NIST.SP.800-218 - -- **Design impact:** PW.1 — well-secured software. Kubernetes NetworkPolicy - remains defense in depth; application checks are mandatory. - -## Operator next action - -If a legitimate internal origin is denied, add it to `DESTINATION_ALLOWLIST` -(`host`, `*.suffix`, or `CIDR`) and restart. To block a previously allowed -name, put it in `DESTINATION_DENYLIST`. Loopback development still permits -loopback-class destinations so local fixtures work; production non-loopback -listeners use the strict class list. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d29de13e..4fd1c987 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -68,13 +68,11 @@ still say `waf-ids-ai-soc`. Kubernetes manifest remains this pass: docs and health copy already mention Wardnet in newer surfaces; wholesale crate rename is deferred (not a merge blocker). -### Proven-engine enforcement (issue #86) — **partial this pass** +### Proven-engine enforcement (issue #86) -Coraza/Suricata ingest still maps proven-engine hits into DNSBL + threat -indicators. This pass also writes an `engine_payload` hint from the audit URI -query so the **same CRS payload is blocked for any client IP** on `/gateway` -(not only the original source). In-process OWASP CRS/Coraza on every -transaction, Suricata tail/shipper, and detection-quality corpora remain open. +Coraza audit JSON and Suricata EVE ingest exist as **admin-token import +adapters**. They are not an in-process enforcement engine on `/gateway`. +Scoring still uses in-repo indicators + DNSBL. Do not invent CRS replacements. ### Identity (issue #82, Keyverse) @@ -102,16 +100,10 @@ Shipped: Doctoring: `docs/doctoring/fail-closed-management-auth.md` (APA 7th). -### Destination policy (issue #79) — **closed this pass** +### Destination policy (issue #79) -Shipped in `src/destination.rs` and wired through route upsert, gateway proxy, -threat-intel fetch, Clearfolio, and SOC LLM. Default deny of loopback, RFC 1918, -link-local, ULA, CGNAT, documentation, and cloud-metadata classes unless -`DESTINATION_ALLOWLIST` (or loopback development) permits them. -`DESTINATION_DENYLIST` wins. HTTP clients: no redirects, `no_proxy()`. - -Remaining: custom connector that pins the TCP peer to the evaluated IP (full -TOCTOU close); Kubernetes NetworkPolicy examples as defense in depth. +Upstream scheme validation exists; no fail-closed destination allowlist for all +outbound (feed fetch, proxy, LLM, Clearfolio). Next-loop candidate after #78. ### SIEM / OpenTelemetry (issue #85 / PR #90) diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index fbaace12..71736507 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -70,15 +70,6 @@ Expected fields: - `admin_auth_configured`: whether any admin token is configured - `auth_mode`: `development` only on a loopback listener with no write-capable principal; otherwise `production`. A non-loopback process refuses to become ready without credentials (issue #78). -## Destination policy (issue #79) - -Outbound `http`/`https` is fail-closed. If a route or feed is denied, the error -names the host and the denied class or denylist entry (never credentials). - -Next action: add the origin to `DESTINATION_ALLOWLIST` (`host`, `*.suffix`, or -`CIDR`) and restart; or keep it blocked with `DESTINATION_DENYLIST`. Kubernetes -NetworkPolicy egress is defense in depth, not a substitute. - ## Smoke Test ```bash diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index ebc7f131..88afd112 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -26,7 +26,7 @@ | Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; audit log for successful writes; **fail-closed startup** on non-loopback bind without a write-capable principal; `401` vs `403` without leaking expected role; constant-time secret compare | SSO/OIDC via Keyverse, mTLS or identity proxy, SCIM | | Malicious threat feed import | False positives or broad blocks | Validation, route-scoped enforcement | Source signing, feed confidence, staged promotion | | State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error | Database, backup, schema migration | -| Upstream SSRF through routes | Internal network exposure | Scheme validation plus fail-closed destination policy (`src/destination.rs`): deny loopback/private/link-local/metadata unless allowlisted; denylist wins; no ambient HTTP proxy; no redirects | Kubernetes NetworkPolicy egress as defense in depth | +| Upstream SSRF through routes | Internal network exposure | Upstream scheme validation | Upstream allowlists, egress policy | | Gateway DoS | Availability loss | Rust memory safety, event retention limit | Rate limits, body limits, async event sink | | DNSBL abuse | Reputation damage | Loopback response-code validation | Authoritative DNS service, signing, publisher workflow | | Secret disclosure | Admin compromise | Support bundle excludes admin token; secrets bootstrapped into credential registry (`WAF_IDS_CREDENTIALS_PATH` preferred over long-lived env); health exposes source label only | External secret manager / SSO, rotation, access review | diff --git a/src/destination.rs b/src/destination.rs deleted file mode 100644 index 3cf16c03..00000000 --- a/src/destination.rs +++ /dev/null @@ -1,502 +0,0 @@ -//! Fail-closed destination policy for every outbound URL (issue #79). -//! -//! One checker is used for gateway upstreams, threat-intel fetches, Clearfolio, -//! and SOC-LLM calls. Structural URL parse happens first; DNS answers are then -//! classified. Deny-overrides win over allowlists. Loopback-class destinations -//! are allowed only when [`DestinationPolicy::development`] is selected (the -//! process itself is loopback-only). - -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs}; -use waf_ids_core::ip_in_network; - -/// Outcome of a destination-policy check. `reason` never includes credentials -/// or query strings. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DestinationDecision { - pub allowed: bool, - pub reason: String, - pub host: String, - pub ips: Vec, -} - -/// Hostname, suffix, or CIDR entry parsed from an operator allow/deny list. -#[derive(Debug, Clone, PartialEq, Eq)] -enum ListEntry { - Hostname(String), - Suffix(String), - Cidr { network: IpAddr, prefix_len: u8 }, -} - -/// Fail-closed policy applied to outbound http/https URLs. -#[derive(Debug, Clone)] -pub struct DestinationPolicy { - /// When true, loopback destinations are an allowed class (local development). - allow_loopback_class: bool, - allow: Vec, - deny: Vec, -} - -impl DestinationPolicy { - /// Production default: deny loopback, private, link-local, metadata, and - /// other non-global unicast classes unless an allowlist entry matches. - pub fn production() -> Self { - Self { - allow_loopback_class: false, - allow: Vec::new(), - deny: Vec::new(), - } - } - - /// Loopback-listener development: same denies except loopback-class IPs - /// and `localhost` are permitted so in-process fixtures can run. - pub fn development() -> Self { - Self { - allow_loopback_class: true, - allow: Vec::new(), - deny: Vec::new(), - } - } - - /// Parse comma-separated allow/deny lists (`host`, `*.suffix`, `cidr`). - pub fn with_lists(mut self, allow: &str, deny: &str) -> Result { - self.allow = parse_list(allow)?; - self.deny = parse_list(deny)?; - Ok(self) - } - - /// Evaluate `raw` with `resolver`. Denied destinations return `Err`. - pub fn evaluate( - &self, - raw: &str, - resolver: &dyn HostResolver, - ) -> Result { - let parsed = parse_outbound_url(raw)?; - let host_allowlisted = self.allow.iter().any(|entry| match entry { - ListEntry::Hostname(_) | ListEntry::Suffix(_) => { - matching_host(entry, &parsed.host, &[]) - } - ListEntry::Cidr { .. } => false, - }); - let literal_loopback = parsed.host == "localhost" - || parsed - .host - .parse::() - .map(|ip| canonicalize_ip(ip).is_loopback()) - .unwrap_or(false); - if parsed.port != 80 - && parsed.port != 443 - && !host_allowlisted - && !(self.allow_loopback_class && literal_loopback) - { - return Err(format!( - "destination port {} is not a default http/https port", - parsed.port - )); - } - let mut ips = Vec::new(); - if parsed.host == "localhost" { - ips.push(IpAddr::V4(Ipv4Addr::LOCALHOST)); - } else if let Ok(ip) = parsed.host.parse::() { - ips.push(canonicalize_ip(ip)); - } else { - ips = resolver - .resolve(&parsed.host) - .map_err(|error| format!("destination DNS failed for {}: {error}", parsed.host))?; - if ips.is_empty() { - return Err(format!( - "destination {} resolved to no addresses", - parsed.host - )); - } - ips = ips.into_iter().map(canonicalize_ip).collect(); - } - - if let Some(entry) = self.matching_entry(&self.deny, &parsed.host, &ips) { - return Err(format!( - "destination {} denied by denylist ({})", - parsed.host, - entry_label(entry) - )); - } - - let allowlisted = self - .matching_entry(&self.allow, &parsed.host, &ips) - .is_some(); - - for ip in &ips { - if ip_is_denied_class(*ip) { - if allowlisted || (self.allow_loopback_class && ip.is_loopback()) { - continue; - } - return Err(format!( - "destination {} resolved to denied address class {ip}", - parsed.host - )); - } - } - - Ok(DestinationDecision { - allowed: true, - reason: format!("destination {} permitted", parsed.host), - host: parsed.host, - ips, - }) - } -} - -struct ParsedOutbound { - host: String, - port: u16, -} - -/// Resolve a hostname to A/AAAA addresses. Tests inject a fake. -pub trait HostResolver { - fn resolve(&self, host: &str) -> Result, String>; -} - -/// Operating-system DNS via [`ToSocketAddrs`]. -#[derive(Debug, Default, Clone, Copy)] -pub struct SystemHostResolver; - -impl HostResolver for SystemHostResolver { - fn resolve(&self, host: &str) -> Result, String> { - let addrs = (host, 0) - .to_socket_addrs() - .map_err(|error| error.to_string())?; - let mut ips = Vec::new(); - for addr in addrs { - let ip = canonicalize_ip(addr.ip()); - if !ips.contains(&ip) { - ips.push(ip); - } - } - Ok(ips) - } -} - -fn parse_outbound_url(raw: &str) -> Result { - let parsed = reqwest::Url::parse(raw).map_err(|_| "destination URL must be absolute")?; - match parsed.scheme() { - "http" | "https" => {} - other => { - return Err(format!("destination scheme {other} is not http or https")); - } - } - if !parsed.username().is_empty() || parsed.password().is_some() { - return Err("destination URL must not contain userinfo".to_string()); - } - if parsed.fragment().is_some() { - return Err("destination URL must not contain a fragment".to_string()); - } - let host = parsed - .host_str() - .ok_or_else(|| "destination URL host is required".to_string())?; - let host = host.trim_start_matches('[').trim_end_matches(']'); - if host.is_empty() || host == "." { - return Err("destination URL host is ambiguous".to_string()); - } - if host_is_ambiguous_literal(host) { - return Err(format!( - "destination host {host} uses a forbidden numeric spelling" - )); - } - let port = parsed.port_or_known_default().unwrap_or(0); - Ok(ParsedOutbound { - host: host.trim_end_matches('.').to_ascii_lowercase(), - port, - }) -} - -fn host_is_ambiguous_literal(host: &str) -> bool { - if host.chars().all(|c| c.is_ascii_digit()) { - return true; - } - let lowered = host.to_ascii_lowercase(); - if lowered.contains("0x") { - return true; - } - let octets: Vec<&str> = host.split('.').collect(); - octets.len() == 4 - && octets - .iter() - .all(|octet| !octet.is_empty() && octet.chars().all(|c| c.is_ascii_digit())) - && octets - .iter() - .any(|octet| octet.len() > 1 && octet.starts_with('0')) -} - -fn canonicalize_ip(ip: IpAddr) -> IpAddr { - match ip { - IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), - IpAddr::V4(_) => ip, - } -} - -fn ip_is_denied_class(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - v4.is_loopback() - || v4.is_unspecified() - || v4.is_private() - || v4.is_link_local() - || v4.is_broadcast() - || v4.is_multicast() - || v4.is_documentation() - || v4.octets()[0] == 0 - || is_metadata_v4(v4) - || ip_in_network(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 0)), 10, ip) - || ip_in_network(IpAddr::V4(Ipv4Addr::new(198, 18, 0, 0)), 15, ip) - } - IpAddr::V6(v6) => { - v6.is_loopback() - || v6.is_unspecified() - || v6.is_multicast() - || v6.is_unicast_link_local() - || v6.is_unique_local() - || ip_in_network( - IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0)), - 32, - ip, - ) - || v6 - .to_ipv4_mapped() - .is_some_and(|v4| ip_is_denied_class(IpAddr::V4(v4))) - } - } -} - -fn is_metadata_v4(v4: Ipv4Addr) -> bool { - v4.octets() == [169, 254, 169, 254] -} - -fn parse_list(raw: &str) -> Result, String> { - let mut out = Vec::new(); - for item in raw.split(',') { - let item = item.trim(); - if item.is_empty() { - continue; - } - if let Some((addr, prefix)) = item.split_once('/') { - let network: IpAddr = addr - .parse() - .map_err(|_| format!("invalid CIDR address {addr}"))?; - let prefix_len: u8 = prefix - .parse() - .map_err(|_| format!("invalid CIDR prefix {prefix}"))?; - out.push(ListEntry::Cidr { - network, - prefix_len, - }); - continue; - } - let host = item.trim_start_matches("*").trim_start_matches('.'); - let host = host.trim_end_matches('.').to_ascii_lowercase(); - if item.starts_with("*.") || item.starts_with('.') { - out.push(ListEntry::Suffix(format!(".{host}"))); - } else { - out.push(ListEntry::Hostname(host)); - } - } - Ok(out) -} - -fn matching_host(entry: &ListEntry, host: &str, ips: &[IpAddr]) -> bool { - match entry { - ListEntry::Hostname(expected) => host.eq_ignore_ascii_case(expected), - ListEntry::Suffix(suffix) => host.ends_with(suffix) && host != &suffix[1..], - ListEntry::Cidr { - network, - prefix_len, - } => ips - .iter() - .any(|ip| ip_in_network(*network, *prefix_len, *ip)), - } -} - -impl DestinationPolicy { - fn matching_entry<'a>( - &'a self, - list: &'a [ListEntry], - host: &str, - ips: &[IpAddr], - ) -> Option<&'a ListEntry> { - list.iter().find(|entry| matching_host(entry, host, ips)) - } -} - -fn entry_label(entry: &ListEntry) -> String { - match entry { - ListEntry::Hostname(h) => h.clone(), - ListEntry::Suffix(s) => format!("*{s}"), - ListEntry::Cidr { - network, - prefix_len, - } => format!("{network}/{prefix_len}"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - struct MapResolver(HashMap>); - - impl HostResolver for MapResolver { - fn resolve(&self, host: &str) -> Result, String> { - self.0 - .get(host) - .cloned() - .ok_or_else(|| format!("no fixture for {host}")) - } - } - - fn resolver(pairs: &[(&str, &str)]) -> MapResolver { - let mut map = HashMap::new(); - for (host, ip) in pairs { - map.insert( - (*host).to_string(), - vec![ip.parse::().expect("fixture ip")], - ); - } - MapResolver(map) - } - - fn deny(policy: &DestinationPolicy, url: &str, resolver: &MapResolver, needle: &str) { - let err = policy.evaluate(url, resolver).unwrap_err(); - assert!( - err.contains(needle), - "expected {needle:?} in {err:?} for {url}" - ); - assert!( - !err.contains('@') && !err.contains("://user"), - "decision must not leak credentials: {err}" - ); - } - - #[test] - fn production_denies_ssrf_classes_and_ambiguous_spellings() { - let policy = DestinationPolicy::production(); - let dns = resolver(&[ - ("evil.example", "10.0.0.5"), - ("meta.example", "169.254.169.254"), - ("mixed.example", "203.0.113.10"), - ("cgnat.example", "100.64.0.1"), - ("ula.example", "fd12:3456:789a::1"), - ]); - deny(&policy, "http://127.0.0.1/", &dns, "denied address class"); - deny(&policy, "http://0.0.0.0/", &dns, "denied address class"); - deny( - &policy, - "http://192.168.1.10/", - &dns, - "denied address class", - ); - deny( - &policy, - "http://169.254.169.254/", - &dns, - "denied address class", - ); - deny(&policy, "http://[::1]/", &dns, "denied address class"); - deny(&policy, "http://[fe80::1]/", &dns, "denied address class"); - deny( - &policy, - "http://[::ffff:127.0.0.1]/", - &dns, - "denied address class", - ); - deny(&policy, "http://2130706433/", &dns, "denied"); - deny(&policy, "http://0x7f.0.0.1/", &dns, "denied"); - deny(&policy, "http://0177.0.0.1/", &dns, "denied"); - deny(&policy, "https://user:pass@example.com/", &dns, "userinfo"); - deny(&policy, "https://example.com/#frag", &dns, "fragment"); - deny(&policy, "ftp://example.com/", &dns, "not http or https"); - deny( - &policy, - "http://evil.example/", - &dns, - "denied address class", - ); - deny( - &policy, - "http://meta.example/", - &dns, - "denied address class", - ); - deny( - &policy, - "http://cgnat.example/", - &dns, - "denied address class", - ); - deny(&policy, "http://ula.example/", &dns, "denied address class"); - deny( - &policy, - "https://example.com:8443/", - &dns, - "not a default http/https port", - ); - } - - #[test] - fn mixed_public_and_denied_answers_fail_closed() { - let policy = DestinationPolicy::production(); - let mut map = HashMap::new(); - map.insert( - "split.example".to_string(), - vec!["8.8.8.8".parse().unwrap(), "10.1.1.1".parse().unwrap()], - ); - let dns = MapResolver(map); - deny( - &policy, - "https://split.example/", - &dns, - "denied address class 10.1.1.1", - ); - } - - #[test] - fn allowlist_permits_otherwise_denied_class_and_denylist_wins() { - let policy = DestinationPolicy::production() - .with_lists("10.0.0.0/8,*.internal.example", "blocked.internal.example") - .unwrap(); - let dns = resolver(&[ - ("svc.internal.example", "10.2.3.4"), - ("blocked.internal.example", "10.2.3.5"), - ("public.example", "8.8.8.8"), - ]); - policy - .evaluate("https://svc.internal.example/", &dns) - .unwrap(); - deny( - &policy, - "https://blocked.internal.example/", - &dns, - "denied by denylist", - ); - policy.evaluate("https://public.example/", &dns).unwrap(); - } - - #[test] - fn development_allows_loopback_but_still_denies_rfc1918() { - let policy = DestinationPolicy::development(); - let dns = resolver(&[("app.local", "127.0.0.1")]); - policy - .evaluate("http://127.0.0.1:80/healthz", &dns) - .unwrap(); - policy.evaluate("http://localhost/", &dns).unwrap(); - deny(&policy, "http://10.0.0.8/", &dns, "denied address class"); - } - - #[test] - fn trailing_dot_host_still_matches_allowlist() { - let policy = DestinationPolicy::production() - .with_lists("origin.example", "") - .unwrap(); - let dns = resolver(&[("origin.example", "8.8.4.4")]); - policy - .evaluate("https://origin.example./path", &dns) - .unwrap(); - } -} diff --git a/src/lib.rs b/src/lib.rs index 162823df..0a1ca169 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,7 +37,6 @@ pub use waf_ids_core::{ mod coraza_audit; mod credentials; -mod destination; mod misp_import; mod opencti_import; mod stix_import; @@ -47,7 +46,6 @@ pub use credentials::{ CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource, listen_is_loopback_only, require_write_auth_for_bind, }; -pub use destination::{DestinationPolicy, HostResolver, SystemHostResolver}; #[derive(Clone)] pub struct AppState { @@ -65,9 +63,6 @@ pub struct AppState { /// to this). Combined with missing write credentials, `/healthz` reports /// `auth_mode=development`. listen_loopback: bool, - /// Fail-closed destination policy for every outbound http/https call. - destination: DestinationPolicy, - resolver: Arc, state_path: Option, dnsbl_origin: String, event_limit: usize, @@ -130,14 +125,15 @@ impl AppState { Self { inner: Arc::new(RwLock::new(data)), persist_lock: Arc::new(Mutex::new(())), - http: outbound_http_client(), - feed_http: outbound_http_client(), + http: reqwest::Client::new(), + feed_http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build no-redirect feed client"), admin_token: config.admin_token, admin_tokens: HashMap::new(), credentials_source: CredentialSource::None, listen_loopback: true, - destination: DestinationPolicy::development(), - resolver: Arc::new(SystemHostResolver), state_path: config.state_path, dnsbl_origin: normalized_origin(&config.dnsbl_origin), event_limit: config.event_limit.max(1), @@ -199,19 +195,6 @@ impl AppState { self } - /// Replace the outbound destination policy. Builder-style. - pub fn with_destination_policy(mut self, policy: DestinationPolicy) -> Self { - self.destination = policy; - self - } - - /// Fail closed before any outbound http/https send. - fn assert_outbound(&self, url: &str) -> Result<(), String> { - self.destination - .evaluate(url, self.resolver.as_ref()) - .map(|_| ()) - } - fn has_write_capable_admin(&self) -> bool { if !self.admin_tokens.is_empty() { self.admin_tokens @@ -642,11 +625,10 @@ async fn clearfolio_submit( .mime_str("text/plain") .expect("text/plain is a valid MIME type"); let form = reqwest::multipart::Form::new().part("file", part); - let submit_url = clearfolio_submit_url(&config.base_url); - if let Err(message) = state.assert_outbound(&submit_url) { - return error(StatusCode::BAD_REQUEST, message); - } - let mut request = state.http.post(submit_url).multipart(form); + let mut request = state + .http + .post(clearfolio_submit_url(&config.base_url)) + .multipart(form); for (name, value) in clearfolio_tenant_headers(&config) { request = request.header(name, value); } @@ -675,11 +657,9 @@ async fn clearfolio_status( "Clearfolio integration is not configured", ); }; - let status_url = clearfolio_status_url(&config.base_url, &job_id); - if let Err(message) = state.assert_outbound(&status_url) { - return error(StatusCode::BAD_REQUEST, message); - } - let mut request = state.http.get(status_url); + let mut request = state + .http + .get(clearfolio_status_url(&config.base_url, &job_id)); for (name, value) in clearfolio_tenant_headers(&config) { request = request.header(name, value); } @@ -818,9 +798,6 @@ async fn soc_analyze( "{}/v1/chat/completions", config.base_url.trim_end_matches('/') ); - if let Err(message) = state.assert_outbound(&endpoint) { - return error(StatusCode::BAD_REQUEST, message); - } let response = state .http .post(endpoint) @@ -908,11 +885,6 @@ async fn create_route( if let Err(message) = validate_route(&route) { return error(StatusCode::BAD_REQUEST, message); } - if (route.upstream.starts_with("http://") || route.upstream.starts_with("https://")) - && let Err(message) = state.assert_outbound(&route.upstream) - { - return error(StatusCode::BAD_REQUEST, message); - } let actor = audit_actor(&state, &headers); match state @@ -1636,7 +1608,6 @@ async fn fetch_taxii_objects( ) -> Result { use futures_util::StreamExt; - state.assert_outbound(url)?; let mut request = state .feed_http .get(url) @@ -1951,7 +1922,7 @@ fn apply_engine_enforcement_hints( let path_indicator = ThreatIndicator { value: path_only.to_string(), indicator_type: "path".to_string(), - severity: severity.clone(), + severity, source: source.to_string(), ttl_seconds: TTL_SECONDS, }; @@ -1960,25 +1931,6 @@ fn apply_engine_enforcement_hints( written += 1; } } - // Query token from the proven-engine audit URI is applied to later - // gateway requests (any client IP) so CRS/Suricata evidence is in-path, - // not only an IP reputation hint for the original source. - if let Some((_, query)) = path.split_once('?') { - let token = query.trim(); - if token.len() >= 8 { - let payload = ThreatIndicator { - value: token.to_string(), - indicator_type: "engine_payload".to_string(), - severity, - source: source.to_string(), - ttl_seconds: TTL_SECONDS, - }; - if validate_threat(&payload).is_ok() { - upsert_threat(&mut data.threats, payload); - written += 1; - } - } - } written } @@ -2319,7 +2271,6 @@ async fn proxy_request( body: Bytes, ) -> Result { let target = upstream_target(route, path, query)?; - state.assert_outbound(&target)?; let method = reqwest::Method::from_bytes(method.as_str().as_bytes()) .expect("axum HTTP methods are valid reqwest HTTP methods"); let response = state @@ -2650,7 +2601,6 @@ async fn fetch_text_feed(state: &AppState, url: &str) -> Result validate_http_url(url, /* allow_non_default_hosts */ true) .map_err(|message| format!("invalid feed URL {url}: {message}"))?; - state.assert_outbound(url)?; let response = state .feed_http .get(url) @@ -3142,29 +3092,6 @@ pub fn parse_u64_env( /// over this function so every branch is reachable from tests (the parse/error /// paths in-process, the bind/serve path via an ephemeral listener and an /// immediate shutdown). -fn outbound_http_client() -> reqwest::Client { - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .build() - .expect("failed to build fail-closed outbound HTTP client") -} - -fn startup_destination_policy( - bind_addr: &str, -) -> Result> { - let base = if listen_is_loopback_only(bind_addr) { - DestinationPolicy::development() - } else { - DestinationPolicy::production() - }; - let allow = std::env::var("DESTINATION_ALLOWLIST").unwrap_or_default(); - let deny = std::env::var("DESTINATION_DENYLIST").unwrap_or_default(); - Ok(base - .with_lists(&allow, &deny) - .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?) -} - pub async fn run_from_env( shutdown: std::pin::Pin + Send>>, ) -> Result<(), Box> { @@ -3238,7 +3165,6 @@ pub async fn run_from_env( .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) .with_listen_loopback(listen_is_loopback_only(&bind_addr)) - .with_destination_policy(startup_destination_policy(&bind_addr)?) .with_max_body_size(max_body_bytes); let served = axum::serve(listener, build_app(state)) .with_graceful_shutdown(shutdown) @@ -5139,19 +5065,6 @@ mod tests { .await; assert_ne!(allowed.status(), StatusCode::FORBIDDEN); - // Same CRS payload from a new client is blocked via the engine_payload - // hint (in-path adapter), not only the original source IP. - let same_payload_new_ip = app_request( - &app, - gateway_get_from_ip("/gateway/search?q=1'+OR+1=1", "198.51.100.9"), - ) - .await; - assert_eq!( - same_payload_new_ip.status(), - StatusCode::FORBIDDEN, - "proven-engine payload must enforce for any client IP" - ); - let audit: Vec = json_body( app_request( &app, @@ -5897,33 +5810,6 @@ mod tests { assert!(result.err().unwrap().contains("upstream must use http://")); } - #[tokio::test] - async fn create_route_fail_closes_metadata_upstream() { - let app = build_app(AppState::seeded(None)); - let denied = app_request( - &app, - json_request( - Method::POST, - "/api/routes", - None, - &serde_json::json!({ - "id": "pivot", - "path_prefix": "/pivot", - "upstream": "http://169.254.169.254/", - "mode": "monitor", - "enabled": true - }), - ), - ) - .await; - assert_eq!(denied.status(), StatusCode::BAD_REQUEST); - let body = body_text(denied).await; - assert!( - body.contains("denied address class"), - "operator must see the denied class: {body}" - ); - } - fn temp_state_path(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) From c1b03725c0ac40269542446a630ead8e31016e6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 07:01:44 -0700 Subject: [PATCH 06/22] test(auth): cover credential comparison and blank path regressions RED: preserve the Devin findings as executable regressions before changing production behavior. The constant-time helper must reject length deltas that alias through u8, and blank credential paths must be normalized explicitly rather than relying on platform-specific read errors. --- src/credentials.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/credentials.rs b/src/credentials.rs index 7478e2d1..5076041e 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -357,6 +357,21 @@ mod tests { assert!(constant_time_eq(b"", b"")); } + #[test] + fn constant_time_eq_rejects_lengths_differing_by_256_with_zero_suffix() { + let short = vec![0_u8; 1]; + let long = vec![0_u8; 257]; + assert!(!constant_time_eq(&short, &long)); + } + + #[test] + fn nonempty_credentials_path_filters_blank_values() { + assert!(nonempty_credentials_path(Path::new("")).is_none()); + assert!(nonempty_credentials_path(Path::new(" ")).is_none()); + let path = Path::new("credentials.json"); + assert_eq!(nonempty_credentials_path(path), Some(path)); + } + #[test] fn listen_is_loopback_only_classifies_bind_addresses() { assert!(listen_is_loopback_only("127.0.0.1:0")); From b69a22e2fb7b3a5c30f21b23a26b8b2accf37627 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 07:03:32 -0700 Subject: [PATCH 07/22] fix(auth): harden credential path and comparison invariants GREEN: normalize blank credential bootstrap paths before filesystem access and retain a full length-inequality bit in constant-time secret comparison. This closes the current auth-scope Devin findings without restoring unrelated destination/WAF drift. --- src/credentials.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/credentials.rs b/src/credentials.rs index 5076041e..2816343d 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -78,7 +78,7 @@ impl CredentialRegistry { let mut from_file = false; let mut from_env = false; - if let Some(path) = credentials_path { + if let Some(path) = credentials_path.and_then(nonempty_credentials_path) { match std::fs::read_to_string(path) { Ok(content) => { let file_map: HashMap = @@ -133,13 +133,27 @@ impl CredentialRegistry { } } +/// Return a credentials path only when it contains a non-whitespace path value. +/// +/// `WAF_IDS_CREDENTIALS_PATH` is an optional bootstrap transport. Treating an +/// empty environment value as a real path makes startup behavior depend on how +/// the host platform classifies `read_to_string("")`; normalizing it here keeps +/// the credential registry portable and fail-closed without inventing a file. +fn nonempty_credentials_path(path: &Path) -> Option<&Path> { + if path.as_os_str().to_string_lossy().trim().is_empty() { + None + } else { + Some(path) + } +} + /// Constant-time equality for presented admin secrets. /// /// Length is mixed into the accumulator so a mismatched length does not take a /// faster path that would reveal the expected secret size. pub fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { let max = left.len().max(right.len()); - let mut diff = (left.len() ^ right.len()) as u8; + let mut diff = u8::from(left.len() != right.len()); for i in 0..max { let l = left.get(i).copied().unwrap_or(0); let r = right.get(i).copied().unwrap_or(0); From 96ca869af4e4a2d8dbb76f391305245e8b2a12db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 07:04:55 -0700 Subject: [PATCH 08/22] test(startup): prove state validation precedes readiness RED: a corrupt persisted-state file currently binds and prints the listening readiness line before AppState::load rejects the file. Preserve the review finding as an executable binary regression before changing startup ordering. --- tests/binary.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/binary.rs b/tests/binary.rs index 5f80fbd2..bc4ca0a8 100644 --- a/tests/binary.rs +++ b/tests/binary.rs @@ -68,6 +68,46 @@ fn binary_fail_closes_non_loopback_listen_without_admin() { ); } +#[test] +fn binary_does_not_report_readiness_before_state_validation() { + let state_path = std::env::temp_dir().join(format!( + "wardnet-corrupt-state-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + std::fs::write(&state_path, "not-json").expect("write corrupt state fixture"); + + let output = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) + .env("BIND_ADDR", "127.0.0.1:0") + .env("WAF_IDS_STATE_PATH", &state_path) + .env_remove("ADMIN_TOKEN") + .env_remove("ADMIN_TOKENS") + .env_remove("WAF_IDS_CREDENTIALS_PATH") + .output() + .expect("spawn gateway binary for startup validation check"); + let _ = std::fs::remove_file(&state_path); + + assert!( + !output.status.success(), + "corrupt state must fail startup: {:?}", + output.status + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("is not valid JSON"), + "startup error should explain the invalid state file:\n{combined}" + ); + assert!( + !combined.contains("waf-ids-ai-soc listening on"), + "readiness must not be reported until persisted state validates:\n{combined}" + ); +} + fn spawn_ready_gateway() -> Child { let mut child = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) .env("BIND_ADDR", "127.0.0.1:0") From b9daeb582675d1b9832f47014b9da61b62719aa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:06:03 +0000 Subject: [PATCH 09/22] fix(auth): generate a per-process smoke-test admin token Strix failed PR #94 as CWE-798 on the hardcoded smoke.sh secret. The process still sets ADMIN_TOKEN from that value before listen; generating it at runtime removes the shared source-tree credential without weakening the scanner. --- CHANGELOG.md | 3 ++- docs/doctoring/fail-closed-management-auth.md | 4 +++- scripts/smoke.sh | 6 +++++- src/credentials.rs | 5 +++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a328e42..84e4e0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fail closed before readiness when `BIND_ADDR` is not loopback-only and no write-capable admin principal is configured (`ADMIN_TOKEN`, `ADMIN_TOKENS`, or `WAF_IDS_CREDENTIALS_PATH`). Loopback development may still start without a token and reports `auth_mode=development` on `/healthz`. - A blank `WAF_IDS_STATE_PATH` is treated as in-memory state instead of becoming ready and then failing to replace an empty path. - Management writes now distinguish `401` (unauthenticated) from `403` (authenticated, not permitted to write) without naming the expected role. -- Presented admin secrets are compared in constant time. Duplicate, blank, and unknown `ADMIN_TOKENS` roles fail startup. +- Presented admin secrets are compared in constant time, including when lengths differ by a multiple of 256. Duplicate, blank, and unknown `ADMIN_TOKENS` roles fail startup. A blank `WAF_IDS_CREDENTIALS_PATH` is treated as unset. +- `scripts/smoke.sh` generates a per-process admin token instead of embedding a shared secret (CWE-798). ### Documentation diff --git a/docs/doctoring/fail-closed-management-auth.md b/docs/doctoring/fail-closed-management-auth.md index 7b74b8e2..3e8a7654 100644 --- a/docs/doctoring/fail-closed-management-auth.md +++ b/docs/doctoring/fail-closed-management-auth.md @@ -49,7 +49,9 @@ https://cwe.mitre.org/data/definitions/306.html | Fail closed on public bind | `require_write_auth_for_bind` + `run_from_env` | | Loopback development remains usable | `listen_is_loopback_only`; `/healthz.auth_mode=development` | | 401 vs 403 | `reject_management_write` | -| Constant-time compare | `constant_time_eq` over RBAC map and shared token | +| Constant-time compare | `constant_time_eq` mixes a length-inequality flag, not `(len ^ len) as u8` | +| Blank credentials path | empty/whitespace `WAF_IDS_CREDENTIALS_PATH` is unset | +| Smoke-test token | `scripts/smoke.sh` generates a per-process secret (CWE-798) | | Ambiguous token registry | `parse_admin_tokens_strict` (duplicate / blank / unknown role) | PII is **not** masked on security events: SOC operators cannot do their job if diff --git a/scripts/smoke.sh b/scripts/smoke.sh index de419f94..0cc18e0f 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -5,7 +5,11 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMP_DIR="$(mktemp -d)" STATE_FILE="$TMP_DIR/state.json" LOG_FILE="$TMP_DIR/server.log" -ADMIN_TOKEN_VALUE="dev-secret" +ADMIN_TOKEN_VALUE="$(python3 - <<'PY' +import secrets +print(secrets.token_hex(16)) +PY +)" PORT="$(python3 - <<'PY' import socket s = socket.socket() diff --git a/src/credentials.rs b/src/credentials.rs index 2816343d..5d044f53 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -149,8 +149,9 @@ fn nonempty_credentials_path(path: &Path) -> Option<&Path> { /// Constant-time equality for presented admin secrets. /// -/// Length is mixed into the accumulator so a mismatched length does not take a -/// faster path that would reveal the expected secret size. +/// Length inequality is mixed in as a boolean flag, not a truncated integer +/// XOR. Folding `(left.len() ^ right.len()) as u8` would treat lengths that +/// differ by a multiple of 256 as equal when the extra bytes are `0x00`. pub fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { let max = left.len().max(right.len()); let mut diff = u8::from(left.len() != right.len()); From f31d960a0b52bd037c6611a59a07e425c35e642b Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 23 Aug 2026 14:50:46 +0000 Subject: [PATCH 10/22] fix(startup): validate persisted state before reporting readiness Corrupt WAF_IDS_STATE_PATH must fail closed before TcpListener bind and the "waf-ids-ai-soc listening on" line, so a supervisor cannot treat a dying process as ready. --- src/lib.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0a1ca169..6a90d5f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3147,6 +3147,14 @@ pub async fn run_from_env( std::env::var("MAX_BODY_BYTES").ok().as_deref(), 1_048_576, )? as usize; + let state = AppState::load(config) + .await + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))? + .with_rate_limit(rate_limit, rate_limit_window) + .with_admin_tokens(admin_tokens) + .with_credentials_source(credentials.source()) + .with_listen_loopback(listen_is_loopback_only(&bind_addr)) + .with_max_body_size(max_body_bytes); let listener = tokio::net::TcpListener::bind(&bind_addr).await?; let local_addr = listener.local_addr()?; let auth_mode = if listen_is_loopback_only(&bind_addr) && !has_write_capable_admin { @@ -3158,14 +3166,6 @@ pub async fn run_from_env( // Flush so a supervising parent process (the e2e test) sees the readiness // line immediately even though stdout is block-buffered when piped. std::io::Write::flush(&mut std::io::stdout())?; - let state = AppState::load(config) - .await - .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))? - .with_rate_limit(rate_limit, rate_limit_window) - .with_admin_tokens(admin_tokens) - .with_credentials_source(credentials.source()) - .with_listen_loopback(listen_is_loopback_only(&bind_addr)) - .with_max_body_size(max_body_bytes); let served = axum::serve(listener, build_app(state)) .with_graceful_shutdown(shutdown) .await; @@ -3331,7 +3331,7 @@ mod tests { std::env::set_var("BIND_ADDR", "127.0.0.1:0"); std::env::set_var("WAF_IDS_STATE_PATH", path.to_str().unwrap()); } - // Bind succeeds, but loading corrupt persisted state maps to an error. + // Corrupt persisted state fails closed before the listener binds. assert!( run_from_env(Box::pin(std::future::ready(()))) .await From ee72b1eb8287c21856684ec665073745ab211246 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 05:03:21 -0700 Subject: [PATCH 11/22] test(fuzz): cover strict admin token parser --- fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs | 26 ++++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs b/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs index 80fadda8..752710ec 100644 --- a/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs +++ b/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs @@ -1,15 +1,21 @@ #![no_main] -//! Fuzz the admin-token config parser: `waf_ids_ai_soc::parse_admin_tokens`. +//! Fuzz both admin-token config parsers: +//! `waf_ids_ai_soc::parse_admin_tokens` and +//! `waf_ids_ai_soc::parse_admin_tokens_strict`. //! -//! This parses the `ADMIN_TOKENS` operator config string +//! Both parse the untrusted `ADMIN_TOKENS` operator config string //! (`token:actor[:role],...`) into an RBAC principal map. Malformed or -//! adversarial config must never panic, and the parser's structural invariants -//! must hold for every input: +//! adversarial config must never panic. Whenever either parser accepts an +//! input, its structural invariants must hold: //! * no empty token key ever ends up in the map; //! * every actor value is non-empty (defaults to "admin"). +//! +//! The strict startup parser may reject duplicate tokens, blank token entries, +//! or unknown roles; those errors are expected fuzz outcomes rather than +//! crashes. use libfuzzer_sys::fuzz_target; -use waf_ids_ai_soc::parse_admin_tokens; +use waf_ids_ai_soc::{parse_admin_tokens, parse_admin_tokens_strict}; fuzz_target!(|data: &[u8]| { let Ok(raw) = std::str::from_utf8(data) else { @@ -24,4 +30,14 @@ fuzz_target!(|data: &[u8]| { "actor value must never be empty" ); } + + if let Ok(tokens) = parse_admin_tokens_strict(raw) { + for (token, principal) in &tokens { + assert!(!token.is_empty(), "strict token key must never be empty"); + assert!( + !principal.actor.is_empty(), + "strict actor value must never be empty" + ); + } + } }); From 75e950cf7b7587f8f5bc4795f7e5660f0bde392e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:11:15 -0700 Subject: [PATCH 12/22] docs(gaps): correct snapshot and admin credential boundary --- docs/product-technical-gap-baseline.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 4fd1c987..4c635367 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product and technical gap baseline -Snapshot date: 2026-08-23 (exact-head inventory of then-open GitHub PRs and +Snapshot date: 2026-08-23 (point-in-time inventory of then-open GitHub PRs and Issues plus operator-perceptible gaps). Update this file on every hourly loop. Commercial contract and `/api/commercial/readiness` remain **2B KRW**. The @@ -92,7 +92,9 @@ Shipped: - `require_write_auth_for_bind` in `src/credentials.rs` (driven by unit tests and by `run_from_env` / the real binary). -- Non-loopback `BIND_ADDR` without a write-capable principal exits before bind +- Non-loopback `BIND_ADDR` without a write-capable admin principal from + `ADMIN_TOKEN`, a write-capable entry in `ADMIN_TOKENS`, or + `WAF_IDS_CREDENTIALS_PATH` exits before bind (`tests/binary.rs::binary_fail_closes_non_loopback_listen_without_admin`). - Loopback remains usable; `/healthz.auth_mode` is `development` or `production`. - `401` vs `403` on management writes; constant-time compare; strict @@ -146,8 +148,9 @@ Remaining holes on untouched handlers stay listed for later loops. ## This loop’s shipped gap Issue **#78**: fail-closed management credentials on non-loopback listen. -Operator-visible: a cluster bind (`0.0.0.0:8080`) without `ADMIN_TOKEN` no -longer becomes ready with open management writes. +Operator-visible: a cluster bind (`0.0.0.0:8080`) without a write-capable admin +principal from `ADMIN_TOKEN`, a write-capable `ADMIN_TOKENS` entry, or +`WAF_IDS_CREDENTIALS_PATH` no longer becomes ready with open management writes. ## Next hourly loop (do, do not report) From b150132d0d7b338a5280e144df64b8275f2c1511 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:14:39 -0700 Subject: [PATCH 13/22] docs(a11y): remove shared token example and correct target-size claims --- docs/ui-ux/storybook-scene-inventory.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/ui-ux/storybook-scene-inventory.md b/docs/ui-ux/storybook-scene-inventory.md index b69ba703..2dd61207 100644 --- a/docs/ui-ux/storybook-scene-inventory.md +++ b/docs/ui-ux/storybook-scene-inventory.md @@ -16,7 +16,7 @@ operator instrument panel). ## How to exercise each scene -1. `cargo run` (loopback; optional `ADMIN_TOKEN=dev-secret`). +1. `cargo run` on loopback; no admin credential is required for read-only development startup. For authenticated scenes, generate a unique per-process `ADMIN_TOKEN` outside the source tree and do not reuse a repository literal. 2. Open `http://127.0.0.1:8080/admin`. 3. Drive the event in the table. Expected result is the operator’s next action, not a status narrative. @@ -26,7 +26,7 @@ operator instrument panel). | Area | Console contract | Edge-case events | | --- | --- | --- | | Accessibility | Skip link, wrapping labels, `th scope`, live regions, High Contrast `aria-pressed`, text+colour badges | Keyboard-only create-route; High Contrast + focus ring visible on every control; screen-reader hears KPI refresh (`aria-live=polite`) and toast (`assertive`) | -| Touch & Interaction | Controls `min-height: 44px` (WCAG 2.5.5) | Tap primary save on a 390px-wide viewport; toast auto-dismiss ~4.5s; do not rely on hover | +| Touch & Interaction | Primary controls use a 44px minimum height as an ergonomic floor. This height alone is **not** evidence of WCAG 2.5.5 conformance. WCAG 2.2 SC 2.5.8 (Level AA) requires pointer targets to be at least 24 × 24 CSS px or satisfy a specified exception/spacing rule; SC 2.5.5 (Level AAA) requires 44 × 44 CSS px except its specified exceptions. | Tap primary save on a 390px-wide viewport; verify the actual target/hit-area on both axes before claiming target-size conformance; toast auto-dismiss ~4.5s; do not rely on hover | | Performance | No framework, no extra network for CSS/JS; `Promise.allSettled` per card | One failing `/api/*` card shows `.err` and does not blank the page; large event list capped at 25 with truncation copy | | Style Selection | Default tokens vs High Contrast (`data-theme=hc`, `localStorage["waf-theme"]`) | Toggle High Contrast, reload, theme persists; never raw hex on a component | | Layout & Responsive | Card grid `repeat(auto-fit, minmax(340px, 1fr))` | 1280px desktop and 390px mobile: header, KPI strip, and create forms remain usable; no horizontal trap | @@ -65,3 +65,9 @@ If a future pass adds a static `storybook/` package, CSF stories must mount the same CSS tokens (not a parallel palette) and replay the events above. Until then this file is the inventory of record. Do not claim `/admin` loads Storybook. + +## Standards evidence (APA 7) + +World Wide Web Consortium. (n.d.). *Understanding Success Criterion 2.5.8: Target Size (Minimum).* Retrieved August 25, 2026, from https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html + +World Wide Web Consortium. (n.d.). *Understanding Success Criterion 2.5.5: Target Size (Enhanced).* Retrieved August 25, 2026, from https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html From 5a629093d48df9c44348d20f58343956182f72dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:15:25 -0700 Subject: [PATCH 14/22] docs(a11y): make target-size evidence fail closed --- docs/design-system.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index c30523ac..4826ec3e 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -53,7 +53,13 @@ carried by token swaps only, no component markup changes between modes. | `--fs-metric` | 28px/700 | KPI tile value | | `--radius` | 8px | cards, inputs (6px), badges (pill) | -Controls (`button`, `input`, `select`) are `min-height: 44px` (WCAG 2.5.5 target size). +Primary form controls (`button`, `input`, `select`) use `min-height: 44px` as a +Wardnet ergonomic floor. **Height alone is not target-size conformance evidence.** +WCAG 2.2 SC 2.5.8 Target Size (Minimum), Level AA, requires a pointer target to be +at least 24 × 24 CSS pixels or satisfy one of the criterion's specified exceptions, +including its spacing rule. SC 2.5.5 Target Size (Enhanced), Level AAA, requires +44 × 44 CSS pixels except its specified exceptions. Do not claim SC 2.5.5 from the +44px height rule unless both axes of the actual target/hit area have been measured. ## Components @@ -87,7 +93,7 @@ Each entry: **anatomy · states · usage · a11y · data**. ### Button - **Variants** `btn-primary` (brand fill — one primary action per form), `btn-secondary` (bordered, on surface), `btn-ghost` (in the brand header). -- **States** default / `:focus-visible` ring / `aria-pressed` (toggle). 44px min. +- **States** default / `:focus-visible` ring / `aria-pressed` (toggle). Runtime CSS guarantees a 44px minimum height; target width/hit area must still be verified before making a WCAG target-size claim. ### Form field - **Anatomy** `label.field` wrapping caption + control + optional `.field-help`. @@ -124,7 +130,8 @@ First tab stop, off-screen until focused, jumps to `#main`. ## Accessibility checklist (per screen) - [ ] All text pairs ≥ 4.5:1 (see table); non-text state has a text label too. -- [ ] Every control ≥ 44×44 and reachable by keyboard with a visible focus ring. +- [ ] Every pointer target is verified against WCAG 2.2 SC 2.5.8: at least 24 × 24 CSS px or a documented applicable exception/spacing result. Important controls should aim for 44 × 44 CSS px; SC 2.5.5 Level AAA is not claimed from height alone. +- [ ] Every interactive control is reachable by keyboard with a visible focus ring. - [ ] Tables use `/
`; forms use wrapping `