Skip to content
Open
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
16 changes: 16 additions & 0 deletions browser-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
149 changes: 149 additions & 0 deletions browser-host/broker-origin.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
},
);
1 change: 1 addition & 0 deletions browser-host/relay-broker.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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
>;
Expand Down
31 changes: 30 additions & 1 deletion browser-host/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,7 @@ const json = (res, code, body) => {
/** @returns {import('vite').Plugin} */
export function relayBrokerPlugin({
authorizedViewer,
browserOrigin,
relayUrl,
communityAliases,
identity = () => loadIdentity(authorizedViewer),
Expand All @@ -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.
Expand Down Expand Up @@ -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" });
Expand Down
Loading