DAST Baseline — OWASP ZAP
Scan date: 2026-07-20
Tool: OWASP ZAP baseline scan (zaproxy/zap-stable, zap-baseline.py)
Prepared for: CSUB ITS security vendor review (US-44 / PR-F2)
Target
The story asks for a scan "against staging." No staging tier exists — the
only environment axis this app has is WP_ENV ∈ {dev, production}
(backend/app/config.py), and the only deployed host is
demo.greaterangelssoftware.com, behind a Cloudflare tunnel
(docs/diagrams/deployment/pass59_k8s_topology.puml). Scanning that live
public host was ruled out without a separate explicit go-ahead. Instead this
scan targets the local docker-compose.yml stack — the same images the real
deploy path builds (docker/api.Dockerfile, docker/frontend.Dockerfile —
US-48), composed the same way, reachable unauthenticated the way any external
caller would be.
That substitution has a real gap, stated plainly rather than implied away:
the compose stack runs in dev mode (WP_ENV unset), so
HTTPSRedirectMiddleware, the session cookie's Secure flag, and the HSTS
header are all off in what this scan actually saw — those three are gated on
Settings.is_production (backend/app/config.py) and are covered instead by
test_web_session_hardening_bdd.py's dedicated production-configured app
fixture, not by this scan. TLS termination and the Cloudflare tunnel edge are
also outside what a compose-stack scan can exercise. See
docs/diagrams/deployment/pass53_dast_scan_topology.puml for the full
picture of what this scan does and does not stand in for.
Reproduce with make security-dast (brings up an ephemeral copy of the
compose stack, runs the baseline scan against it, tears the stack down).
Findings fixed during this baseline
Six findings were fixed as part of producing this baseline, verified by re-running the scan afterward and confirming each disappeared:
- Server Leaks Version Information (
Server: nginx/1.27.5) — both nginx configs now setserver_tokens off;(docker/frontend.nginx.conf,docker/compose-edge.nginx.conf). - CSP: Failure to Define Directive with No Fallback —
base-uriandform-actionhave nodefault-srcfallback per spec and were never named explicitly. Added to all three CSP-setting locations:docker/frontend.nginx.conf('self'/'self'), the backend's default CSP (backend/app/middleware.py,'none'/'none'), and the mock-IdP page's own CSP override (backend/app/routers/auth.py,'self'/'self'). - Permissions-Policy Header Not Set — added
geolocation=(), microphone=(), camera=()to both the backend (backend/app/middleware.py) and the frontend (docker/frontend.nginx.conf). - Cross-Origin-Opener-Policy Header Missing — added
same-originto both. - Cross-Origin-Resource-Policy Header Missing — added
same-originto both. - Cross-Origin-Embedder-Policy Header Missing or Invalid (PASS-221) —
added
require-corpto both. This baseline had previously accepted the finding:require-corpwould have blocked the Google Fonts stylesheetindex.htmllinked to, unless Google's CDN sent a matchingCross-Origin-Resource-Policyheader, which was never verified. PASS-221 self-hosts those fonts underfrontend/public/fonts/and removed the Google Fonts allowances fromdocker/frontend.nginx.conf's CSP — style-src, font-src, and connect-src now name no outside origin at all, so there is no remaining cross-origin fetch left forrequire-corpto break. The one other candidate — the per-theme, admin-configurablelogoUrl(ChallengeBuilder.tsx) rendered as an<img>(Passport.tsx) — is already unreachable regardless of COEP:img-srchas always been'self' data:, so a campus-configured external logo URL was already blocked by CSP before this change and stays that way after it.
Triage
| # | Alert | Risk | Disposition | Rationale |
|---|---|---|---|---|
| 1 | Absence of Anti-CSRF Tokens | Medium | Accepted | Found on /mock-idp/login's POST form only — the dev-only mock IdP stand-in, which 404s whenever WP_AUTH_PROVIDER is not mock (backend/app/config.py refuses to boot in production with it set at all — SEC-5, docs/hecvat-lite.md). A real campus deployment never serves this route, and the "attacker" already controls the browser session establishing an identity — there is no cross-site victim to protect. |
| 2 | CSP: script-src unsafe-inline | Medium | Accepted | Same /mock-idp/login page's own CSP override, needed for its inline onclick preset buttons. Same dev-only rationale as #1. |
| 3 | CSP: style-src unsafe-inline | Medium | Accepted | The SPA's production CSP (docker/frontend.nginx.conf) needs it for dynamic per-theme CSS custom properties applied via inline style attributes (US-60 design-of-record token realignment). Removing it needs auditing every dynamic-style usage across the React app — deferred as tracked frontend work rather than attempted blind against a mobile-first, actively-themed UI. |
| 4 | Sub Resource Integrity Attribute Missing | Medium | Accepted | Scripts/styles are served same-origin from the exact image the deploy pipeline builds (US-48), not a third-party CDN — SRI's threat model (a compromised third-party host swapping the file) does not apply; the same pipeline that builds the HTML also builds the JS/CSS it references. |
| 5 | Base64 Disclosure | Informational | Accepted | Base64 content in built JS/CSS bundles (icon/font data URIs) and in /auth/acs's opaque signed session token — expected content, not a cleartext secret leak. |
| 6 | Modern Web Application | Informational | Accepted | ZAP noting the app is a modern SPA; an observation about crawl strategy, not a finding. |
| 7 | Non-Storable Content | Informational | Accepted | Some responses (/auth/session when unauthenticated) are correctly non-cacheable — expected, not a defect. |
| 8 | Sec-Fetch-Dest Header is Missing | Informational | Accepted | Sec-Fetch-* are request headers modern browsers add automatically; ZAP's own scanner does not send them, so their "absence" here reflects the scanning tool, not anything the server could add to the browser's outgoing request. |
| 9 | Sec-Fetch-Mode Header is Missing | Informational | Accepted | Same as #8. |
| 10 | Sec-Fetch-Site Header is Missing | Informational | Accepted | Same as #8. |
| 11 | Sec-Fetch-User Header is Missing | Informational | Accepted | Same as #8. |
| 12 | Session Management Response Identified | Informational | Accepted | ZAP confirming it found a session-establishing response (Set-Cookie on /auth/acs) — the expected behavior of a working sign-in flow. |
| 13 | Storable and Cacheable Content | Informational | Accepted | Static assets (icons, manifest, JS/CSS bundles) are cacheable by design — the PWA offline story (US-6) depends on service-worker + browser caching of exactly these. No session or student data appears in these responses. |
| 14 | User Controllable HTML Element Attribute (Potential XSS) | Informational | Accepted | The returnTo query parameter is reflected into the dev-only mock-IdP page's hidden input value, already HTML-escaped server-side (_html_escape, backend/app/routers/auth.py) before interpolation — confirmed-mitigated; flagged informational because ZAP's static analysis cannot see past the escaping to confirm it itself. |
No High or Critical findings. No unresolved-and-unaccepted findings remain — every row above is either fixed (the six listed above) or Accepted with a stated rationale.
Residual gaps
- No staging tier existed at the time of this scan (2026-07-20), and the
compose stack does not reproduce
WP_ENV=production's TLS/cookie/HSTS posture or the Cloudflare tunnel edge — see the Target section above anddocs/diagrams/deployment/pass53_dast_scan_topology.puml. US-38 / PR-D1 (after this scan) has since stood up a staging tier (k8s-staging/,wellness-passport-staging— seedocs/diagrams/deployment/pass45_staging_topology.puml), but this baseline has not been re-run against it — that re-scan remains outstanding, not implied by staging merely existing now. - The live public host was never scanned. Scanning
demo.greaterangelssoftware.comdirectly needs an explicit go-ahead this story did not seek. - This is a baseline (passive) scan, not an authenticated active scan.
ZAP's crawler reached only unauthenticated, publicly linked routes; it did
not sign in and probe the admin/student API surface for injection-class
defects. The authorization surface itself is covered instead by
backend/tests/test_vendor_security_package_bdd.py's Scenario 4 (every admin route rejects a student session) and FR-A4's existing sweep — a different technique for a different class of defect than DAST covers.