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(`
${message}