diff --git a/browser-host/README.md b/browser-host/README.md index 1ed2d50e7d..1fe8eb0ecb 100644 --- a/browser-host/README.md +++ b/browser-host/README.md @@ -17,3 +17,19 @@ identity pin; never put a private key in environment configuration. Preserve supported browser capabilities while consolidating duplicate implementations. Use the native path for native acceptance and browser workflows to validate browser support; see the [consolidation guidance](../docs/contributing.md#broker-consolidation-and-completion). + +## Hosted browser previews + +`relayBrokerPlugin({ browserOrigin: "https://preview.example.org" })` accepts +one explicit HTTPS browser origin through a trusted proxy. The listener must +stay on loopback. This setting is an explicit development opt-in. The broker +refuses it outside the Vite development server in `development` mode, including +production mode and builds. The proxy must authenticate its users; the origin setting +does not grant access or authenticate a remote client. Use a simulated identity +and relay for shared UX previews. + +Set the origin in trusted server configuration, never from request headers. +Origins with credentials, a path, a query, or a fragment are refused. Requests +still require a loopback Host and matching Origin. Cross-site browser requests +remain refused. Without this setting, the broker requires its HTTP loopback +origin. Native and release hosts do not use this setting. diff --git a/browser-host/broker-origin.test.mjs b/browser-host/broker-origin.test.mjs new file mode 100644 index 0000000000..cbb0faaad5 --- /dev/null +++ b/browser-host/broker-origin.test.mjs @@ -0,0 +1,149 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request } from "node:http"; +import { getPublicKey } from "nostr-tools"; +import { createServer, resolveConfig } from "vite"; +import { expect, it } from "vitest"; +import { relayBrokerPlugin } from "./relay-broker.mjs"; + +it("admits only the configured browser origin through a loopback proxy", async () => { + const directory = mkdtempSync(join(tmpdir(), "buzz-broker-origin-")); + const key = new Uint8Array(32).fill(1); + const browserOrigin = "https://preview.example.org"; + const server = await createServer({ + root: directory, + configFile: false, + logLevel: "silent", + optimizeDeps: { noDiscovery: true, include: [] }, + server: { host: "127.0.0.1", port: 0, watch: null, hmr: false }, + plugins: [ + relayBrokerPlugin({ + browserOrigin, + identity: () => key.slice(), + archiveFile: ":memory:", + }), + ], + }); + try { + await server.listen(); + const base = `http://127.0.0.1:${server.httpServer.address().port}`; + const allowed = await fetch(`${base}/api/relay/identity`, { + headers: { Origin: browserOrigin, "Sec-Fetch-Site": "same-origin" }, + }); + expect(allowed.status).toBe(200); + expect(await allowed.json()).toEqual({ viewer: getPublicKey(key) }); + const post = await fetch( + `${base}/api/relay/prepare-remote-agent-authorization`, + { + method: "POST", + headers: { + Origin: browserOrigin, + "Sec-Fetch-Site": "same-origin", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + owner: getPublicKey(key), + agentPubkey: getPublicKey(new Uint8Array(32).fill(2)), + }), + }, + ); + expect(post.status).toBe(200); + expect((await post.json()).slice(0, 2)).toEqual([ + "auth", + getPublicKey(key), + ]); + for (const Origin of [ + "https://other.example.org", + `${browserOrigin}.evil.example`, + "null", + base, + ]) { + const denied = await fetch(`${base}/api/relay/identity`, { + headers: { Origin }, + }); + expect(denied.status, Origin).toBe(403); + } + for (const headers of [ + {}, + { Origin: browserOrigin, "Sec-Fetch-Site": "cross-site" }, + ]) { + const denied = await fetch( + `${base}/api/relay/prepare-remote-agent-authorization`, + { + method: "POST", + headers, + body: "{}", + }, + ); + expect(denied.status).toBe(403); + } + const hostStatus = await new Promise((resolve, reject) => { + const req = request( + `${base}/api/relay/identity`, + { + headers: { Origin: browserOrigin, Host: "other.example.org" }, + }, + (response) => { + response.resume(); + response.on("end", () => resolve(response.statusCode)); + }, + ); + req.on("error", reject); + req.end(); + }); + expect(hostStatus).toBe(403); + } finally { + await server.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); + +it.each([ + ["serve", "production"], + ["build", "development"], + ["build", "production"], +])("refuses a hosted origin for %s in %s mode", async (command, mode) => { + await expect( + resolveConfig( + { + configFile: false, + mode, + plugins: [ + relayBrokerPlugin({ + browserOrigin: "https://preview.example.org", + identity: () => { + throw new Error("Identity read"); + }, + }), + ], + }, + command, + mode, + ), + ).rejects.toThrow( + "browserOrigin requires the Vite development server in development mode", + ); +}); + +it.each([ + "*", + "http://preview.example.org", + "https://user:pass@preview.example.org", + "https://preview.example.org/path", + "https://preview.example.org/?query", + "https://preview.example.org/#fragment", + "", +])( + "refuses invalid browser origin %s before reading identity", + (browserOrigin) => { + expect(() => + relayBrokerPlugin({ + browserOrigin, + identity: () => { + throw new Error("Identity read"); + }, + }), + ).toThrow("browserOrigin must be an HTTPS origin"); + }, +); diff --git a/browser-host/relay-broker.d.mts b/browser-host/relay-broker.d.mts index 353212fc92..e53c0149c3 100644 --- a/browser-host/relay-broker.d.mts +++ b/browser-host/relay-broker.d.mts @@ -2,6 +2,7 @@ import type { Plugin } from "vite"; export function relayBrokerPlugin(options?: { archiveFile?: string; authorizedViewer?: string | undefined; + browserOrigin?: string | undefined; agentLibrary?: () => Promise< import("../src/features/agents/library").AgentLibrary >; diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index eab68ca6fa..431d066942 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -848,6 +848,7 @@ const json = (res, code, body) => { /** @returns {import('vite').Plugin} */ export function relayBrokerPlugin({ authorizedViewer, + browserOrigin, relayUrl, communityAliases, identity = () => loadIdentity(authorizedViewer), @@ -860,11 +861,38 @@ export function relayBrokerPlugin({ // Tests that hold EOSE on purpose lengthen this real-time deadline. liveSetupTimeoutMs, } = {}) { + const publicOrigin = + browserOrigin === undefined + ? undefined + : typeof browserOrigin === "string" + ? URL.parse(browserOrigin) + : null; + if ( + browserOrigin !== undefined && + (publicOrigin?.protocol !== "https:" || + publicOrigin.username || + publicOrigin.password || + publicOrigin.pathname !== "/" || + publicOrigin.search || + publicOrigin.hash) + ) + throw new Error( + "browserOrigin must be an HTTPS origin without credentials, path, query, or fragment.", + ); const aliases = parseCommunityAliases(communityAliases); const defaultRelay = relayUrl?.trim() ? relayOrigin(relayUrl) : undefined; return { name: "buzz-relay-broker", configResolved(config) { + if ( + publicOrigin && + (config.command !== "serve" || + config.mode !== "development" || + config.isProduction) + ) + throw new Error( + "browserOrigin requires the Vite development server in development mode.", + ); // Request headers express browser origin intent, not remote-client // authentication. Check the effective listener, including CLI overrides, // before configureServer loads the signing identity. @@ -1010,7 +1038,8 @@ export function relayBrokerPlugin({ else if (res.statusCode >= 400) log.warn(line); else log.debug(line); }); - const origin = `http://${req.headers.host ?? ""}`; + const origin = + publicOrigin?.origin ?? `http://${req.headers.host ?? ""}`; // Same-origin browser access only; trusted plugins/local processes are not sandboxed. if (!/^(localhost|127\.0\.0\.1):\d+$/.test(req.headers.host ?? "")) return json(res, 403, { error: "Host rejected" });