mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
123 lines
6.1 KiB
JavaScript
123 lines
6.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Scans a finished Lite export for outside hosts (#903). Every http(s) or
|
|
// ws(s) host literal, and every protocol-relative //host in a string, must be
|
|
// the configured JMAP server, a placeholder, one of Bulwark's built-in outside
|
|
// hosts (switched off at runtime by noThirdPartyTraffic) or a known string
|
|
// that is never requested, in the kind of file it is listed for. Anything else
|
|
// fails the build in CI (.github/workflows/static-lite.yml).
|
|
//
|
|
// node scripts/lite/scan-hosts.mjs [outDir=out]
|
|
//
|
|
// LITE_BUILT_IN_HOSTS mirrors BUILT_IN_HOSTNAMES in lib/outgoing-hosts.ts (a
|
|
// TS module this script cannot import); lib/__tests__/lite-bundle-hosts.test.ts
|
|
// pins the two together.
|
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { join, relative, sep } from "node:path";
|
|
import { isMainModule, resolveRepoRoot } from "./lib.mjs";
|
|
|
|
export const LITE_BUILT_IN_HOSTS = [
|
|
"telemetry.bulwarkmail.org",
|
|
"version.telemetry.bulwarkmail.org",
|
|
"notifications.relay.bulwarkmail.org",
|
|
"extensions.bulwarkmail.org",
|
|
"connector.bulwarkmail.org",
|
|
"icons.duckduckgo.com",
|
|
"api.mymemory.translated.net",
|
|
"www.google.com",
|
|
"randomuser.me",
|
|
"img.freepik.com",
|
|
"openpgpkey.",
|
|
];
|
|
|
|
const CHUNKS = /^_next\/static\/chunks\/[^/]+\.js$/;
|
|
const PAGES = /\.(html|txt)$/;
|
|
|
|
/** Strings in the bundle that are never requested, by where they may appear and why. */
|
|
export const LITE_BUNDLE_NON_REQUEST = [
|
|
{ files: CHUNKS, why: "Bulwark's built-in hosts, off at runtime with noThirdPartyTraffic", hosts: LITE_BUILT_IN_HOSTS },
|
|
{ files: CHUNKS, why: "error and docs links of bundled libraries (Next.js, React, JSZip, ProseMirror, core-js, date-fns)", hosts: ["nextjs.org", "react.dev", "stuk.github.io", "prosemirror.net", "github.com", "foo.bar"] },
|
|
{ files: CHUNKS, why: "example addresses the translations localise", hosts: ["eksempel.dk", "mail.eksempel.dk", "piemers.lv", "pasts.piemers.lv", "ornek.com", "mail.ornek.com"] },
|
|
{ files: CHUNKS, why: "links inside the sample mail of the demo mode (lib/demo/fixtures)", hosts: ["zoom.us", "notion.so", "www.figma.com", "vercel.com", "i.scdn.co", "cf.bstatic.com", "open.spotify.com", "linear.app", "sentry.io", "app.docusign.com", "github.com", "bulwark-webmail.vercel.app"] },
|
|
{ files: CHUNKS, why: "JMAP capability URI and XML namespaces", hosts: ["bulwarkmail.com", "www.w3.org", "creativecommons.org"] },
|
|
{ files: /./, why: "docs links in the translations and the README", hosts: ["bulwarkmail.org", "github.com"] },
|
|
{ files: PAGES, why: "XML namespaces of inline SVG", hosts: ["www.w3.org"] },
|
|
{ files: /^_next\/static\/chunks\/[^/]+\.css$/, why: "XML namespace of the SVG pointers in app/accessibility.css (large mouse pointer)", hosts: ["www.w3.org"] },
|
|
{ files: /^_next\/static\/media\/pdf\.worker[^/]*\.m?js$/, why: "XML namespaces and the licence link inside pdf.js", hosts: ["www.apache.org", "www.w3.org", "www.xfa.org", "ns.adobe.com"] },
|
|
];
|
|
|
|
const RESERVED_TLDS = new Set(["example", "test", "invalid", "localhost", "local", "internal", "lan"]);
|
|
|
|
function isReservedIPv4(ip) {
|
|
const parts = ip.split(".").map(Number);
|
|
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return false;
|
|
const [a, b, c] = parts;
|
|
return a === 0 || a === 10 || a === 127 || a >= 224
|
|
|| (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
|
|
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
|
|| (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 198 && (b === 18 || b === 19))
|
|
|| (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113);
|
|
}
|
|
|
|
/** Same rule as isPlaceholderHost in lib/admin/outgoing-hosts-registry.ts. */
|
|
export function isPlaceholderHost(raw) {
|
|
const host = raw.toLowerCase().replace(/:\d+$/, "").replace(/\.+$/, "");
|
|
if (/^[\d.]+$/.test(host)) return isReservedIPv4(host);
|
|
if (!host.includes(".") || host.includes("*")) return true;
|
|
const tld = host.split(".").pop();
|
|
if (RESERVED_TLDS.has(tld)) return true;
|
|
return /(^|\.)example\.(com|org|net)$/.test(host);
|
|
}
|
|
|
|
const SCHEME_HOST = /\b(?:https?|wss?):\/\/([a-z0-9.*_-]+)/gi;
|
|
const PROTOCOL_RELATIVE = /["'`]\/\/([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
|
|
|
|
export function bundleHosts(text) {
|
|
const out = [];
|
|
for (const m of text.matchAll(SCHEME_HOST)) out.push(m[1].toLowerCase());
|
|
for (const m of text.matchAll(PROTOCOL_RELATIVE)) out.push(m[1].toLowerCase());
|
|
return out;
|
|
}
|
|
|
|
function files(dir, out = []) {
|
|
for (const name of readdirSync(dir)) {
|
|
const full = join(dir, name);
|
|
if (statSync(full).isDirectory()) files(full, out);
|
|
else if (/\.(js|mjs|html|css|json|txt|webmanifest|md|example)$/.test(name) || name === "_headers") out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** "file: host" for every host in `outDir` that isn't accounted for. */
|
|
export function scanBundleHosts(outDir) {
|
|
let jmapHost = null;
|
|
try {
|
|
const config = JSON.parse(readFileSync(join(outDir, "config.json"), "utf8"));
|
|
if (config.jmapServerUrl) jmapHost = new URL(config.jmapServerUrl).host.toLowerCase();
|
|
} catch { /* no config: nothing configured */ }
|
|
const problems = new Set();
|
|
for (const full of files(outDir)) {
|
|
const file = relative(outDir, full).split(sep).join("/");
|
|
for (const raw of bundleHosts(readFileSync(full, "utf8"))) {
|
|
const host = raw.replace(/\.{2,}$/, "");
|
|
if (host === jmapHost) continue;
|
|
const listed = LITE_BUNDLE_NON_REQUEST.some((entry) => entry.files.test(file) && (entry.hosts.includes(raw) || entry.hosts.includes(host)));
|
|
if (listed) continue;
|
|
const known = LITE_BUNDLE_NON_REQUEST.some((entry) => entry.hosts.includes(host));
|
|
if (!known && isPlaceholderHost(host)) continue;
|
|
problems.add(`${file}: ${host}`);
|
|
}
|
|
}
|
|
return [...problems];
|
|
}
|
|
|
|
if (isMainModule(import.meta.url)) {
|
|
const outDir = process.argv[2] ?? join(resolveRepoRoot(import.meta.url), "out");
|
|
const problems = scanBundleHosts(outDir);
|
|
if (problems.length > 0) {
|
|
console.error(`[lite] ${problems.length} outside host(s) in the bundle that aren't accounted for (scripts/lite/scan-hosts.mjs):`);
|
|
for (const p of problems) console.error(` - ${p}`);
|
|
process.exit(1);
|
|
}
|
|
console.log("[lite] no unaccounted outside hosts in the bundle");
|
|
}
|