Skip to content

feat(middleware): mode-aware security headers (#437) [stacked on #474] - #475

Open
olegbrok wants to merge 1 commit into
feat/lan-filter-stackedfrom
feat/security-headers-stacked
Open

feat(middleware): mode-aware security headers (#437) [stacked on #474]#475
olegbrok wants to merge 1 commit into
feat/lan-filter-stackedfrom
feat/security-headers-stacked

Conversation

@olegbrok

Copy link
Copy Markdown
Collaborator

Summary

PR-4 of the #433 web-exposed hardening track. Replaces the inline helmet-style middleware in `api.py` with a mode-aware module that adds CSP, HSTS-only-when-verified, and a stricter Permissions-Policy on top of what already shipped.

Stacked on #474#473#472. No code conflicts with the parents; just consumes the cached `app.state.deployment_mode` and `app.state.trusted_proxies`.

Closes part of #433. Refs #437.

Header matrix

Header trusted lan web
`X-Content-Type-Options`
`X-Frame-Options`
`Referrer-Policy`
`Permissions-Policy` basic strict
`Content-Security-Policy` basic strict
`Strict-Transport-Security` ✓ (verified-HTTPS only)
`X-XSS-Protection` `0` `0` `0`

What

  • `pinky_daemon.middleware.security_headers` (new module):

    • `headers_for_mode(mode)` — static set per posture
    • `build_csp(mode)` — CSP string. Web adds `object-src 'none'` + `upgrade-insecure-requests`. `'unsafe-inline'` for `style-src` is acknowledged compromise (Svelte inline styles per CLAUDE.md)
    • `is_verified_https(request)` — direct TLS OR `X-Forwarded-Proto: https` from a peer ∈ `trusted_proxies`. Bare XFP from any other peer is NOT enough (closes the HSTS-poisoning vector).
    • `build_security_headers_middleware(csp_enforcing=None)` — ASGI closure. CSP defaults to `Content-Security-Policy-Report-Only`; flip via `PINKY_CSP_ENFORCING=1` env or `csp_enforcing=True` arg.
  • `pinky_daemon.net.client_identity`: promoted `peer_is_trusted` + `raw_peer_from_request` from underscore-private to public (needed by the HSTS check + future Hardening 5/8: Auth/admin rate limiting #438/Hardening 6/8: Elevated-session protection for /admin/* in web mode #439).

  • `create_api`: replaced the inline middleware.

Murzik review followups (from #437) — addressed

  • HSTS only emitted when HTTPS is verified through a trusted proxy or direct TLS
  • CSP runs in report-only by default; operator opts into enforcing via env
  • CSP allows `data:` for img-src, `'unsafe-inline'` for style-src (compromise documented)
  • `connect-src 'self' ws: wss:` for SSE/websocket
  • `frame-ancestors 'none'` (redundant with X-Frame-Options DENY, fine — split policy if presentations need to be embeddable later)
  • `X-XSS-Protection: 0` in all modes (modern OWASP guidance)
  • Twilio callbacks unaffected (server-to-server)

Tests (24 new)

`tests/test_security_headers.py`:

  • `headers_for_mode`: trusted minimal, lan + web full set, X-XSS=0 everywhere
  • `build_csp`: lan/web directives, web-only extras
  • `is_verified_https`: direct TLS, trusted-peer XFP, untrusted-peer XFP rejected, XFP=http rejected, no-header rejected
  • Middleware closure: trusted minimal, lan CSP report-only, CSP enforcing via kwarg + env, web HSTS only when verified, web NO HSTS when untrusted-peer claims https, websocket upgrade pass-through
  • End-to-end `create_api`: trusted minimal, lan full set + report-only CSP, web no HSTS over plain http

286 existing api tests + all earlier hardening-track tests still green.

Test plan

  • `pytest tests/test_security_headers.py -v` (24 passing)
  • `pytest tests/test_api.py tests/test_*.py` (no regression)
  • `ruff check src/pinky_daemon/middleware/ src/pinky_daemon/net/ src/pinky_daemon/api.py tests/test_security_headers.py` (clean)
  • Manual: load SPA in `web` mode, check Network panel for CSP-Report-Only header + X-Frame-Options + Referrer-Policy
  • Manual: load SPA in `web` mode over plain http, verify NO Strict-Transport-Security header
  • Manual: with reverse proxy in front (`PINKY_TRUSTED_PROXIES=127.0.0.1` + `X-Forwarded-Proto: https`), verify HSTS appears

🤖 Opened by Barsik

PR-4 of the #433 web-exposed hardening track. Replaces the inline
helmet-style middleware in api.py with a mode-aware module that adds CSP,
HSTS-when-verified, and a stricter permissions-policy on top of what was
already shipped.

Header matrix
=============
============================  ========  =====  =====
Header                        trusted   lan    web
============================  ========  =====  =====
X-Content-Type-Options        ✓         ✓      ✓
X-Frame-Options               ✗         ✓      ✓
Referrer-Policy               ✗         ✓      ✓
Permissions-Policy            ✗         ✓      ✓
Content-Security-Policy       ✗         basic  strict
Strict-Transport-Security     ✗         ✗      ✓ (verified-HTTPS only)
X-XSS-Protection              ``0``     ``0``  ``0``
============================  ========  =====  =====

Stacked on #474#473#472.

What
----
* `pinky_daemon.middleware.security_headers`:
  - `headers_for_mode(mode)` — static header set per posture.
  - `build_csp(mode)` — CSP string (web adds object-src 'none' +
    upgrade-insecure-requests). 'unsafe-inline' for style-src is
    documented compromise per CLAUDE.md.
  - `is_verified_https(request)` — direct TLS OR
    `X-Forwarded-Proto: https` from a peer ∈ trusted_proxies. Bare
    `X-Forwarded-Proto: https` from any other peer is NOT enough —
    closes the HSTS-poisoning vector.
  - `build_security_headers_middleware(csp_enforcing=None)` — ASGI
    middleware closure. Reads `app.state.deployment_mode` per request.
    CSP defaults to `Content-Security-Policy-Report-Only`; flip to
    `Content-Security-Policy` via `PINKY_CSP_ENFORCING=1` env or
    `csp_enforcing=True` arg. Soak in report-only first.
* `pinky_daemon.net.client_identity`: promoted `peer_is_trusted` and
  `raw_peer_from_request` from underscore-private to public — needed
  by the security_headers HSTS check and by future #438 / #439 work.
* `create_api`: replaced inline middleware with
  `build_security_headers_middleware()`.

Murzik review followups (from #437)
-----------------------------------
* [x] HSTS only emitted when HTTPS is **verified** through a trusted
  proxy or direct TLS — bare `X-Forwarded-Proto` from untrusted peers
  ignored.
* [x] CSP runs in report-only by default; operator opts into enforcing
  via env.
* [x] CSP allows `data:` for img-src, `'unsafe-inline'` for style-src
  (Svelte inline-styles compromise documented in module docstring).
* [x] `connect-src 'self' ws: wss:` for SSE/websocket.
* [x] `frame-ancestors 'none'` (redundant with X-Frame-Options DENY,
  fine).
* [x] X-XSS-Protection: 0 in all modes (modern OWASP guidance).
* [x] Twilio callbacks unaffected (server-to-server, no CSP relevance).

Tests (24 new)
--------------
* `headers_for_mode`: trusted minimal, lan + web headers, X-XSS=0
  everywhere.
* `build_csp`: lan/web required directives, web-only extras.
* `is_verified_https`: direct TLS, trusted-peer XFP, untrusted-peer
  XFP rejected, XFP=http rejected, no header rejected.
* Middleware closure: trusted minimal, lan CSP report-only, CSP
  enforcing kwarg + env, web HSTS only when verified, web HSTS NOT
  when untrusted-peer claims https, websocket upgrade pass-through.
* End-to-end `create_api`: trusted minimal, lan full set + report-only
  CSP, web no HSTS over plain http.

286 existing api tests still green.

Closes part of #433. Refs #437.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant