Skip to content

feat: Enforce CSP via middleware (OHE-2815) - #94

Merged
tofarr merged 9 commits into
mainfrom
ohe-2815-csp-report-only
Jul 30, 2026
Merged

feat: Enforce CSP via middleware (OHE-2815)#94
tofarr merged 9 commits into
mainfrom
ohe-2815-csp-report-only

Conversation

@tofarr

@tofarr tofarr commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

HUMAN:

  • A human has tested these changes.

Tested on staging and CSP is in place and app still works.

AGENT:


Why

OHE-2815 / pen-test finding #1 (Vanta): the SPA was served without a Content-Security-Policy header. Ship one in enforce mode so the browser actually blocks disallowed requests, scripts, frames and connections — matching what the pen-test finding asked for.

The PR went through a report-only phase on staging to surface every violation in DevTools before enforcing. Staging report came back clean (the runtime / PostHog / iframe cases below were all fixed during the report-only iteration), so this commit flips to enforce mode.

Summary

  • New SecurityHeadersMiddleware (openhands/app_server/middleware.py) emits Content-Security-Policy (enforce) plus X-Content-Type-Options, Referrer-Policy, Permissions-Policy and X-Frame-Options on every response. HSTS is intentionally left to the edge proxy.

  • Default policy covers what the app actually needs and blocks everything else:

    default-src 'self'
    script-src 'self' 'unsafe-inline' https://us-assets.i.posthog.com
    style-src 'self' 'unsafe-inline' https://fonts.googleapis.com
    font-src 'self' https://fonts.gstatic.com data:
    img-src 'self' data: blob: https:
    connect-src 'self' ws: wss: https://us.i.posthog.com https://us-assets.i.posthog.com https://*.{WEB_HOST_REGISTRABLE}
    frame-src 'self' https://*.{WEB_HOST_REGISTRABLE}
    frame-ancestors 'self'
    object-src 'none'
    base-uri 'self'
    form-action 'self'
    worker-src 'self' blob:
    

    Notes:

    • 'unsafe-inline' on script/style stays for now because React Router's SPA bootstrap emits inline <script>/<style>. Tightening to nonces or static hashes is the next follow-up before this PR is "fully done".
    • Runtime hosts derive from WEB_HOST by stripping the leftmost DNS label, yielding the registrable domain (eTLD+1). WEB_HOST=staging.all-hands.devhttps://*.all-hands.dev, which matches <sandbox>.staging-runtime.all-hands.dev, vscode-<sandbox>.staging-runtime.all-hands.dev, and any other sibling subdomain the runtime team spins up. Single-label hosts (e.g. localhost) skip the wildcard.
    • PostHog assets host (us-assets.i.posthog.com) is in script-src/connect-src for the SDK scripts. The event-ingest host (us.i.posthog.com) stays in connect-src only.
  • CONTENT_SECURITY_POLICY env var overrides the default policy string wholesale. An explicit empty value disables the header entirely — the kill switch if a directive ends up blocking something legitimate. The env var is only honoured when set (not when merely empty), so an unset variable doesn't accidentally disable CSP.

Issue Number

OHE-2815 (pen-test remediation / Vanta)

How to Test

  1. Backend unit tests (all new): poetry run pytest tests/unit/app_server/test_security_headers_middleware.py -v
  2. Manual smoke test against make run:
    • curl -I http://localhost:3000/ — every response carries Content-Security-Policy plus the four companion headers. The Content-Security-Policy-Report-Only header must NOT be present.
    • With WEB_HOST=staging.all-hands.dev, the policy includes https://*.all-hands.dev in both frame-src and connect-src.
    • With CONTENT_SECURITY_POLICY="default-src 'none'" set, the response carries exactly that value (no derived directives).
    • With CONTENT_SECURITY_POLICY="" set, the response does NOT carry a Content-Security-Policy header at all.
  3. Browser smoke test in DevTools:
    • Open https:/// and load a conversation. Network panel should show no failed/blocked requests for the runtime, PostHog SDK, or VS Code iframe. Console should be free of CSP violation messages.

The sandbox here could not run pytest directly (the committed .venv is macOS-built, and base62 has no PyPI distribution). Lint/format were all verified locally, and the override / wildcard / disable paths were validated via an isolated FastAPI harness against a copy of the directive map.

Video/Screenshots

image image

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Rollout safety

  • Deploy as-is. DevTools still logs violations if anything ends up blocked, so triage is straightforward.
  • If something legitimate gets blocked (e.g. a runtime subdomain we don't know about yet), set CONTENT_SECURITY_POLICY="" to disable the header without a redeploy. Or paste a relaxed override (CONTENT_SECURITY_POLICY="default-src *" for "open it up entirely", or a partial override that drops the offending directive).
  • The override env var was designed for exactly this — it doesn't require a code change.

Required follow-ups (separate PRs)

  • Tighten script-src/style-src by either shipping nonces from React Router or pinning SHA-256 of the two inline scripts emitted by <Scripts />. Then drop 'unsafe-inline'. This is the only directive keeping us from a strict policy.
  • Document CONTENT_SECURITY_POLICY in enterprise/enterprise_local/convert_to_env.py and its README so operators know about the kill switch.
  • Helm chart should ensure WEB_HOST is set to the deployment's apex (e.g. staging.all-hands.dev for the staging cluster, app.all-hands.dev for prod).

Doing those here would expand the blast radius of this PR.

Notes

  • VS Code iframe: unchanged behaviour. The iframe in vscode-tab.tsx already points at the current host with the sandbox hostname replaced in. With WEB_HOST set, <sandbox>.<sibling>.<registrable> (REST/WS) and vscode-<sandbox>.<sibling>.<registrable> (iframe) are covered by *.<registrable>.
  • CORS / static mount / rate-limited endpoints: all covered because the middleware is registered last in app.py so it wraps everything, including the SPAStaticFiles mount.
  • HSTS: left to the edge reverse proxy. We don't want to advertise HTTPS-only to clients that might be hitting a staging cluster over a private tunnel where HTTPS isn't terminated by us.

Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-83a0652

- New SecurityHeadersMiddleware emits Content-Security-Policy-Report-Only
  plus X-Content-Type-Options, Referrer-Policy, Permissions-Policy and
  X-Frame-Options on every response.
- POST /api/v1/security/csp-report accepts both application/csp-report
  and application/reports+json payloads and logs each violation.
- Default policy uses 'unsafe-inline' on script-src/style-src so React
  Router's inline bootstrap does not flood the report; tighten before
  flipping to enforce mode.
- New env vars: CONTENT_SECURITY_POLICY_REPORT_ONLY (override),
  CONTENT_SECURITY_POLICY_REPORT_URI, OH_FRAME_SRC_ALLOWLIST.

Co-authored-by: openhands <openhands@all-hands.dev>
@linear

linear Bot commented Jul 29, 2026

Copy link
Copy Markdown
OHE-2815 Pen test remediation: Implement Content-Security-Policy (CSP) header

Source: All Hands Ai Web Application Penetration Test (June 2026), finding #1. Vanta: https://app.vanta.com/c/openhands.dev/tests/pen-test-remediation?tab=results

Risk: Medium · OWASP A02 - Security Misconfiguration · CWE-693

Affected: https://staging.all-hands.dev/ (and presumably prod)

Description: No Content-Security-Policy header is enforced. Leaves the app vulnerable to XSS and malicious content injection — no active XSS was found, but there's no defense-in-depth against it.

Remediation:

  • Add a Content-Security-Policy header to all HTTP responses, starting with restrictive default-src, script-src, style-src directives (e.g. default-src 'self'; script-src 'self'; style-src 'self';)
  • Recommend rolling out in report-only mode first to catch violations without breaking functionality, then tighten and enforce
  • Review/update as the app evolves

Review in Linear

@tofarr tofarr changed the title Add CSP report-only middleware and report collector (OHE-2815) feat: Add CSP report-only middleware and report collector (OHE-2815) Jul 29, 2026
@github-actions github-actions Bot added the type: feat A new feature label Jul 29, 2026
tofarr and others added 3 commits July 29, 2026 12:44
…connect-src

Staging surfaced three missing pieces in the default policy:

- PostHog SDK scripts are loaded from us-assets.i.posthog.com, not just
  us.i.posthog.com (which is the event-ingest endpoint). Added the
  assets host to script-src and connect-src by default.
- Runtime API calls (e.g. /api/conversations/.../events/count) and the
  VS Code iframe both target *.staging-runtime.all-hands.dev, so the
  runtime host set has to be in connect-src as well as frame-src.

Renamed OH_FRAME_SRC_ALLOWLIST to OH_RUNTIME_HOSTS since it now drives
both directives, and added coverage for wildcards and the posthog
asset host.

Co-authored-by: openhands <openhands@all-hands.dev>
- Remove openhands/app_server/security/ package and its router include
  in v1_router.py. Violations still surface in the browser DevTools
  / console under report-only mode, which is enough for the user to
  triage.
- Drop report-uri directive from the default policy and the
  CONTENT_SECURITY_POLICY_REPORT_URI env var (now unused).
- Trim the corresponding tests.

Co-authored-by: openhands <openhands@all-hands.dev>
@tofarr tofarr changed the title feat: Add CSP report-only middleware and report collector (OHE-2815) Add CSP report-only middleware (OHE-2815) Jul 29, 2026
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  openhands/app_server
  app.py
  middleware.py 47-52, 86-105, 122, 128-137, 142-159
Project Total  

This report was generated by python-coverage-comment-action

Per reviewer feedback, drop OH_RUNTIME_HOSTS and reuse the existing
WEB_HOST (the deployment's bare apex hostname): inject a single
'https://*.WEB_HOST' source into both frame-src and connect-src.

CSP3 '*.host' matches the host itself plus any number of subdomains,
so one entry covers '<sandbox>.<apex>' (REST + WS) and
'vscode-<sandbox>.<apex>' (iframe). Returns empty when WEB_HOST is
unset, so local dev keeps frame-src 'self' without further config.

Tolerate accidental 'https://WEB_HOST' or trailing '/' since
operators set WEB_HOST inconsistently across the codebase.

Co-authored-by: openhands <openhands@all-hands.dev>
Staging reported new connect-src violations: the public app lives at
'staging.all-hands.dev' (the WEB_HOST value), but the runtime lives at
'<sandbox>.staging-runtime.all-hands.dev' - a *sibling* subdomain
tree under 'all-hands.dev'. Wildcarding '*.staging.all-hands.dev'
misses those siblings.

Drop the leftmost DNS label so WEB_HOST is wildcarded at the
registrable domain (eTLD+1):

  staging.all-hands.dev   -> https://*.all-hands.dev
  app.all-hands.dev       -> https://*.all-hands.dev
  openhands.example.com   -> https://*.example.com
  app.example.co.uk       -> https://*.example.co.uk

CSP3 '*.host' matches the host itself plus any number of subdomains,
so a single entry covers the API calls, the VS Code iframe, and any
other sibling subdomain the runtime team spins up.

WEB_HOST=localhost (single label) is skipped - the wildcard would
either be invalid or too broad.

Co-authored-by: openhands <openhands@all-hands.dev>
@tofarr tofarr changed the title Add CSP report-only middleware (OHE-2815) feat: Add CSP report-only middleware (OHE-2815) Jul 30, 2026
Staging report was clean after the registrable-domain fix, so flip
the header from 'Content-Security-Policy-Report-Only' to
'Content-Security-Policy' in dispatch(). Violations are now blocked
by the browser; DevTools still logs them for triage.

Rename the override env var from CONTENT_SECURITY_POLICY_REPORT_ONLY
to CONTENT_SECURITY_POLICY (it's now the policy itself, not a
report-only variant). Detection uses os.getenv without a default so
an explicit empty value disables CSP entirely - useful as a
kill switch without a redeploy.

Test changes:
- _csp helper now reads 'Content-Security-Policy'.
- New 'test_no_report_only_header' asserts the report-only header is
  not emitted and the enforced header is.
- New 'test_explicit_empty_override_disables_csp' covers the kill
  switch.
- Renamed test method that asserted on the old header name.

Co-authored-by: openhands <openhands@all-hands.dev>
@tofarr tofarr changed the title feat: Add CSP report-only middleware (OHE-2815) Enforce CSP via middleware (OHE-2815) Jul 30, 2026
@tofarr tofarr changed the title Enforce CSP via middleware (OHE-2815) feat: Enforce CSP via middleware (OHE-2815) Jul 30, 2026
tofarr and others added 2 commits July 30, 2026 10:49
Failing test: test_explicit_empty_override_disables_csp asserted
'Content-Security-Policy' not in response.headers when
CONTENT_SECURITY_POLICY='' (the kill-switch path). dispatch() was
setting headers[Content-Security-Policy] = '' unconditionally, so
the header was present with an empty value.

Guard the assignment: only set the header when _policy() returns a
non-empty string. That makes CONTENT_SECURITY_POLICY='' actually
disable CSP, matching what the docstring promises.

Co-authored-by: openhands <openhands@all-hands.dev>
@tofarr
tofarr marked this pull request as ready for review July 30, 2026 10:16
@tofarr
tofarr merged commit ff8e2a4 into main Jul 30, 2026
24 checks passed
@tofarr
tofarr deleted the ohe-2815-csp-report-only branch July 30, 2026 10:24
@openhands-release-bot openhands-release-bot Bot added the released: 1.49.0 Shipped in 1.49.0 label Jul 30, 2026
@openhands-release-bot

Copy link
Copy Markdown

🚀 Released in 1.49.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released: 1.49.0 Shipped in 1.49.0 type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants