141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
/**
|
|
* Computes the union of CSP `frame-src` origins declared by installed and
|
|
* enabled plugins. The proxy reads this on each request so that plugins can
|
|
* embed external content (YouTube, Vimeo, Jitsi, …) without us hard-coding
|
|
* domains in the host CSP.
|
|
*
|
|
* Origins are validated at install time and re-validated here as defense in
|
|
* depth - any malformed value is dropped so a corrupted registry can never
|
|
* inject arbitrary CSP fragments.
|
|
*/
|
|
|
|
import { getPluginRegistry } from './plugin-registry';
|
|
|
|
// `https://host`, `https://host:port`, or `https://*.host[:port]`
|
|
//
|
|
// Each label is alphanumeric with optional inner dashes; the final TLD label
|
|
// MUST start with a letter so we reject raw IPv4 literals.
|
|
//
|
|
// Disallowed by the regex (intentionally):
|
|
// - any scheme other than https
|
|
// - paths, queries, fragments
|
|
// - userinfo, IPv4 literals, IPv6 literals (`[::1]`)
|
|
// - bare wildcards (`https://*`)
|
|
const FRAME_ORIGIN_RE =
|
|
/^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i;
|
|
|
|
export function isValidFrameOrigin(origin: unknown): origin is string {
|
|
if (typeof origin !== 'string') return false;
|
|
if (origin.length > 200) return false;
|
|
if (!FRAME_ORIGIN_RE.test(origin)) return false;
|
|
// Reject control characters / whitespace as a final safeguard against
|
|
// anything that would let an attacker break out of the directive.
|
|
if (/[\s'"`;,()]/.test(origin)) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Sanitises a list of candidate origins from a manifest. Drops invalid
|
|
* entries silently and dedupes (case-insensitive on the host).
|
|
*/
|
|
export function sanitizeFrameOrigins(input: unknown): string[] {
|
|
if (!Array.isArray(input)) return [];
|
|
const seen = new Set<string>();
|
|
const out: string[] = [];
|
|
for (const value of input) {
|
|
if (!isValidFrameOrigin(value)) continue;
|
|
const key = value.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Same syntax + validation as `sanitizeFrameOrigins`, but for the
|
|
* `httpOrigins` manifest field. Kept as a separate exported function so the
|
|
* intent is explicit at every call site (frame embedding vs. HTTP fetch).
|
|
*/
|
|
export const sanitizeHttpOrigins = sanitizeFrameOrigins;
|
|
export const isValidHttpOrigin = isValidFrameOrigin;
|
|
|
|
// ─── apiPostPaths (manifest field) ────────────────────────────
|
|
|
|
/**
|
|
* Validates an `/api/...` path entry. Must start with `/api/`, contain only
|
|
* URL-path-safe characters, and have no `..` segment. The trailing slash is
|
|
* meaningful (treated as a prefix at enforcement time).
|
|
*/
|
|
export function isValidApiPostPath(path: unknown): path is string {
|
|
if (typeof path !== 'string') return false;
|
|
if (path.length === 0 || path.length > 200) return false;
|
|
if (!path.startsWith('/api/')) return false;
|
|
if (path.includes('..')) return false;
|
|
if (/[\s'"`;,()?#]/.test(path)) return false;
|
|
if (!/^[/A-Za-z0-9._\-]+$/.test(path)) return false;
|
|
return true;
|
|
}
|
|
|
|
export function sanitizeApiPostPaths(input: unknown): string[] {
|
|
if (!Array.isArray(input)) return [];
|
|
const seen = new Set<string>();
|
|
const out: string[] = [];
|
|
for (const value of input) {
|
|
if (!isValidApiPostPath(value)) continue;
|
|
if (seen.has(value)) continue;
|
|
seen.add(value);
|
|
out.push(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// In-memory cache. The proxy fires on every page navigation; reading the
|
|
// registry JSON every time is fine but cheap to skip when nothing has
|
|
// changed. Five seconds is short enough to make plugin install/uninstall
|
|
// feel snappy without measurable overhead.
|
|
let cachedAt = 0;
|
|
let cachedOrigins: string[] = [];
|
|
const CACHE_TTL_MS = 5_000;
|
|
|
|
/**
|
|
* Returns the union of frame origins declared by every enabled plugin in
|
|
* the server-side registry, deduped and validated.
|
|
*
|
|
* Returns an empty array on any failure (missing file, parse error, …) so
|
|
* a broken registry only ever shrinks the CSP - never widens it.
|
|
*/
|
|
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
|
|
const now = Date.now();
|
|
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
|
|
|
|
try {
|
|
const registry = await getPluginRegistry();
|
|
const seen = new Set<string>();
|
|
const out: string[] = [];
|
|
for (const plugin of registry.plugins) {
|
|
if (!plugin.enabled) continue;
|
|
const origins = sanitizeFrameOrigins(plugin.frameOrigins);
|
|
for (const o of origins) {
|
|
const key = o.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(o);
|
|
}
|
|
}
|
|
cachedOrigins = out;
|
|
cachedAt = now;
|
|
return out;
|
|
} catch {
|
|
cachedOrigins = [];
|
|
cachedAt = now;
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Force the next call to re-read the registry. Used by install/uninstall. */
|
|
export function invalidateFrameOriginsCache(): void {
|
|
cachedAt = 0;
|
|
cachedOrigins = [];
|
|
}
|