Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions e2e/tests/content-security-policy.spec.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
21 changes: 21 additions & 0 deletions e2e/tests/cross-site.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<!doctype html><title>Some other site</title><iframe src="${baseURL}/"></iframe>`);
});
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));
}
});
29 changes: 26 additions & 3 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
12 changes: 6 additions & 6 deletions web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -795,7 +795,7 @@ class MailboxZero {
}

showError(container, message) {
container.innerHTML = `<div class="empty-state"><p style="color: #e74c3c;">${message}</p></div>`;
container.innerHTML = `<div class="empty-state"><p class="error-message">${message}</p></div>`;
}

showEmpty(container, message) {
Expand Down Expand Up @@ -1047,19 +1047,19 @@ class MailboxZero {

// Format URLs (make them look like links without actually being clickable)
formatted = formatted.replace(/\b(https?:\/\/[\w\-\._~:\/?#\[\]@!\$&'\(\)*\+,;=.]+)/gi,
'<span style="color: #3498db; text-decoration: underline;">$1</span>');
'<span class="preview-link">$1</span>');

// Format email addresses
formatted = formatted.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
'<span style="color: #3498db;">$&</span>');
'<span class="preview-address">$&</span>');

// Bold text patterns (common email signatures)
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');

// Format common patterns like "From:", "To:", "Subject:" in forwarded emails
formatted = formatted.replace(/^(From|To|Subject|Date|CC|BCC):\s*/gim,
'<strong style="color: #666;">$1:</strong> ');
'<strong class="preview-label">$1:</strong> ');

return formatted;
}
Expand Down
26 changes: 26 additions & 0 deletions web/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -479,6 +485,10 @@ header h1 {
font-size: 0.9em;
}

.empty-state .error-message {
color: #e74c3c;
}

.modal-overlay {
display: none;
position: fixed;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ <h1>Mailbox Zero</h1>
</label>
<button id="archive-btn" class="btn btn-danger" disabled>Archive Selected</button>
<button id="archive-find-next-btn" class="btn btn-danger" title="Archive &amp; Find Next (A)" aria-keyshortcuts="A" disabled>Archive &amp; Find Next</button>
<div id="archive-status" class="archive-status" style="display: none;">
<div id="archive-status" class="archive-status" hidden>
<span id="archive-status-text" class="archive-status-text"></span>
<button id="archive-undo-btn" class="btn btn-secondary archive-status-undo" title="Undo (U)" aria-keyshortcuts="U">Undo</button>
</div>
Expand Down