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

-