@statewalker/httpeers-share 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Sharing an HTTP service with the members of an httpeers mesh. It covers:
10
10
  - how they are invited (invite links);
11
11
  - what a failed join means (join outcome).
12
12
 
13
- `./bridge` is the PeerShare Bridge, for Node and Bun only.
13
+ `./bridge` is the PeerShare Bridge, for Node, Bun and Deno.
14
14
 
15
15
  ## Why it exists
16
16
 
@@ -34,7 +34,7 @@ pnpm add @statewalker/httpeers-share
34
34
  | `.` | `encodeInvite`, `decodeInvite` | anywhere | 06 |
35
35
  | `.` | `joinOutcome`, `revokeGuest`, `isSharerOffline`, `stampGuest` | anywhere | 09, 10, 16 |
36
36
  | `.` | `openTunnel`, `sharerSocket`, `encodeFrame`/`decodeFrames`: a WebSocket inside one full-duplex mesh request | anywhere | 15 |
37
- | `./bridge` | `createBridge` (HTTP and its WebSocket gate), `listenLoopback` (Node), `connectApp`/`relaySocket` (for a Bun host) | Node, Bun | 02, 03, 16 |
37
+ | `./bridge` | `createBridge` (HTTP and its WebSocket gate), `originAllowed`, `listenLoopback`, `connectApp`/`relaySocket` (for another server) | Node, Bun, Deno | 02, 03, 16 |
38
38
 
39
39
  ## Examples
40
40
 
@@ -54,6 +54,19 @@ const mount = serviceTable(
54
54
  ); // the Sharer's `/s` mount
55
55
  ```
56
56
 
57
+ ```ts
58
+ import { createBridge, listenLoopback } from "@statewalker/httpeers-share/bridge";
59
+
60
+ const bridge = await listenLoopback(
61
+ createBridge({
62
+ token: "", // no key: any allowed page may use it
63
+ allowedOrigins: ["https://peershare.httpeers.net", "http://localhost:*", "http://127.0.0.1:*"],
64
+ }),
65
+ 9090,
66
+ );
67
+ bridge.origin; // "http://127.0.0.1:9090"
68
+ ```
69
+
57
70
  ## Internals
58
71
 
59
72
  - The root entry point is isomorphic. `tests/boundary.test.ts` fails if any file under `src/`
@@ -63,6 +76,26 @@ const mount = serviceTable(
63
76
  - `service-table`, `join-outcome` and `stamp-guest` were tested over the mesh in the rungs.
64
77
  Their unit tests here characterise code that moved, and passed on their first run.
65
78
 
79
+ - **Who may call the Bridge.** It listens on 127.0.0.1 only, but a loopback port that answers
80
+ cross-origin requests is reachable from every website the Sharer visits. So a request whose
81
+ `Origin` is not in `allowedOrigins` gets `403` with `x-peershare-bridge: origin-forbidden` and
82
+ no CORS grant, on HTTP and WebSocket alike. A pattern is an exact origin, `scheme://host:*`
83
+ (that host on any port; compared as a parsed URL, so `http://localhost.evil.com` does not
84
+ match `http://localhost:*`) — on any port, or none — or `*`, every site (`Origin: null`,
85
+ sandboxed frames and `file://` pages, included). A request with no `Origin` is a local
86
+ process, admitted by the token alone.
87
+ - **Only loopback names.** A request whose `Host` is not `127.0.0.1`, `localhost` or `[::1]`
88
+ gets `403` `host-forbidden`. That is DNS rebinding: a website rebinds its own name to
89
+ 127.0.0.1, its requests become same-origin and carry no `Origin`, and without this check an
90
+ empty token would let it relay anywhere and read the answers.
91
+ - **The token is optional.** `token: ""` checks no token, so the origin list is the only guard:
92
+ any allowed page can relay to this machine and the private network. With a token, a missing
93
+ or wrong one gets `401` `bad-token` (on a WebSocket, the subprotocol `peershare.<token>`),
94
+ which a page should report as "wrong key", not as "no Bridge running". A WebSocket must offer
95
+ a `peershare.` subprotocol even with no token (`peershare.` will do), or it gets `401`.
96
+ - **Node, Bun and Deno** run `listenLoopback` unchanged: Bun and Deno serve `@hono/node-server`
97
+ and `ws` upgrades through their `node:http` compatibility. `pnpm test:e2e` starts the Bridge
98
+ under each (all three must be installed; a missing one fails with "deno is not installed").
66
99
  - `./bridge` serves with `@hono/node-server`'s `overrideGlobalObjects: false`. By default that
67
100
  server replaces the process's global `Request`/`Response`, which stalls streaming fetches
68
101
  (the mesh member's full-duplex requests) everywhere else in the process.
@@ -9,10 +9,14 @@
9
9
  import { type FetchHandler } from "@statewalker/webrun-http-proxy";
10
10
  import { type SocketGate } from "./sockets.js";
11
11
  export interface BridgeInit {
12
- /** The Pairing token generated at launch; nothing is answered without it. */
12
+ /** The Pairing token; nothing is answered without it. `""`: no token is checked. */
13
13
  token: string;
14
- /** The PeerShare page's origin; the only one granted cross-origin access. */
15
- allowedOrigin: string;
14
+ /**
15
+ * The pages granted cross-origin access: exact origins, `scheme://host:*`
16
+ * (that host on any port, or none) and `*` (every site — any page the
17
+ * Sharer visits can then use the Bridge).
18
+ */
19
+ allowedOrigins: string[];
16
20
  /** Injected by tests; defaults to the platform's. */
17
21
  fetchImpl?: typeof fetch;
18
22
  /** Every address a `.local` name resolves to. Injected by tests; defaults to the OS resolver. */
@@ -25,6 +29,16 @@ export interface BridgeInit {
25
29
  */
26
30
  cookieJars?: boolean;
27
31
  }
32
+ /** Whether `origin` matches one of `patterns` (see `BridgeInit.allowedOrigins`). */
33
+ export declare function originAllowed(origin: string, patterns: string[]): boolean;
34
+ /**
35
+ * Whether a request was addressed to the Bridge by a loopback name. A page
36
+ * whose own name was rebound to 127.0.0.1 (DNS rebinding) sends same-origin
37
+ * requests, so no `Origin` to refuse; only its `Host` gives it away.
38
+ */
39
+ export declare function loopbackHost(host: string | null): boolean;
40
+ /** Whether `given` is the Bridge's token; an empty token admits everyone. */
41
+ export declare function tokenAccepted(given: string | null, token: string): boolean;
28
42
  /** Whether the Bridge may relay to `target` (rung 02 rules): `ok`, `forbidden` or `unreachable`. */
29
43
  export declare function reachable(target: string, resolveHost: (name: string) => Promise<string[]>): Promise<"ok" | "forbidden" | "unreachable">;
30
44
  export declare function systemResolve(name: string): Promise<string[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../../src/bridge/bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,KAAK,YAAY,EAAuB,MAAM,gCAAgC,CAAC;AAYxF,OAAO,EAAE,KAAK,UAAU,EAAc,MAAM,cAAc,CAAC;AAE3D,MAAM,WAAW,UAAU;IACzB,6EAA6E;IAC7E,KAAK,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,aAAa,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,iGAAiG;IACjG,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAClD;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,oGAAoG;AACpG,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,GAC/C,OAAO,CAAC,IAAI,GAAG,WAAW,GAAG,aAAa,CAAC,CAO7C;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAGnE;AAED,4FAA4F;AAC5F,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG;IAAE,OAAO,EAAE,UAAU,CAAA;CAAE,CAAC;AAEnE,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,aAAa,CA+E5D;AAsDD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAK1E"}
1
+ {"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../../src/bridge/bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,KAAK,YAAY,EAAuB,MAAM,gCAAgC,CAAC;AAYxF,OAAO,EAAE,KAAK,UAAU,EAAc,MAAM,cAAc,CAAC;AAE3D,MAAM,WAAW,UAAU;IACzB,oFAAoF;IACpF,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,qDAAqD;IACrD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,iGAAiG;IACjG,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAClD;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,oFAAoF;AACpF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAezE;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAOzD;AAED,6EAA6E;AAC7E,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAE1E;AAED,oGAAoG;AACpG,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,GAC/C,OAAO,CAAC,IAAI,GAAG,WAAW,GAAG,aAAa,CAAC,CAO7C;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAGnE;AAED,4FAA4F;AAC5F,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG;IAAE,OAAO,EAAE,UAAU,CAAA;CAAE,CAAC;AAEnE,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,aAAa,CAkF5D;AAsDD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAK1E"}
@@ -11,6 +11,43 @@ import { MARKER, urlUpstream } from "@statewalker/webrun-http-proxy";
11
11
  import { checkTarget, isPrivateAddress } from "../targets.js";
12
12
  import { BRIDGE_COOKIES_KEPT_HEADER, BRIDGE_GUEST_HEADER, BRIDGE_MARKER, BRIDGE_REDIRECT_LOCATION_HEADER, BRIDGE_REDIRECT_STATUS_HEADER, BRIDGE_SET_COOKIE_HEADER, BRIDGE_TARGET_HEADER, BRIDGE_TOKEN_HEADER, } from "../upstreams.js";
13
13
  import { socketGate } from "./sockets.js";
14
+ /** Whether `origin` matches one of `patterns` (see `BridgeInit.allowedOrigins`). */
15
+ export function originAllowed(origin, patterns) {
16
+ return patterns.some((pattern) => {
17
+ if (pattern === "*" || pattern === origin)
18
+ return true;
19
+ if (!pattern.endsWith(":*"))
20
+ return false;
21
+ // Compared as parsed URLs, so `http://localhost.evil.com` is not `http://localhost`.
22
+ try {
23
+ const want = new URL(pattern.slice(0, -2));
24
+ const got = new URL(origin);
25
+ return (got.protocol === want.protocol && got.hostname === want.hostname && got.origin === origin);
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ });
31
+ }
32
+ /**
33
+ * Whether a request was addressed to the Bridge by a loopback name. A page
34
+ * whose own name was rebound to 127.0.0.1 (DNS rebinding) sends same-origin
35
+ * requests, so no `Origin` to refuse; only its `Host` gives it away.
36
+ */
37
+ export function loopbackHost(host) {
38
+ if (host == null)
39
+ return false;
40
+ try {
41
+ return ["127.0.0.1", "localhost", "[::1]"].includes(new URL(`http://${host}`).hostname);
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /** Whether `given` is the Bridge's token; an empty token admits everyone. */
48
+ export function tokenAccepted(given, token) {
49
+ return token === "" || sameSecret(given, token);
50
+ }
14
51
  /** Whether the Bridge may relay to `target` (rung 02 rules): `ok`, `forbidden` or `unreachable`. */
15
52
  export async function reachable(target, resolveHost) {
16
53
  const verdict = checkTarget(target);
@@ -36,9 +73,12 @@ export function createBridge(init) {
36
73
  const request = jar == null ? incoming : withCookies(incoming, jar);
37
74
  // A page on another site carries its own `Origin`. No `Origin` at all is
38
75
  // a local process (curl, the Probe's CLI): the token alone admits it.
76
+ if (!loopbackHost(request.headers.get("host")))
77
+ return refuse(403, "host-forbidden");
39
78
  const origin = request.headers.get("origin");
40
- if (origin != null && origin !== init.allowedOrigin)
79
+ if (origin != null && !originAllowed(origin, init.allowedOrigins)) {
41
80
  return refuse(403, "origin-forbidden");
81
+ }
42
82
  // A preflight never carries custom headers, so it cannot carry the token:
43
83
  // the origin check above is all that guards it, and it grants nothing
44
84
  // but permission to send the real, token-bearing request.
@@ -47,7 +87,7 @@ export function createBridge(init) {
47
87
  request.headers.has("access-control-request-method")) {
48
88
  return preflight(request, origin);
49
89
  }
50
- if (!sameSecret(request.headers.get(BRIDGE_TOKEN_HEADER), init.token)) {
90
+ if (!tokenAccepted(request.headers.get(BRIDGE_TOKEN_HEADER), init.token)) {
51
91
  return refuse(401, "bad-token", origin);
52
92
  }
53
93
  // No target: the page is asking whether its Bridge is there and its
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The Bridge (Node and Bun only): a locked-down loopback relay from the
2
+ * The Bridge (Node, Bun and Deno): a locked-down loopback relay from the
3
3
  * PeerShare page to the Sharer's local Services, with per-Guest cookie jars.
4
4
  */
5
- export { type BridgeHandler, type BridgeInit, createBridge } from "./bridge.js";
5
+ export { type BridgeHandler, type BridgeInit, createBridge, originAllowed } from "./bridge.js";
6
6
  export { type Listening, listenLoopback } from "./serve.js";
7
7
  export { acceptedProtocol, connectApp, type InboundSocket, relaySocket, SOCKET_PATH, type SocketGate, type SocketPlan, TOKEN_PROTOCOL_PREFIX, } from "./sockets.js";
8
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bridge/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChF,OAAO,EAAE,KAAK,SAAS,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5D,OAAO,EACL,gBAAgB,EAChB,UAAU,EACV,KAAK,aAAa,EAClB,WAAW,EACX,WAAW,EACX,KAAK,UAAU,EACf,KAAK,UAAU,EACf,qBAAqB,GACtB,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bridge/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC/F,OAAO,EAAE,KAAK,SAAS,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5D,OAAO,EACL,gBAAgB,EAChB,UAAU,EACV,KAAK,aAAa,EAClB,WAAW,EACX,WAAW,EACX,KAAK,UAAU,EACf,KAAK,UAAU,EACf,qBAAqB,GACtB,MAAM,cAAc,CAAC"}
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The Bridge (Node and Bun only): a locked-down loopback relay from the
2
+ * The Bridge (Node, Bun and Deno): a locked-down loopback relay from the
3
3
  * PeerShare page to the Sharer's local Services, with per-Guest cookie jars.
4
4
  */
5
- export { createBridge } from "./bridge.js";
5
+ export { createBridge, originAllowed } from "./bridge.js";
6
6
  export { listenLoopback } from "./serve.js";
7
7
  export { acceptedProtocol, connectApp, relaySocket, SOCKET_PATH, TOKEN_PROTOCOL_PREFIX, } from "./sockets.js";
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The Bridge on a port, under Node. Under Bun, `main.ts` hands the same
3
- * handler to `Bun.serve`.
2
+ * The Bridge on a port, under Node, Bun or Deno (the last two through their
3
+ * `node:http` compatibility; `tests/bridge-runtimes.e2e.test.ts`).
4
4
  */
5
5
  import type { FetchHandler } from "@statewalker/webrun-http-proxy";
6
6
  import { type SocketGate } from "./sockets.js";
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The Bridge on a port, under Node. Under Bun, `main.ts` hands the same
3
- * handler to `Bun.serve`.
2
+ * The Bridge on a port, under Node, Bun or Deno (the last two through their
3
+ * `node:http` compatibility; `tests/bridge-runtimes.e2e.test.ts`).
4
4
  */
5
5
  import { serve } from "@hono/node-server";
6
6
  import { WebSocketServer } from "ws";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The Bridge's WebSockets (M9): `/.peershare/socket?target=<url>&protocols=a,b`.
3
3
  *
4
- * Guarded exactly like HTTP (rung 02): the PeerShare origin only, the
4
+ * Guarded exactly like HTTP (rung 02): a loopback `Host`, the allowed origins, the
5
5
  * Pairing token (as the subprotocol `peershare.<token>`, because a browser
6
6
  * cannot set headers on a WebSocket), and the same target rules. Then one
7
7
  * socket to the app is relayed message for message, both ways.
@@ -24,7 +24,7 @@ export type SocketGate = (url: URL, headers: Headers) => Promise<{
24
24
  ok: false;
25
25
  status: 400 | 401 | 403 | 502;
26
26
  }>;
27
- export declare function socketGate(init: Pick<BridgeInit, "token" | "allowedOrigin">, resolveHost: (name: string) => Promise<string[]>): SocketGate;
27
+ export declare function socketGate(init: Pick<BridgeInit, "token" | "allowedOrigins">, resolveHost: (name: string) => Promise<string[]>): SocketGate;
28
28
  /** The page's side of a relayed socket, whatever server accepted it. */
29
29
  export interface InboundSocket {
30
30
  send(data: string | ArrayBuffer): void;
@@ -1 +1 @@
1
- {"version":3,"file":"sockets.d.ts","sourceRoot":"","sources":["../../src/bridge/sockets.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAG9C,eAAO,MAAM,WAAW,uBAAuB,CAAC;AAChD,eAAO,MAAM,qBAAqB,eAAe,CAAC;AAElD,MAAM,WAAW,UAAU;IACzB,+BAA+B;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,yCAAyC;IACzC,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,UAAU,GAAG,CACvB,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,OAAO,KACb,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;CAAE,CAAC,CAAC;AAE5F,wBAAgB,UAAU,CACxB,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,eAAe,CAAC,EACjD,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,GAC/C,UAAU,CA6BZ;AAED,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAC;IACvC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7C;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAY/D;AAED,4GAA4G;AAC5G,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,MAAM,CAE5F;AAID,sGAAsG;AACtG,wBAAgB,WAAW,CACzB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,SAAS,GACb;IAAE,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAC;IAAC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAa5F"}
1
+ {"version":3,"file":"sockets.d.ts","sourceRoot":"","sources":["../../src/bridge/sockets.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAG9C,eAAO,MAAM,WAAW,uBAAuB,CAAC;AAChD,eAAO,MAAM,qBAAqB,eAAe,CAAC;AAElD,MAAM,WAAW,UAAU;IACzB,+BAA+B;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,yCAAyC;IACzC,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,UAAU,GAAG,CACvB,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,OAAO,KACb,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;CAAE,CAAC,CAAC;AAE5F,wBAAgB,UAAU,CACxB,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,gBAAgB,CAAC,EAClD,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,GAC/C,UAAU,CA+BZ;AAED,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAC;IACvC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7C;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAY/D;AAED,4GAA4G;AAC5G,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,MAAM,CAE5F;AAID,sGAAsG;AACtG,wBAAgB,WAAW,CACzB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,SAAS,GACb;IAAE,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAC;IAAC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAa5F"}
@@ -1,24 +1,26 @@
1
1
  /**
2
2
  * The Bridge's WebSockets (M9): `/.peershare/socket?target=<url>&protocols=a,b`.
3
3
  *
4
- * Guarded exactly like HTTP (rung 02): the PeerShare origin only, the
4
+ * Guarded exactly like HTTP (rung 02): a loopback `Host`, the allowed origins, the
5
5
  * Pairing token (as the subprotocol `peershare.<token>`, because a browser
6
6
  * cannot set headers on a WebSocket), and the same target rules. Then one
7
7
  * socket to the app is relayed message for message, both ways.
8
8
  */
9
- import { reachable, sameSecret } from "./bridge.js";
9
+ import { loopbackHost, originAllowed, reachable, tokenAccepted } from "./bridge.js";
10
10
  export const SOCKET_PATH = "/.peershare/socket";
11
11
  export const TOKEN_PROTOCOL_PREFIX = "peershare.";
12
12
  export function socketGate(init, resolveHost) {
13
13
  return async (url, headers) => {
14
14
  if (url.pathname !== SOCKET_PATH)
15
15
  return { ok: false, status: 400 };
16
+ if (!loopbackHost(headers.get("host")))
17
+ return { ok: false, status: 403 };
16
18
  const origin = headers.get("origin");
17
- if (origin != null && origin !== init.allowedOrigin)
19
+ if (origin != null && !originAllowed(origin, init.allowedOrigins))
18
20
  return { ok: false, status: 403 };
19
21
  const offered = (headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim());
20
22
  const accept = offered.find((p) => p.startsWith(TOKEN_PROTOCOL_PREFIX) &&
21
- sameSecret(p.slice(TOKEN_PROTOCOL_PREFIX.length), init.token));
23
+ tokenAccepted(p.slice(TOKEN_PROTOCOL_PREFIX.length), init.token));
22
24
  if (accept == null)
23
25
  return { ok: false, status: 401 };
24
26
  const target = url.searchParams.get("target") ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/httpeers-share",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Share HTTP services with the members of a mesh: upstream kinds, sharing policy, probe, service table, invite links, WebSockets over the mesh, and the Bridge.",
6
6
  "bugs": {
@@ -55,6 +55,7 @@
55
55
  "scripts": {
56
56
  "build": "tsc -p tsconfig.build.json",
57
57
  "test": "pnpm run build && pnpm run typecheck && vitest run",
58
+ "test:e2e": "pnpm run build && E2E=1 vitest run tests/bridge-runtimes.e2e.test.ts",
58
59
  "typecheck": "tsc --noEmit",
59
60
  "lint": "biome check src tests",
60
61
  "lint:check": "biome check src tests"
@@ -23,10 +23,14 @@ import {
23
23
  import { type SocketGate, socketGate } from "./sockets.js";
24
24
 
25
25
  export interface BridgeInit {
26
- /** The Pairing token generated at launch; nothing is answered without it. */
26
+ /** The Pairing token; nothing is answered without it. `""`: no token is checked. */
27
27
  token: string;
28
- /** The PeerShare page's origin; the only one granted cross-origin access. */
29
- allowedOrigin: string;
28
+ /**
29
+ * The pages granted cross-origin access: exact origins, `scheme://host:*`
30
+ * (that host on any port, or none) and `*` (every site — any page the
31
+ * Sharer visits can then use the Bridge).
32
+ */
33
+ allowedOrigins: string[];
30
34
  /** Injected by tests; defaults to the platform's. */
31
35
  fetchImpl?: typeof fetch;
32
36
  /** Every address a `.local` name resolves to. Injected by tests; defaults to the OS resolver. */
@@ -40,6 +44,43 @@ export interface BridgeInit {
40
44
  cookieJars?: boolean;
41
45
  }
42
46
 
47
+ /** Whether `origin` matches one of `patterns` (see `BridgeInit.allowedOrigins`). */
48
+ export function originAllowed(origin: string, patterns: string[]): boolean {
49
+ return patterns.some((pattern) => {
50
+ if (pattern === "*" || pattern === origin) return true;
51
+ if (!pattern.endsWith(":*")) return false;
52
+ // Compared as parsed URLs, so `http://localhost.evil.com` is not `http://localhost`.
53
+ try {
54
+ const want = new URL(pattern.slice(0, -2));
55
+ const got = new URL(origin);
56
+ return (
57
+ got.protocol === want.protocol && got.hostname === want.hostname && got.origin === origin
58
+ );
59
+ } catch {
60
+ return false;
61
+ }
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Whether a request was addressed to the Bridge by a loopback name. A page
67
+ * whose own name was rebound to 127.0.0.1 (DNS rebinding) sends same-origin
68
+ * requests, so no `Origin` to refuse; only its `Host` gives it away.
69
+ */
70
+ export function loopbackHost(host: string | null): boolean {
71
+ if (host == null) return false;
72
+ try {
73
+ return ["127.0.0.1", "localhost", "[::1]"].includes(new URL(`http://${host}`).hostname);
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /** Whether `given` is the Bridge's token; an empty token admits everyone. */
80
+ export function tokenAccepted(given: string | null, token: string): boolean {
81
+ return token === "" || sameSecret(given, token);
82
+ }
83
+
43
84
  /** Whether the Bridge may relay to `target` (rung 02 rules): `ok`, `forbidden` or `unreachable`. */
44
85
  export async function reachable(
45
86
  target: string,
@@ -71,8 +112,11 @@ export function createBridge(init: BridgeInit): BridgeHandler {
71
112
  const request = jar == null ? incoming : withCookies(incoming, jar);
72
113
  // A page on another site carries its own `Origin`. No `Origin` at all is
73
114
  // a local process (curl, the Probe's CLI): the token alone admits it.
115
+ if (!loopbackHost(request.headers.get("host"))) return refuse(403, "host-forbidden");
74
116
  const origin = request.headers.get("origin");
75
- if (origin != null && origin !== init.allowedOrigin) return refuse(403, "origin-forbidden");
117
+ if (origin != null && !originAllowed(origin, init.allowedOrigins)) {
118
+ return refuse(403, "origin-forbidden");
119
+ }
76
120
 
77
121
  // A preflight never carries custom headers, so it cannot carry the token:
78
122
  // the origin check above is all that guards it, and it grants nothing
@@ -84,7 +128,7 @@ export function createBridge(init: BridgeInit): BridgeHandler {
84
128
  ) {
85
129
  return preflight(request, origin);
86
130
  }
87
- if (!sameSecret(request.headers.get(BRIDGE_TOKEN_HEADER), init.token)) {
131
+ if (!tokenAccepted(request.headers.get(BRIDGE_TOKEN_HEADER), init.token)) {
88
132
  return refuse(401, "bad-token", origin);
89
133
  }
90
134
  // No target: the page is asking whether its Bridge is there and its
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The Bridge (Node and Bun only): a locked-down loopback relay from the
2
+ * The Bridge (Node, Bun and Deno): a locked-down loopback relay from the
3
3
  * PeerShare page to the Sharer's local Services, with per-Guest cookie jars.
4
4
  */
5
5
 
6
- export { type BridgeHandler, type BridgeInit, createBridge } from "./bridge.js";
6
+ export { type BridgeHandler, type BridgeInit, createBridge, originAllowed } from "./bridge.js";
7
7
  export { type Listening, listenLoopback } from "./serve.js";
8
8
  export {
9
9
  acceptedProtocol,
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The Bridge on a port, under Node. Under Bun, `main.ts` hands the same
3
- * handler to `Bun.serve`.
2
+ * The Bridge on a port, under Node, Bun or Deno (the last two through their
3
+ * `node:http` compatibility; `tests/bridge-runtimes.e2e.test.ts`).
4
4
  */
5
5
 
6
6
  import type { IncomingMessage } from "node:http";
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * The Bridge's WebSockets (M9): `/.peershare/socket?target=<url>&protocols=a,b`.
3
3
  *
4
- * Guarded exactly like HTTP (rung 02): the PeerShare origin only, the
4
+ * Guarded exactly like HTTP (rung 02): a loopback `Host`, the allowed origins, the
5
5
  * Pairing token (as the subprotocol `peershare.<token>`, because a browser
6
6
  * cannot set headers on a WebSocket), and the same target rules. Then one
7
7
  * socket to the app is relayed message for message, both ways.
8
8
  */
9
9
 
10
10
  import type { BridgeInit } from "./bridge.js";
11
- import { reachable, sameSecret } from "./bridge.js";
11
+ import { loopbackHost, originAllowed, reachable, tokenAccepted } from "./bridge.js";
12
12
 
13
13
  export const SOCKET_PATH = "/.peershare/socket";
14
14
  export const TOKEN_PROTOCOL_PREFIX = "peershare.";
@@ -28,19 +28,21 @@ export type SocketGate = (
28
28
  ) => Promise<{ ok: true; plan: SocketPlan } | { ok: false; status: 400 | 401 | 403 | 502 }>;
29
29
 
30
30
  export function socketGate(
31
- init: Pick<BridgeInit, "token" | "allowedOrigin">,
31
+ init: Pick<BridgeInit, "token" | "allowedOrigins">,
32
32
  resolveHost: (name: string) => Promise<string[]>,
33
33
  ): SocketGate {
34
34
  return async (url, headers) => {
35
35
  if (url.pathname !== SOCKET_PATH) return { ok: false, status: 400 };
36
+ if (!loopbackHost(headers.get("host"))) return { ok: false, status: 403 };
36
37
  const origin = headers.get("origin");
37
- if (origin != null && origin !== init.allowedOrigin) return { ok: false, status: 403 };
38
+ if (origin != null && !originAllowed(origin, init.allowedOrigins))
39
+ return { ok: false, status: 403 };
38
40
 
39
41
  const offered = (headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim());
40
42
  const accept = offered.find(
41
43
  (p) =>
42
44
  p.startsWith(TOKEN_PROTOCOL_PREFIX) &&
43
- sameSecret(p.slice(TOKEN_PROTOCOL_PREFIX.length), init.token),
45
+ tokenAccepted(p.slice(TOKEN_PROTOCOL_PREFIX.length), init.token),
44
46
  );
45
47
  if (accept == null) return { ok: false, status: 401 };
46
48