From 4fc8e5efdcb63940362a2f3b77e99fafa91c2d09 Mon Sep 17 00:00:00 2001 From: Timo Taskinen Date: Fri, 16 Oct 2026 17:16:08 +0300 Subject: [PATCH] Send a Content-Security-Policy and refuse framing 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. --- CLAUDE.md | 4 +- README.md | 2 +- e2e/tests/content-security-policy.spec.js | 76 +++++++++++++++++++++++ e2e/tests/cross-site.spec.js | 21 +++++++ internal/server/server.go | 29 ++++++++- internal/server/server_test.go | 65 +++++++++++++++++++ web/static/app.js | 12 ++-- web/static/style.css | 26 ++++++++ web/templates/index.html | 2 +- 9 files changed, 224 insertions(+), 13 deletions(-) create mode 100644 e2e/tests/content-security-policy.spec.js diff --git a/CLAUDE.md b/CLAUDE.md index aa0a71d..d47d814 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,7 @@ mailboxzero/ - JSON API responses - Error handling and logging - Static file serving - - `Handler()` builds the router behind the request checks described under Security Considerations (testable end to end); `Start()` serves it through `newHTTPServer`, which sets timeouts: 10 s to read headers, 30 s to read the request, 2 min to write the response (Find Similar fetches up to 10,000 emails in JMAP requests of up to 1000, each with a 30 s client timeout, then groups them), 2 min idle + - `Handler()` builds the router behind the request checks and response headers described under Security Considerations (testable end to end); `Start()` serves it through `newHTTPServer`, which sets timeouts: 10 s to read headers, 30 s to read the request, 2 min to write the response (Find Similar fetches up to 10,000 emails in JMAP requests of up to 1000, each with a 30 s client timeout, then groups them), 2 min idle - Find Similar searches the newest 10,000 inbox emails (`maxSimilarityEmails`), which the client fetches in pages of up to 1000 (or the server's `maxObjectsInGet`). A targeted email outside them (older, or newer than the cache) is fetched by itself with `GetEmail` - Inbox cache for `/api/similar`: that fetch is cached in-process for 60 seconds (`inboxCacheTTL`). A successful archive removes the archived emails from the cache (a dry run leaves it), so successive "Find Similar" / "Archive & Find Next" calls don't re-pull from JMAP; a failed archive (JMAP can reject some emails while moving the rest) and any unarchive invalidate it @@ -190,7 +190,7 @@ default_similarity: 60 # Default similarity percentage (0-100) ### Security Considerations - **Localhost Only:** The web UI has no login, so `validate()` in `internal/config/config.go` rejects any `server.host` that `IsLoopbackHost` does not accept (`localhost`, 127.0.0.0/8, `::1`), including empty and `0.0.0.0`. The app deliberately provides no authentication or HTTPS; remote use goes through an SSH tunnel -- **Browser Protections:** `Handler()` wraps the router in `localhostOnly` (403 unless the `Host` header is a loopback name, which stops DNS rebinding) and Go's `http.CrossOriginProtection` (403 for cross-origin `POST`s via `Sec-Fetch-Site`/`Origin`, which stops cross-site request forgery). `e2e/tests/cross-site.spec.js` checks the latter in a real browser +- **Browser Protections:** `Handler()` wraps the router in `localhostOnly` (403 unless the `Host` header is a loopback name, which stops DNS rebinding) and Go's `http.CrossOriginProtection` (403 for cross-origin `POST`s via `Sec-Fetch-Site`/`Origin`, which stops cross-site request forgery). Outside both, `securityHeaders` sets on every response, refusals included, the Content-Security-Policy `contentSecurityPolicy` (only the app's own script, stylesheet, images and API; no inline script, `on…` handler attributes or `style` attributes; `frame-ancestors 'none'`), `X-Frame-Options: DENY` and `X-Content-Type-Options: nosniff`. That stops other sites from framing the page to trick clicks or keys such as `A` (clickjacking), and stops any markup that slips into the page from running or loading anything. So style elements with classes or the `hidden` attribute, never `style` attributes (setting `element.style` from JavaScript is allowed). `e2e/tests/cross-site.spec.js` checks the forgery and framing protections in a real browser, and `e2e/tests/content-security-policy.spec.js` checks that injected script does not run and that the app works with no policy violations - **API Tokens:** Use Fastmail API tokens for secure authentication - **Local Processing:** All similarity calculations happen locally - **Minimal Permissions:** Uses only the JMAP Core and Mail capabilities (`urn:ietf:params:jmap:mail`), and its only changes move emails between the inbox and the archive diff --git a/README.md b/README.md index 5a1c856..086385a 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ To stay fast on large inboxes, grouping only compares emails that could reach th ## Security Considerations - **Localhost Only**: The web UI has no login, so the server only listens on this machine. `server.host` must be `localhost`, `127.0.0.1` or `::1`; anything else (including `0.0.0.0`) is rejected at startup. To use it from another machine, forward the port over SSH (`ssh -L 8080:localhost:8080 your-server`) and open http://localhost:8080 locally -- **Browser Protections**: Other websites open in your browser cannot use the app: requests addressed to any host name but localhost are refused (blocking DNS rebinding), and cross-origin `POST` requests are refused (blocking cross-site request forgery) +- **Browser Protections**: Other websites open in your browser cannot use the app: requests addressed to any host name but localhost are refused (blocking DNS rebinding), and cross-origin `POST` requests are refused (blocking cross-site request forgery). The app also refuses to be shown in another site's frame (blocking clickjacking), and its Content-Security-Policy lets the page run only its own scripts and styles - **API Tokens**: Use Fastmail API tokens for secure authentication - **Local Only**: All processing happens locally; the server sends requests only to Fastmail's JMAP API - **Read-Heavy**: Only reads email data, minimal write operations diff --git a/e2e/tests/content-security-policy.spec.js b/e2e/tests/content-security-policy.spec.js new file mode 100644 index 0000000..af9b5ad --- /dev/null +++ b/e2e/tests/content-security-policy.spec.js @@ -0,0 +1,76 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +// Record every Content-Security-Policy violation the page reports. +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const violations = []; + /** @type {any} */ (window).cspViolations = violations; + document.addEventListener('securitypolicyviolation', (e) => { + violations.push({ directive: e.effectiveDirective, blocked: e.blockedURI }); + }); + }); +}); + +const violations = (page) => page.evaluate(() => /** @type {any} */ (window).cspViolations); + +test('the page runs no script injected into it', async ({ page }) => { + await page.goto('/'); + + // Markup that slipped into the page, the way a bug in the preview once + // let an email's HTML do: an image whose error handler runs script. + await page.evaluate(() => new Promise((resolve) => { + const image = document.createElement('img'); + image.setAttribute('onerror', 'window.injectedHandlerRan = true'); + image.addEventListener('error', resolve); + image.src = '/no-such-image.png'; + document.body.appendChild(image); + })); + + expect(await page.evaluate(() => /** @type {any} */ (window).injectedHandlerRan)).toBeUndefined(); + await expect.poll(() => violations(page)).toContainEqual({ directive: 'script-src-attr', blocked: 'inline' }); +}); + +test('the app works within its own policy', async ({ page }) => { + const response = await page.goto('/'); + expect(response?.headers()['content-security-policy']).toContain("default-src 'none'"); + await expect(page.locator('#inbox-list .email-item').first()).toBeVisible(); + await expect(page.locator('#archive-status')).toBeHidden(); + + await page.click('#find-similar-btn'); + const similar = page.locator('#similar-list .email-item').first(); + await expect(similar).toBeVisible(); + + // A preview marks up links and addresses with classes, not style + // attributes, which the policy would block. + const id = await similar.getAttribute('data-email-id'); + await page.route((url) => url.pathname === `/api/emails/${id}`, (route) => route.fulfill({ + json: { + bodyValues: { 1: { value: 'See https://example.com/offer or write to help@example.com' } }, + textBody: [{ partId: '1', type: 'text/plain' }], + }, + })); + await similar.hover(); + const link = page.locator('#preview-body span', { hasText: 'https://example.com/offer' }); + await expect(link).toHaveCSS('color', 'rgb(52, 152, 219)'); + await expect(link).toHaveCSS('text-decoration-line', 'underline'); + await expect(page.locator('#preview-body span', { hasText: 'help@example.com' })).toHaveCSS('color', 'rgb(52, 152, 219)'); + await page.keyboard.press('Escape'); + + // The status pill shows once something is archived. Undo puts the emails + // back, since every test shares the mock inbox. + await page.click('#archive-find-next-btn'); + await expect(page.locator('#archive-status')).toBeVisible(); + await expect(page.locator('#archive-status-text')).toHaveText(/^Successfully archived/); + await page.click('#archive-undo-btn'); + await expect(page.locator('#archive-status-text')).toHaveText(/^Unarchived/); + + // An error message keeps its color. + await page.route((url) => url.pathname === '/api/similar', (route) => route.fulfill({ status: 500, body: 'failed' })); + await page.click('#find-similar-btn'); + const error = page.locator('#similar-list .empty-state p'); + await expect(error).toHaveText('Failed to find similar emails.'); + await expect(error).toHaveCSS('color', 'rgb(231, 76, 60)'); + + expect(await violations(page)).toEqual([]); +}); diff --git a/e2e/tests/cross-site.spec.js b/e2e/tests/cross-site.spec.js index 408b73f..424f1d1 100644 --- a/e2e/tests/cross-site.spec.js +++ b/e2e/tests/cross-site.spec.js @@ -42,3 +42,24 @@ test('a page on another site cannot archive emails through the browser', async ( await new Promise((resolve) => attacker.close(resolve)); } }); + +// A site that could show the app in a frame (say, invisibly over its own +// buttons) could trick the user into clicking Archive & Find Next. +test('a page on another site cannot show the app in a frame', async ({ page, baseURL }) => { + const attacker = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(`Some other site`); + }); + await new Promise((resolve) => attacker.listen(attackerPort, '127.0.0.1', resolve)); + + try { + // The browser does ask the app for the page; it just must not show it. + const appRequest = page.waitForRequest(`${baseURL}/`); + await page.goto(`http://127.0.0.1:${attackerPort}/`); + await appRequest; + + await expect(page.frameLocator('iframe').locator('#inbox-list')).toHaveCount(0); + } finally { + await new Promise((resolve) => attacker.close(resolve)); + } +}); diff --git a/internal/server/server.go b/internal/server/server.go index 7ff0a94..89a7236 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -45,6 +45,14 @@ const ( idleTimeout = 2 * time.Minute ) +// contentSecurityPolicy lets the page use only its own script, stylesheet, +// images and API, with no inline script, event handler attributes or style +// attributes, so markup that ever slips into the page can neither run nor +// load anything. frame-ancestors keeps other sites from showing the page in +// a frame. img-src allows the browser's own request for /favicon.ico. +const contentSecurityPolicy = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; " + + "connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + type Server struct { config *config.Config jmapClient jmap.JMAPClient @@ -84,8 +92,10 @@ func (s *Server) Start() error { // Handler returns the HTTP handler that serves the UI, static files and API. // // Listening on localhost only keeps other machines out, but every website -// open in the user's browser can still send requests here. Two checks run -// before the router: +// open in the user's browser can still send requests here, or show the page +// in a frame. Three layers run before the router: +// - securityHeaders tells the browser, on every response (refusals too), +// what the page may load and run, and not to show it in a frame. // - localhostOnly refuses requests addressed to any other host name, which // stops DNS rebinding (a foreign domain re-pointed at 127.0.0.1). // - CrossOriginProtection refuses cross-origin POSTs, so another site cannot @@ -103,7 +113,20 @@ func (s *Server) Handler() http.Handler { r.HandleFunc("/api/unarchive", s.handleUnarchive).Methods("POST") r.HandleFunc("/api/clear", s.handleClear).Methods("POST") - return localhostOnly(http.NewCrossOriginProtection().Handler(r)) + return securityHeaders(localhostOnly(http.NewCrossOriginProtection().Handler(r))) +} + +// securityHeaders sets the Content-Security-Policy, refuses framing (also +// for browsers that only know X-Frame-Options) and stops the browser from +// guessing a response's type, so a response is never run as something else. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", contentSecurityPolicy) + h.Set("X-Frame-Options", "DENY") + h.Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) } // localhostOnly serves only requests whose Host header names this machine. diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f677b5d..bc21aa0 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1532,3 +1532,68 @@ func TestHandleGetEmail(t *testing.T) { }) } } + +// Every response, including refusals, tells the browser to run only the +// app's own scripts and styles and never to show the page in a frame. +func TestHandler_SecurityHeaders(t *testing.T) { + server := setupTestServer(t) + handler := server.Handler() + + tests := []struct { + name string + method string + path string + host string + headers map[string]string + want int + wantType string // a part of Content-Type that nosniff relies on + }{ + {name: "page", method: "GET", path: "/", want: http.StatusOK, wantType: "text/html"}, + {name: "script", method: "GET", path: "/static/app.js", want: http.StatusOK, wantType: "javascript"}, + {name: "stylesheet", method: "GET", path: "/static/style.css", want: http.StatusOK, wantType: "text/css"}, + {name: "API", method: "GET", path: "/api/emails", want: http.StatusOK, wantType: "application/json"}, + {name: "not found", method: "GET", path: "/no-such-page", want: http.StatusNotFound}, + {name: "foreign host", method: "GET", path: "/", host: "evil.example:8080", want: http.StatusForbidden}, + { + name: "cross-site POST", method: "POST", path: "/api/clear", + headers: map[string]string{"Sec-Fetch-Site": "cross-site"}, want: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, nil) + req.Host = "localhost:8080" + if tt.host != "" { + req.Host = tt.host + } + for k, v := range tt.headers { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != tt.want { + t.Errorf("status = %d, want %d", w.Code, tt.want) + } + if got := w.Header().Get("X-Frame-Options"); got != "DENY" { + t.Errorf("X-Frame-Options = %q, want DENY", got) + } + if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("X-Content-Type-Options = %q, want nosniff", got) + } + var directives []string + for _, directive := range strings.Split(w.Header().Get("Content-Security-Policy"), ";") { + directives = append(directives, strings.TrimSpace(directive)) + } + for _, want := range []string{"default-src 'none'", "script-src 'self'", "style-src 'self'", "frame-ancestors 'none'"} { + if !slices.Contains(directives, want) { + t.Errorf("Content-Security-Policy %q lacks %q", w.Header().Get("Content-Security-Policy"), want) + } + } + if got := w.Header().Get("Content-Type"); !strings.Contains(got, tt.wantType) { + t.Errorf("Content-Type = %q, want it to contain %q", got, tt.wantType) + } + }) + } +} diff --git a/web/static/app.js b/web/static/app.js index affe6e3..7cea9c6 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -348,7 +348,7 @@ class MailboxZero { this.archiveStatus.classList.remove('error'); this.archiveStatus.classList.toggle('dry-run', Boolean(dryRun)); this.archiveUndoBtn.style.display = dryRun ? 'none' : ''; - this.archiveStatus.style.display = ''; + this.archiveStatus.hidden = false; } // Show a failed archive or undo in the status pill, with the reason, @@ -359,7 +359,7 @@ class MailboxZero { this.archiveStatus.classList.remove('dry-run'); this.archiveStatus.classList.add('error'); this.archiveUndoBtn.style.display = 'none'; - this.archiveStatus.style.display = ''; + this.archiveStatus.hidden = false; } // Turn a failed response into an Error carrying the server's message, @@ -795,7 +795,7 @@ class MailboxZero { } showError(container, message) { - container.innerHTML = `

${message}

`; + container.innerHTML = `

${message}

`; } showEmpty(container, message) { @@ -1047,11 +1047,11 @@ class MailboxZero { // Format URLs (make them look like links without actually being clickable) formatted = formatted.replace(/\b(https?:\/\/[\w\-\._~:\/?#\[\]@!\$&'\(\)*\+,;=.]+)/gi, - '$1'); + '$1'); // Format email addresses formatted = formatted.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, - '$&'); + '$&'); // Bold text patterns (common email signatures) formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '$1'); @@ -1059,7 +1059,7 @@ class MailboxZero { // Format common patterns like "From:", "To:", "Subject:" in forwarded emails formatted = formatted.replace(/^(From|To|Subject|Date|CC|BCC):\s*/gim, - '$1: '); + '$1: '); return formatted; } diff --git a/web/static/style.css b/web/static/style.css index 199be03..1b227a9 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -411,6 +411,12 @@ header h1 { border-radius: 5px; } +/* The pill starts hidden; display: flex above would override the browser's + own rule for the hidden attribute. */ +.archive-status[hidden] { + display: none; +} + .archive-status-text { color: #1e7e34; font-size: 0.9em; @@ -479,6 +485,10 @@ header h1 { font-size: 0.9em; } +.empty-state .error-message { + color: #e74c3c; +} + .modal-overlay { display: none; position: fixed; @@ -636,6 +646,22 @@ header h1 { color: #555; } +/* Links, addresses and header labels marked up by enhanceEmailFormatting() + in app.js. They are classes, not style attributes, because the page's + Content-Security-Policy blocks style attributes. */ +.preview-body .preview-link { + color: #3498db; + text-decoration: underline; +} + +.preview-body .preview-address { + color: #3498db; +} + +.preview-body .preview-label { + color: #666; +} + .preview-loading { padding: 40px 20px; text-align: center; diff --git a/web/templates/index.html b/web/templates/index.html index 9d4beed..8c3b452 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -38,7 +38,7 @@

Mailbox Zero

-