Skip to content

front door: drop google fonts CDN @import + add security headers#149

Open
Antawari wants to merge 1 commit into
mainfrom
ishtar/frontdoor-no-cdn-and-security-headers
Open

front door: drop google fonts CDN @import + add security headers#149
Antawari wants to merge 1 commit into
mainfrom
ishtar/frontdoor-no-cdn-and-security-headers

Conversation

@Antawari
Copy link
Copy Markdown
Contributor

Summary

Two-defect convergence on the host-only Front Door, shipped together because the CSP and the @import removal are the two halves of "the page makes no third-party network requests."

1. Fingerprint exfiltration on every onboarding

ui.html imported a stylesheet from fonts.googleapis.com, sending the user's browser fingerprint + IP + Referer to Google on every bonfire scan invocation. The Front Door is bound to 127.0.0.1 precisely because the page exposes scraped local state; the third-party font fetch contradicted that posture.

2. Missing security headers

_process_request returned only Content-Type on the HTML response — no Content-Security-Policy, no X-Frame-Options, no Referrer-Policy, no X-Content-Type-Options. A drive-by visit to http://127.0.0.1:<port>/ while an operator had a scan running could render the page in an iframe inside an attacker tab, and any third-party subresource fetch would leak the referrer.

Changes

src/bonfire/onboard/ui.html — removed the @import url('https://fonts.googleapis.com/...') line. The existing font-family fallback chain ('JetBrains Mono', 'Fira Code', 'Courier New', monospace) is in place — most developer workstations have JetBrains Mono installed locally already; remaining users get a graceful fallback to system monospace.

src/bonfire/onboard/server.py — extended the GET / response Headers with:

  • Content-Security-Policy: default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'
  • X-Frame-Options: DENY
  • Referrer-Policy: no-referrer
  • X-Content-Type-Options: nosniff

connect-src 'self' covers the same-origin WebSocket to /ws. 'unsafe-inline' on style/script is required because the current page uses inline <style> and <script> blocks; tightening that requires moving them to sibling resources (out of scope).

tests/unit/test_onboard_server_security_hardening.py (new) — 6 Knight RED tests pinning the contract:

  • ui.html contains zero https:// references.
  • GET / response carries CSP with default-src 'self'.
  • GET / response carries X-Frame-Options: DENY.
  • GET / response carries Referrer-Policy: no-referrer.
  • GET / response carries X-Content-Type-Options: nosniff.
  • Content-Type header preserved (regression guard).

TDD verification

  • Knight RED state confirmed against pre-fix code: 5 fail / 1 pass (the 1 passing is the Content-Type regression guard, which should remain green).
  • Warrior GREEN state confirmed after fix: 6/6 pass.
  • Regression check on existing tests/unit/test_onboard_server.py: 19/19 pass (zero regressions).
  • ruff check + ruff format --check on changed files: clean.

Out of scope (filed for follow-up PR)

The WebSocket _ws_handler currently does json.loads(raw) without calling parse_client_message from onboard/protocol.py. The typed-discriminator validation is decorative for the wire path. Wiring parse_client_message into _ws_handler is a separate change with its own test surface (touches server.py, flow.py, and adds malformed-frame rejection at the wire boundary).

Test plan

  • pytest tests/unit/test_onboard_server_security_hardening.py — confirm 6/6 green.
  • pytest tests/unit/test_onboard_server.py — confirm 19/19 still green (no regression).
  • Manual: bonfire scan --no-browser then curl -i http://127.0.0.1:<port>/ and verify all four security headers are present.
  • Manual: open the page in a browser, DevTools → Network tab, confirm zero requests to fonts.googleapis.com.

🤖 Generated with Claude Code

Two-defect convergence on the host-only Front Door:

1. ui.html imported a stylesheet from fonts.googleapis.com, sending the
   user's browser fingerprint + IP + Referer to Google on every scan.
   The Front Door is bound to 127.0.0.1 precisely because the page
   exposes scraped local state; the third-party font fetch contradicted
   that posture.

2. _process_request returned only Content-Type on the HTML response —
   no Content-Security-Policy, no X-Frame-Options, no Referrer-Policy,
   no X-Content-Type-Options. Drive-by visit to http://127.0.0.1:<port>
   while a scan was running could render the page in an iframe inside
   an attacker tab, and any third-party subresource fetch would leak
   the referrer.

Shipping both as one PR — CSP and the @import removal are the two
halves of "the page makes no third-party network requests." Add the
@import back later and CSP refuses to load it; remove it without CSP
and the next contributor can re-introduce it silently.

## Changes

src/bonfire/onboard/ui.html
  - Removed @import url('https://fonts.googleapis.com/...') from the
    <style> block. The existing font-family fallback chain
    ('JetBrains Mono', 'Fira Code', 'Courier New', monospace) is
    in place — most developer workstations have JetBrains Mono
    installed locally already; remaining users get a graceful fallback.

src/bonfire/onboard/server.py
  - Extended _process_request's GET / response Headers with:
    * Content-Security-Policy with default-src 'self'; connect-src
      'self' (covers same-origin WebSocket to /ws); img-src
      'self' data: (allows inline data URLs); style-src 'self'
      'unsafe-inline' (the page uses inline <style>); script-src
      'self' 'unsafe-inline' (the page uses inline <script>).
    * X-Frame-Options: DENY
    * Referrer-Policy: no-referrer
    * X-Content-Type-Options: nosniff

tests/unit/test_onboard_server_security_hardening.py (new · Knight RED)
  - Six tests pinning the contract:
    * ui.html contains zero https:// references (post-fix grep).
    * GET / response carries CSP with default-src 'self'.
    * GET / response carries X-Frame-Options: DENY.
    * GET / response carries Referrer-Policy: no-referrer.
    * GET / response carries X-Content-Type-Options: nosniff.
    * Content-Type header preserved (regression guard).

## Out of scope (filed for follow-up PR)

WebSocket _ws_handler currently does json.loads(raw) without calling
parse_client_message from onboard/protocol.py. The typed-discriminator
validation is decorative for the wire path. Wiring parse_client_message
into _ws_handler is a separate change with its own test surface
(touches server.py, flow.py, and adds malformed-frame rejection).

## Verification

  pytest tests/unit/test_onboard_server_security_hardening.py
    6 passed in 51.06s (RED→GREEN cycle verified)

  pytest tests/unit/test_onboard_server.py (regression)
    19 passed in 12.09s

  ruff check + format on changed files: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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