@statewalker/httpeers-share 0.2.1 → 0.3.1
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 +35 -2
- package/dist/bridge/bridge.d.ts +17 -3
- package/dist/bridge/bridge.d.ts.map +1 -1
- package/dist/bridge/bridge.js +42 -2
- package/dist/bridge/index.d.ts +2 -2
- package/dist/bridge/index.d.ts.map +1 -1
- package/dist/bridge/index.js +2 -2
- package/dist/bridge/serve.d.ts +2 -2
- package/dist/bridge/serve.js +2 -2
- package/dist/bridge/sockets.d.ts +2 -2
- package/dist/bridge/sockets.d.ts.map +1 -1
- package/dist/bridge/sockets.js +6 -4
- package/package.json +3 -2
- package/src/bridge/bridge.ts +49 -5
- package/src/bridge/index.ts +2 -2
- package/src/bridge/serve.ts +2 -2
- package/src/bridge/sockets.ts +7 -5
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
|
|
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
|
|
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.
|
package/dist/bridge/bridge.d.ts
CHANGED
|
@@ -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
|
|
12
|
+
/** The Pairing token; nothing is answered without it. `""`: no token is checked. */
|
|
13
13
|
token: string;
|
|
14
|
-
/**
|
|
15
|
-
|
|
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,
|
|
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"}
|
package/dist/bridge/bridge.js
CHANGED
|
@@ -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
|
|
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 (!
|
|
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
|
package/dist/bridge/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Bridge (Node and
|
|
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;
|
|
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"}
|
package/dist/bridge/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Bridge (Node and
|
|
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";
|
package/dist/bridge/serve.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Bridge on a port, under Node
|
|
3
|
-
*
|
|
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";
|
package/dist/bridge/serve.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Bridge on a port, under Node
|
|
3
|
-
*
|
|
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";
|
package/dist/bridge/sockets.d.ts
CHANGED
|
@@ -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
|
|
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" | "
|
|
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,
|
|
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"}
|
package/dist/bridge/sockets.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"ws": "^8.22.0",
|
|
40
40
|
"@statewalker/httpeers-core": "^0.1.3",
|
|
41
41
|
"@statewalker/httpeers-hub": "^0.1.4",
|
|
42
|
-
"@statewalker/httpeers-member": "^0.3.
|
|
42
|
+
"@statewalker/httpeers-member": "^0.3.2"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@biomejs/biome": "^2.5.15",
|
|
@@ -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"
|
package/src/bridge/bridge.ts
CHANGED
|
@@ -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
|
|
26
|
+
/** The Pairing token; nothing is answered without it. `""`: no token is checked. */
|
|
27
27
|
token: string;
|
|
28
|
-
/**
|
|
29
|
-
|
|
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
|
|
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 (!
|
|
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
|
package/src/bridge/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Bridge (Node and
|
|
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,
|
package/src/bridge/serve.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Bridge on a port, under Node
|
|
3
|
-
*
|
|
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";
|
package/src/bridge/sockets.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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" | "
|
|
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
|
|
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
|
-
|
|
45
|
+
tokenAccepted(p.slice(TOKEN_PROTOCOL_PREFIX.length), init.token),
|
|
44
46
|
);
|
|
45
47
|
if (accept == null) return { ok: false, status: 401 };
|
|
46
48
|
|