fix: harden Bulwark Lite login, deep-link replay and settings gating

This commit is contained in:
Linus Rath
2026-09-18 22:16:57 +02:00
parent 5de98bc04b
commit 2d4a562432
43 changed files with 414 additions and 74 deletions
+126 -13
View File
@@ -53,6 +53,16 @@ export const LITE_SURFACES = ["mail", "calendar", "contacts", "files", "settings
/** Locales written right-to-left; the root shim sets `dir` for them. */
export const RTL_LOCALES = ["ar", "fa", "he"];
/**
* sessionStorage key the 404 shim parks a deep link under. Mirrors
* LITE_PENDING_PATH_KEY in lib/lite.ts (a TS module this script cannot
* import); lib/__tests__/lite-scripts.test.ts pins the two together.
*/
export const LITE_PENDING_PATH_KEY = "bulwark-lite:pending-path";
/** zustand persist key of stores/locale-store.ts (the user's language choice). */
export const LOCALE_STORAGE_KEY = "locale-storage";
/**
* `/api/` strings that legitimately survive in the Lite client chunks. Each
* one is either gated at call time (`IS_LITE`, a policy flag, a config flag
@@ -197,7 +207,11 @@ export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en"
return null;
}
var chosen = null;
try { chosen = pick(localStorage.getItem("NEXT_LOCALE")); } catch (e) {}
// The language picked in Settings (stores/locale-store.ts) wins over the browser's list.
try {
var stored = JSON.parse(localStorage.getItem(${JSON.stringify(LOCALE_STORAGE_KEY)}) || "null");
chosen = pick(stored && stored.state && stored.state.locale);
} catch (e) {}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !chosen && j < langs.length; j++) chosen = pick(langs[j]);
location.replace(base + "/" + (chosen || fallback) + "/" + location.search + location.hash);
@@ -209,6 +223,66 @@ export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en"
`;
}
/**
* 404.html for hosts without rewrite rules (GitHub Pages, S3 website hosting).
*
* `next build` exports Next's default not-found page as 404.html: the
* `[...rest]` catch-all that renders app/(main)/not-found.tsx in the server
* build is deleted for the export, and a route group's not-found.tsx only
* serves `notFound()` calls inside that group. So the park-and-replay logic
* lives in this dependency-free shim instead: a deep link below a known
* surface is parked in sessionStorage and the surface shell is loaded, which
* hands the link over (hooks/use-lite-link-segments.ts). Only `<locale>` and
* `<surface>` from the allowlists ever reach `location.replace`, so a crafted
* URL cannot turn this into an open redirect.
*/
export function buildNotFoundShim({ basePath = "", locales, surfaces = LITE_SURFACES }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>404</title>
<script>
(function () {
var base = ${JSON.stringify(basePath)};
var locales = ${JSON.stringify(locales)};
var surfaces = ${JSON.stringify(surfaces)};
var path = location.pathname;
if (base) {
if (path !== base && path.indexOf(base + "/") !== 0) return;
path = path.slice(base.length) || "/";
}
var parts = path.split("/").filter(Boolean);
if (parts.length < 2 || locales.indexOf(parts[0]) === -1 || surfaces.indexOf(parts[1]) === -1) return;
var target = base + "/" + parts[0] + "/" + parts[1] + "/";
// The shell itself is missing (host misconfigured): show the 404 instead of looping.
if (location.pathname === target) return;
try { sessionStorage.setItem(${JSON.stringify(LITE_PENDING_PATH_KEY)}, location.pathname + location.search); } catch (e) {}
location.replace(target);
})();
</script>
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; background: #fff; color: #111; }
@media (prefers-color-scheme: dark) { body { background: #0a0a0a; color: #ededed; } }
main { text-align: center; padding: 1rem; }
h1 { font-size: 2.25rem; margin: 0 0 .5rem; }
p { margin: 0 0 1.5rem; opacity: .7; }
a { color: inherit; }
</style>
</head>
<body>
<main>
<h1>404</h1>
<p>This page could not be found.</p>
<a href="${basePath}/">Go home</a>
</main>
</body>
</html>
`;
}
/** Netlify / Cloudflare Pages rewrite rules. */
export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACES }) {
const lines = [
@@ -252,24 +326,46 @@ ${basePath || ""}/_next/static/*
`;
}
/**
* Where the unzipped folder has to live for the nginx/Caddy examples: every
* rule below is root-relative, so a sub-path mount means the files sit in a
* folder named after the mount path inside the web root (no `alias` needed,
* which does not work inside a regex `location`).
*/
export function exampleDocRoot(basePath = "") {
return { root: "/var/www/bulwark-lite", files: `/var/www/bulwark-lite${basePath}` };
}
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES }) {
const location = basePath ? `${basePath}/` : "/";
const { root, files } = exampleDocRoot(basePath);
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - nginx example. Serve the unzipped folder at ${location}
# and rewrite deep links to the owning surface's shell.
return `# Bulwark Lite - nginx example. Unzip the archive into ${files}
# so that ${basePath}/index.html is served at ${location}; deep links below a surface
# are rewritten to that surface's shell.
server {
listen 80;
server_name webmail.example.com;
root /var/www/bulwark-lite;
root ${root};
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# See _headers for a Content-Security-Policy that matches your JMAP server.
# Hashed assets never change. (add_header inside a location replaces the
# inherited set, so the security headers are repeated here.)
location ${basePath}/_next/static/ {
${basePath ? `alias /var/www/bulwark-lite/_next/static/;\n ` : ""}expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
}
# Older mime.types files do not know the PWA manifest extension.
location = ${basePath}/manifest.webmanifest {
types { }
default_type application/manifest+json;
}
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
@@ -278,7 +374,7 @@ server {
}
location ${location} {
${basePath ? `alias /var/www/bulwark-lite/;\n ` : ""}try_files $uri $uri/ $uri/index.html =404;
try_files $uri $uri/ $uri/index.html =404;
error_page 404 ${basePath}/404.html;
}
}
@@ -286,10 +382,12 @@ server {
}
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES }) {
const { root, files } = exampleDocRoot(basePath);
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - Caddy example.
return `# Bulwark Lite - Caddy example. Unzip the archive into ${files}
# so that ${basePath}/index.html is served at ${basePath || ""}/.
webmail.example.com {
root * /var/www/bulwark-lite
root * ${root}
encode gzip
header {
@@ -298,16 +396,27 @@ webmail.example.com {
Referrer-Policy strict-origin-when-cross-origin
}
@manifest path ${basePath}/manifest.webmanifest
header @manifest Content-Type application/manifest+json
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
@surface path_regexp surface ^${basePath}/([a-zA-Z-]+)/(${surfaceAlternation})(/.*)?$
handle @surface {
try_files {path} {path}/ {path}/index.html ${basePath}/{re.surface.1}/{re.surface.2}/index.html
}
handle {
try_files {path} {path}/ {path}/index.html ${basePath}/404.html
try_files {path} {path}/ {path}/index.html
}
file_server
# Anything else is a real 404, answered by the shim that replays parked deep links.
handle_errors {
@notfound expression {http.error.status_code} == 404
rewrite @notfound ${basePath}/404.html
file_server
}
}
`;
}
@@ -351,7 +460,7 @@ Deep links such as \`${basePath}/en/mail/thread/abc\` are served by the shell at
- Netlify / Cloudflare Pages: the shipped \`_redirects\` and \`_headers\` files do this.
- nginx: see \`nginx.conf.example\`.
- Caddy: see \`Caddyfile.example\`.
- GitHub Pages and other hosts without rewrites: \`404.html\` replays the link
${basePath ? ` Both examples serve root-relative paths, so unzip into \`<web root>${basePath}\`\n (e.g. \`${exampleDocRoot(basePath).files}\`), not into the web root itself.\n` : ""}- GitHub Pages and other hosts without rewrites: \`404.html\` replays the link
in the browser. It works, with one extra page load.
\`_headers\` also carries the recommended security headers. Adjust
@@ -367,7 +476,8 @@ Not available in Lite (they need the Node.js server): the admin console and
setup wizard, plugins and sidebar apps, settings sync across devices, OAuth /
SSO login, the account security tab (app passwords, 2FA setup), ICS URL
subscriptions and CalDAV discovery, sender favicons, office (WOPI) editing,
web push notifications, the update banner, device pairing.
web push notifications, the update banner, device pairing, and the
"Default apps" page (registering Lite as the mailto:/webcal: handler).
## Security notes
@@ -380,7 +490,10 @@ web push notifications, the update banner, device pairing.
control and keep the Content-Security-Policy from \`_headers\`.
- Servers without Stalwart's token login (\`/api/auth\`) fall back to Basic
auth. "Remember me" is then hidden; the credentials stay in sessionStorage
for the lifetime of the tab so a reload does not sign you out.
for the lifetime of the tab so a reload does not sign you out. The same
fallback applies when a reverse proxy in front of Stalwart answers
\`/api/auth\` without CORS headers: the login still works, but "remember me"
then only lasts for the tab, so add the CORS rule from step 3 there too.
- Because the export contains inline scripts, \`script-src\` must allow
\`'unsafe-inline'\`. Email HTML is still rendered sanitised in a sandboxed
frame, exactly as in the full build.
+4 -1
View File
@@ -5,7 +5,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot,
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
@@ -35,6 +35,9 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
"policy.json": JSON.stringify(buildLitePolicy(), null, 2) + "\n",
"manifest.webmanifest": JSON.stringify(buildManifest({ appName: config.appName, basePath }), null, 2) + "\n",
"index.html": buildRootRedirect({ basePath, locales, defaultLocale }),
// Replaces Next's default not-found page: only this shim replays deep links
// on hosts without rewrite rules (see buildNotFoundShim).
"404.html": buildNotFoundShim({ basePath, locales }),
"_redirects": buildRedirects({ basePath, locales }),
"_headers": buildHeaders({ basePath, connectSrc }),
"nginx.conf.example": buildNginxExample({ basePath }),
+13 -6
View File
@@ -3,15 +3,22 @@
// fallback the shipped nginx/Caddy snippets do. Used by the Playwright smoke
// test and handy for a local look at out/:
//
// node scripts/lite/serve.mjs [outDir=out] [port=4173] [basePath=]
// node scripts/lite/serve.mjs [outDir=out] [port=4173] [basePath=] [--plain]
//
// `--plain` switches the SPA fallback off and answers unknown paths with
// 404.html (status 404), the way GitHub Pages or an S3 bucket would; that is
// the path the 404 shim's park-and-replay exists for.
import { createServer } from "node:http";
import { createReadStream, existsSync, statSync } from "node:fs";
import { extname, join, normalize, resolve } from "node:path";
import { LITE_SURFACES } from "./lib.mjs";
const root = resolve(process.argv[2] || "out");
const port = Number(process.argv[3] || 4173);
const basePath = (process.argv[4] || "").replace(/\/+$/, "");
const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const plain = flags.includes("--plain");
const root = resolve(args[0] || "out");
const port = Number(args[1] || 4173);
const basePath = (args[2] || "").replace(/\/+$/, "");
const TYPES = {
".html": "text/html; charset=utf-8",
@@ -56,7 +63,7 @@ export function resolveRequest(pathname) {
}
const file = fileFor(pathname);
if (file) return { status: 200, file };
const match = pathname.match(surfacePattern);
const match = plain ? null : pathname.match(surfacePattern);
if (match) {
const shell = join(root, match[1], match[2], "index.html");
if (existsSync(shell)) return { status: 200, file: shell };
@@ -71,5 +78,5 @@ createServer((req, res) => {
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
}).listen(port, () => {
console.log(`[lite] serving ${root} at http://localhost:${port}${basePath}/`);
console.log(`[lite] serving ${root} at http://localhost:${port}${basePath}/${plain ? " (plain: no SPA fallback, 404.html for unknown paths)" : ""}`);
});
+41 -12
View File
@@ -1,4 +1,5 @@
import { test, expect, type Page } from "@playwright/test";
import { plainPort } from "./playwright.config";
/**
* Drives a Lite export built with LITE_DEMO_MODE=true. Everything runs in the
@@ -10,16 +11,20 @@ import { test, expect, type Page } from "@playwright/test";
* - the demo account signs in and the mail list renders,
* - top-level navigation between static shells works client-side,
* - a deep link reload keeps its URL and the shell still hydrates,
* - a deep link parked by the 404 shim is replayed by the surface,
* - on a host without rewrites, 404.html parks the deep link and the
* surface replays it (the exported 404.html must be the Lite shim, not
* Next's default not-found page),
* - no request ever targets a server endpoint on the Lite origin.
*/
async function loginAsDemo(page: Page) {
await page.goto("/en/login/");
const PLAIN_ORIGIN = `http://localhost:${plainPort}`;
async function loginAsDemo(page: Page, origin = "") {
await page.goto(`${origin}/en/login/`);
const demoButton = page.getByRole("button", { name: /demo/i }).first();
await expect(demoButton).toBeVisible({ timeout: 30_000 });
await demoButton.click();
await page.waitForURL(/\/en\/?(mail\/?)?(\?.*)?$/, { timeout: 30_000 });
await page.waitForURL(/\/en\/?(mail\/?.*)?(\?.*)?$/, { timeout: 30_000 });
}
test.describe("Bulwark Lite demo export", () => {
@@ -45,6 +50,15 @@ test.describe("Bulwark Lite demo export", () => {
expect(apiRequests).toEqual([]);
});
test("the root shim honours the language chosen in Settings", async ({ page }) => {
await page.goto("/en/login/");
await page.evaluate(() => {
localStorage.setItem("locale-storage", JSON.stringify({ state: { locale: "de" }, version: 0 }));
});
await page.goto("/");
await page.waitForURL(/\/de\/$/);
});
test("demo login, surface switch and deep-link reload", async ({ page }) => {
await loginAsDemo(page);
@@ -68,16 +82,31 @@ test.describe("Bulwark Lite demo export", () => {
expect(apiRequests).toEqual([]);
});
test("a deep link parked by the 404 shim is replayed on the surface", async ({ page }) => {
await loginAsDemo(page);
// What app/(main)/not-found.tsx does on a host without rewrite rules:
// park the link, load the surface root.
await page.evaluate(() => {
sessionStorage.setItem("bulwark-lite:pending-path", "/en/mail/folder/inbox");
});
await page.goto("/en/mail/");
test("404.html is the Lite shim, not Next's default not-found page", async ({ page }) => {
const res = await page.request.get("/404.html");
expect(res.ok()).toBe(true);
const html = await res.text();
expect(html).toContain("bulwark-lite:pending-path");
expect(html).not.toContain("__next_f");
});
test("on a host without rewrites a deep link is parked by 404.html and replayed", async ({ page }) => {
// The plain server answers /en/mail/folder/inbox with 404.html (status
// 404), exactly like GitHub Pages. The shim parks the link, loads
// /en/mail/, and the surface restores the URL.
await loginAsDemo(page, PLAIN_ORIGIN);
const response = await page.goto(`${PLAIN_ORIGIN}/en/mail/folder/inbox`);
expect(response?.status()).toBe(404);
await page.waitForFunction(() => window.location.pathname === "/en/mail/folder/inbox", null, { timeout: 30_000 });
await expect(page.locator("body")).toContainText(/inbox/i, { timeout: 30_000 });
expect(await page.evaluate(() => sessionStorage.getItem("bulwark-lite:pending-path"))).toBeNull();
// A path outside the known surfaces stays a plain 404 page.
const dead = await page.goto(`${PLAIN_ORIGIN}/en/nope`);
expect(dead?.status()).toBe(404);
await page.waitForTimeout(500);
expect(new URL(page.url()).pathname).toBe("/en/nope");
await expect(page.locator("body")).toContainText(/404/);
expect(apiRequests).toEqual([]);
});
});
+19 -8
View File
@@ -1,9 +1,12 @@
import { defineConfig } from "@playwright/test";
// Smoke test for a finished Lite export (`npm run build:lite` with
// LITE_DEMO_MODE=true). Serves out/ with the dependency-free static server
// that applies the same SPA fallback the shipped host snippets do.
// LITE_DEMO_MODE=true). Serves out/ twice with the dependency-free static
// server: once with the SPA fallback the shipped host snippets apply, and
// once "plain" (unknown paths answer 404.html with status 404, like GitHub
// Pages or an S3 bucket) to exercise the 404 shim's park-and-replay path.
const port = Number(process.env.LITE_SMOKE_PORT || 4173);
export const plainPort = Number(process.env.LITE_SMOKE_PLAIN_PORT || port + 1);
export default defineConfig({
testDir: ".",
@@ -17,10 +20,18 @@ export default defineConfig({
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
webServer: {
command: `node scripts/lite/serve.mjs out ${port}`,
port,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
},
webServer: [
{
command: `node scripts/lite/serve.mjs out ${port}`,
port,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
},
{
command: `node scripts/lite/serve.mjs out ${plainPort} "" --plain`,
port: plainPort,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
},
],
});
+9 -2
View File
@@ -2,10 +2,10 @@
// Guards for a finished Lite export: the files a deployer relies on exist,
// and the client chunks reference no server endpoint outside the documented
// allowlist (lib.mjs). Exit 1 with a readable list otherwise.
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings,
LITE_PENDING_PATH_KEY, collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
@@ -31,6 +31,13 @@ export function verifyExport({ root = repoRoot } = {}) {
if (!existsSync(join(outDir, file))) problems.push(`missing out/${file}`);
}
// Next writes its own default not-found page as 404.html; postbuild must
// have replaced it with the shim that parks and replays deep links.
const notFound = join(outDir, "404.html");
if (existsSync(notFound) && !readFileSync(notFound, "utf8").includes(LITE_PENDING_PATH_KEY)) {
problems.push("out/404.html is not the Lite shim (deep links on hosts without rewrites would dead-end); run postbuild");
}
const apiStrings = collectApiStrings(join(outDir, "_next", "static"));
const unexpected = unexpectedApiStrings(apiStrings);
for (const s of unexpected) problems.push(`client chunk references undocumented server endpoint ${s} (add an IS_LITE gate or document it in scripts/lite/lib.mjs)`);