Skip to content

Send a Content-Security-Policy and refuse framing - #41

Merged
taskinen merged 1 commit into
mainfrom
fix/security-headers
Oct 16, 2026
Merged

taskinen merged 1 commit into
mainfrom
fix/security-headers

Conversation

@taskinen

Copy link
Copy Markdown
Owner

Problem

The server told the browser nothing about what the page may do. That left two gaps.

  1. Clickjacking. Any website could show http://localhost:8080 in a frame, for example invisibly over its own buttons, and trick the user into clicking Archive & Find Next (which archives without confirmation) or pressing A.
    • localhostOnly lets the framed page load, because its Host is localhost.
    • The clicks inside it make same-origin requests, which CrossOriginProtection allows.
    • The new e2e test confirmed this on main: a page on another origin showed the app in its iframe.
  2. No second layer against injected markup. The preview bug fixed in Convert email HTML to text without loading or running it #40 showed that one slip let an email run script with the app's access. Nothing in the browser stopped that script, or loading remote images.

Fix

securityHeaders in internal/server/server.go wraps the existing checks. It sets these headers on every response, refusals included:

  • Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
    • The page may use only its own script, stylesheet, images and API.
    • No inline script, on… handler attributes or style attributes.
    • It may not be framed.
    • img-src 'self' covers the browser's own /favicon.ico request.
  • X-Frame-Options: DENY, for browsers that only know that header.
  • X-Content-Type-Options: nosniff, so a response is never run as another type. The Go test checks that the script and stylesheet keep their JavaScript and CSS types.

Left out on purpose:

  • upgrade-insecure-requests, which would break http://localhost.
  • Trusted Types, which every innerHTML would need.
  • Cross-Origin-Resource-Policy and Referrer-Policy. The app has no links or outside requests, and the browser already blocks other sites from reading its JSON.

Inline styles. The policy blocks style attributes, so the five the app had became CSS:

  • The status pill (index.html) starts with the hidden attribute.
    • .archive-status[hidden] { display: none; } is needed because .archive-status sets display: flex.
    • showArchiveStatus() and showArchiveError() set hidden = false.
  • The red error text in showError() becomes .error-message.
  • The link, address and label formatting in enhanceEmailFormatting() becomes .preview-link, .preview-address and .preview-label. They are scoped under .preview-body, so the label still beats .preview-body strong.

Setting element.style from JavaScript (the popup position, the modal) is not affected by the policy.

Docs. The CLAUDE.md Browser Protections entry describes the headers and the rule "classes or hidden, never style attributes". The README Security section mentions the framing and script protection.

This is independent of #40, and the two merge cleanly in either order. Each fixes the preview bug on its own; together they are two layers.

Tests

  • TestHandler_SecurityHeaders (Go): the page, script, stylesheet, API, a 404, a foreign Host (403) and a cross-site POST (403) all carry the headers. The static files keep their Content-Types.
  • cross-site.spec.js, "a page on another site cannot show the app in a frame": a page on 127.0.0.1:18081 frames the app. The browser requests the app, but the frame doesn't show it.
  • New content-security-policy.spec.js:
    • "the page runs no script injected into it": an <img onerror> added to the page doesn't run its handler, and the browser reports a script-src-attr violation.
    • "the app works within its own policy": it first asserts that the CSP header is present, then goes through the main flows:
      • Find Similar;
      • a preview with a link and an address (their colors and underline are checked);
      • Archive & Find Next and Undo, with the status pill;
      • a failed search (the error color is checked).
      • It records every policy violation along the way and expects none.

On main, all four new tests fail:

  • no headers;
  • the framed app is shown;
  • the injected handler runs;
  • no CSP header.

With the change:

  • All 27 Playwright tests pass under the policy, and the 4 new ones pass 40 runs out of 40.
  • go test -race and go vet pass.

Testing on Fastmail

  1. Restart the app (go run main.go) and reload the page.
  2. Use it as usual: page through the inbox, Find Similar, hover previews, Archive & Find Next, Undo, and the keyboard shortcuts.
  3. Everything should look and work as before. The status pill appears after archiving; a preview's links are blue and underlined.
  4. Open the browser's developer tools Console. There should be no "Content Security Policy" errors.
  5. In the Network tab, select the page request. Its response headers include Content-Security-Policy and X-Frame-Options: DENY.

Two gaps in what the browser was told about the page:

- Nothing stopped another site from showing the app in a frame, say
  invisibly over its own buttons, and tricking the user into clicking
  Archive & Find Next or pressing A. localhostOnly lets the framed page
  load, and its clicks make same-origin requests.
- Nothing limited what markup in the page could do. The preview bug
  showed that one slip let an email run script with the app's access.

securityHeaders now sets, on every response including refusals, a
Content-Security-Policy that allows only the app's own script,
stylesheet, images and API, no inline script, handler or style
attributes, and no framing; X-Frame-Options: DENY for browsers that
only know that; and X-Content-Type-Options: nosniff.

The five inline style attributes the policy would block become CSS
classes, and the status pill starts with the hidden attribute.
@taskinen

Copy link
Copy Markdown
Owner Author

Checked the two PRs together, and this one alone:

So the policy stops the preview bug on its own too, and #40 removes its cause.

@taskinen
taskinen merged commit f7a2d98 into main Oct 16, 2026
6 checks passed
@taskinen
taskinen deleted the fix/security-headers branch October 16, 2026 14:53
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