@vincentt-xr/cli 0.18.2 → 0.18.3
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/MIGRATION.md +8 -0
- package/dist/cli/index.js +7 -1
- package/dist/lib/diagnostics.d.ts +2 -1
- package/dist/lib/diagnostics.js +6 -2
- package/dist/lib/feedback.js +5 -4
- package/dist/lib/lifecycle.js +3 -2
- package/dist/lib/relayInfo.d.ts +8 -2
- package/dist/lib/relayInfo.js +15 -5
- package/dist/preview/runner.d.ts +2 -0
- package/dist/preview/runner.js +7 -2
- package/dist/relay/guard.d.ts +23 -0
- package/dist/relay/guard.js +73 -0
- package/dist/relay/server.d.ts +13 -0
- package/dist/relay/server.js +52 -4
- package/package.json +1 -1
package/MIGRATION.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
Per version, newest first. Read by the platform's own version surface and served to
|
|
4
4
|
creators' agents. Accurate or absent, never speculative (B-F5-2).
|
|
5
5
|
|
|
6
|
+
## 0.18.3
|
|
7
|
+
|
|
8
|
+
required: false
|
|
9
|
+
No action required when the preview and the commands reading it (`logs`, `feedback`, `preview --stop`) come from the same CLI version. Behaviour that changed:
|
|
10
|
+
- The local preview relay refuses web pages other than the preview itself. It answers only requests whose Host is `127.0.0.1:<port>` or `localhost:<port>`, accepts the diagnostics socket and feedback posts only from the preview's own address (or from a client that sends no Origin), and refuses a POST that is not `application/json` with 415.
|
|
11
|
+
- The relay's diagnostics read and feedback long-poll now require a per-session token, which the preview writes to `.vincentt/relay.json` (now readable by the owner only). The CLI's own commands send it; a direct request to `/query` or `feedback/next` without the `x-vincentt-relay-token` header is refused with 401. A command from CLI 0.18.2 or older cannot read a preview started by this version, and the reverse still works.
|
|
12
|
+
- `vincentt relay` prints the token it requires and accepts `--origin <url>` for a page it should accept; without it, only pages served from localhost or 127.0.0.1 can connect.
|
|
13
|
+
|
|
6
14
|
## 0.18.2
|
|
7
15
|
|
|
8
16
|
required: false
|
package/dist/cli/index.js
CHANGED
|
@@ -727,7 +727,7 @@ async function followPreview(info, ui, filterTester, filterViewer) {
|
|
|
727
727
|
console.log("✓ Following this preview");
|
|
728
728
|
}
|
|
729
729
|
console.log("");
|
|
730
|
-
const relay = httpRelayClient(`http://127.0.0.1:${info.relayPort}
|
|
730
|
+
const relay = httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
|
|
731
731
|
let since = -1;
|
|
732
732
|
let warnedMissing = false;
|
|
733
733
|
let stop = false;
|
|
@@ -1357,13 +1357,19 @@ async function domain() {
|
|
|
1357
1357
|
// ---- servers ----------------------------------------------------------------
|
|
1358
1358
|
async function relay() {
|
|
1359
1359
|
const { startRelay } = await import("../relay/server.js");
|
|
1360
|
+
const { mintRelayToken, RELAY_TOKEN_HEADER } = await import("../relay/guard.js");
|
|
1360
1361
|
const port = numFlag("--port") ?? 7331;
|
|
1361
1362
|
const dir = flag("--dir");
|
|
1363
|
+
const origin = flag("--origin");
|
|
1364
|
+
const token = mintRelayToken();
|
|
1362
1365
|
startRelay({
|
|
1363
1366
|
port,
|
|
1364
1367
|
projectDir: dir ? path.resolve(dir) : undefined,
|
|
1368
|
+
allowedOrigins: origin ? [origin] : undefined,
|
|
1369
|
+
token,
|
|
1365
1370
|
onLog: (m) => console.log(`[relay] ${m}`),
|
|
1366
1371
|
});
|
|
1372
|
+
console.log(`[relay] /query and feedback/next require ${RELAY_TOKEN_HEADER}: ${token}`);
|
|
1367
1373
|
}
|
|
1368
1374
|
// ---- dispatch ---------------------------------------------------------------
|
|
1369
1375
|
export function usage() {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { DiagEvent, LogEvent, NetworkEvent, RelayQuery, RelayResult, TraceEvent } from "@vincentt-xr/harness/events";
|
|
2
|
+
export declare function relayAuthHeaders(token: string | undefined): Record<string, string>;
|
|
2
3
|
export interface RelayClient {
|
|
3
4
|
query(q: RelayQuery): Promise<RelayResult>;
|
|
4
5
|
}
|
|
5
6
|
/** A RelayClient backed by the relay's HTTP /query endpoint. */
|
|
6
|
-
export declare function httpRelayClient(baseUrl: string): RelayClient;
|
|
7
|
+
export declare function httpRelayClient(baseUrl: string, token?: string): RelayClient;
|
|
7
8
|
export declare function formatLog(e: LogEvent, now: number): string;
|
|
8
9
|
export declare function formatNetwork(e: NetworkEvent, now: number): string;
|
|
9
10
|
export declare function formatTrace(e: TraceEvent, now: number): string;
|
package/dist/lib/diagnostics.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
// the relay and format them into the compact text an agent reads. Kept separate
|
|
3
3
|
// from the SDK wiring (server.ts) so the formatting is unit-testable and the SDK
|
|
4
4
|
// version can churn without touching this.
|
|
5
|
+
import { RELAY_TOKEN_HEADER } from "../relay/guard.js";
|
|
6
|
+
export function relayAuthHeaders(token) {
|
|
7
|
+
return token === undefined ? {} : { [RELAY_TOKEN_HEADER]: token };
|
|
8
|
+
}
|
|
5
9
|
/** A RelayClient backed by the relay's HTTP /query endpoint. */
|
|
6
|
-
export function httpRelayClient(baseUrl) {
|
|
10
|
+
export function httpRelayClient(baseUrl, token) {
|
|
7
11
|
return {
|
|
8
12
|
async query(q) {
|
|
9
13
|
const res = await fetch(`${baseUrl}/query`, {
|
|
10
14
|
method: "POST",
|
|
11
|
-
headers: { "content-type": "application/json" },
|
|
15
|
+
headers: { "content-type": "application/json", ...relayAuthHeaders(token) },
|
|
12
16
|
body: JSON.stringify(q),
|
|
13
17
|
});
|
|
14
18
|
if (!res.ok)
|
package/dist/lib/feedback.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { promises as fs } from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { isProcessAlive, isRelayResponding, readRelayInfo, relayInfoDir, } from "./relayInfo.js";
|
|
14
|
+
import { relayAuthHeaders } from "./diagnostics.js";
|
|
14
15
|
const CURSOR_FILE = ".vincentt/annotations/offset";
|
|
15
16
|
/**
|
|
16
17
|
* A cursor that reads too LOW re-delivers, which is the accepted cost of
|
|
@@ -96,13 +97,13 @@ export async function fetchNext(cwd, opts) {
|
|
|
96
97
|
u.searchParams.set("timeout", String(opts.timeoutMs));
|
|
97
98
|
let res;
|
|
98
99
|
try {
|
|
99
|
-
res = await fetch(u);
|
|
100
|
+
res = await fetch(u, { headers: relayAuthHeaders(info.token) });
|
|
100
101
|
}
|
|
101
102
|
catch {
|
|
102
103
|
// The socket died under us. The pid gate + relay-identity probe is what
|
|
103
104
|
// separates "the preview ended" from "the request failed but a relay is
|
|
104
105
|
// still there" — only the latter is a fault worth an exit 1.
|
|
105
|
-
if (await relayGone(info.pid, info.relayPort)) {
|
|
106
|
+
if (await relayGone(info.pid, info.relayPort, info.token)) {
|
|
106
107
|
return { kind: "closed", reason: "unreachable" };
|
|
107
108
|
}
|
|
108
109
|
throw new Error(`Could not reach the preview relay on :${info.relayPort}. The relay is answering, so this is not a session end.`);
|
|
@@ -117,8 +118,8 @@ export async function fetchNext(cwd, opts) {
|
|
|
117
118
|
throw new Error(`relay feedback/next returned ${res.status}`);
|
|
118
119
|
return { kind: "annotation", annotation: (await res.json()) };
|
|
119
120
|
}
|
|
120
|
-
async function relayGone(pid, port) {
|
|
121
|
+
async function relayGone(pid, port, token) {
|
|
121
122
|
if (pid !== undefined && !isProcessAlive(pid))
|
|
122
123
|
return true;
|
|
123
|
-
return !(await isRelayResponding(port));
|
|
124
|
+
return !(await isRelayResponding(port, undefined, undefined, token));
|
|
124
125
|
}
|
package/dist/lib/lifecycle.js
CHANGED
|
@@ -175,6 +175,7 @@ export async function previewStart(projectCwd, onLog, extra = {}) {
|
|
|
175
175
|
harnessPath: "/__harness",
|
|
176
176
|
expiresAt: preview.expiresAt,
|
|
177
177
|
pidStartTime: await readProcessStartTime(process.pid),
|
|
178
|
+
token: preview.relayToken,
|
|
178
179
|
});
|
|
179
180
|
return { preview, started: true };
|
|
180
181
|
}
|
|
@@ -286,7 +287,7 @@ export async function previewStopSignal(projectCwd, deps = {}) {
|
|
|
286
287
|
// relay's own teardown and print a number that was true of a preview which
|
|
287
288
|
// no longer exists. A dead pid is never queried at all — the marker is stale
|
|
288
289
|
// and there is nothing on that port to ask.
|
|
289
|
-
const relay = deps.relay ?? httpRelayClient(`http://127.0.0.1:${info.relayPort}
|
|
290
|
+
const relay = deps.relay ?? httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
|
|
290
291
|
const connected = await readLiveConnections(relay);
|
|
291
292
|
try {
|
|
292
293
|
(deps.kill ?? ((pid, sig) => process.kill(pid, sig)))(info.pid, "SIGTERM");
|
|
@@ -333,7 +334,7 @@ async function relayForProject(projectCwd, override) {
|
|
|
333
334
|
throw new Error("No running preview for this project. Run `vincentt preview` first.");
|
|
334
335
|
}
|
|
335
336
|
// The relay listens on 127.0.0.1 only; `localhost` may resolve to ::1 first.
|
|
336
|
-
return httpRelayClient(`http://127.0.0.1:${info.relayPort}
|
|
337
|
+
return httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
|
|
337
338
|
}
|
|
338
339
|
/** Raw diagnostics query against the running relay (used by CLI --json). */
|
|
339
340
|
export async function diagQuery(projectCwd, kind, opts = {}, relay) {
|
package/dist/lib/relayInfo.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface RelayInfo {
|
|
|
24
24
|
* written by an older CLI or on a platform with no reader.
|
|
25
25
|
*/
|
|
26
26
|
pidStartTime?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The relay's per-session secret for `/query` and `feedback/next`. The marker
|
|
29
|
+
* is written 0600 because of it. Absent from markers written by an older CLI,
|
|
30
|
+
* whose relay asks for none.
|
|
31
|
+
*/
|
|
32
|
+
token?: string;
|
|
27
33
|
}
|
|
28
34
|
export declare function relayInfoPath(cwd: string): string;
|
|
29
35
|
/**
|
|
@@ -64,7 +70,7 @@ export declare function isProcessAlive(pid: number): boolean;
|
|
|
64
70
|
* runs fifty times a day, and the fallback (mint a preview) is what they asked
|
|
65
71
|
* for anyway.
|
|
66
72
|
*/
|
|
67
|
-
export declare function isRelayResponding(port: number, timeoutMs?: number, fetchImpl?: typeof fetch): Promise<boolean>;
|
|
73
|
+
export declare function isRelayResponding(port: number, timeoutMs?: number, fetchImpl?: typeof fetch, token?: string): Promise<boolean>;
|
|
68
74
|
/**
|
|
69
75
|
* readLivePreview returns the marker ONLY when it still describes a preview that
|
|
70
76
|
* is genuinely running, and CLEARS it otherwise.
|
|
@@ -81,5 +87,5 @@ export declare function isRelayResponding(port: number, timeoutMs?: number, fetc
|
|
|
81
87
|
*/
|
|
82
88
|
export declare function readLivePreview(cwd: string, deps?: {
|
|
83
89
|
processAlive?: (pid: number) => boolean;
|
|
84
|
-
relayResponding?: (port: number) => Promise<boolean>;
|
|
90
|
+
relayResponding?: (port: number, token?: string) => Promise<boolean>;
|
|
85
91
|
}): Promise<RelayInfo | undefined>;
|
package/dist/lib/relayInfo.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { promises as fs } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { locateProjectBinding } from "../shared/config.js";
|
|
8
|
+
import { relayAuthHeaders } from "./diagnostics.js";
|
|
8
9
|
const RELAY_INFO_FILE = "relay.json";
|
|
9
10
|
const VINCENTT_DIR = ".vincentt";
|
|
10
11
|
export function relayInfoPath(cwd) {
|
|
@@ -28,7 +29,15 @@ export async function relayInfoDir(cwd) {
|
|
|
28
29
|
export async function writeRelayInfo(cwd, info) {
|
|
29
30
|
const p = relayInfoPath(cwd);
|
|
30
31
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
31
|
-
|
|
32
|
+
// Removed first so an older 0644 marker is not rewritten in place: `mode`
|
|
33
|
+
// only applies when the file is created, and the token must never be
|
|
34
|
+
// readable by another user.
|
|
35
|
+
await fs.rm(p, { force: true });
|
|
36
|
+
await fs.writeFile(p, JSON.stringify(info, null, 2) + "\n", {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
mode: 0o600,
|
|
39
|
+
flag: "wx",
|
|
40
|
+
});
|
|
32
41
|
return p;
|
|
33
42
|
}
|
|
34
43
|
/** Reads the marker for the TREE containing cwd, not only the folder itself. */
|
|
@@ -83,7 +92,7 @@ export function isProcessAlive(pid) {
|
|
|
83
92
|
* runs fifty times a day, and the fallback (mint a preview) is what they asked
|
|
84
93
|
* for anyway.
|
|
85
94
|
*/
|
|
86
|
-
export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch) {
|
|
95
|
+
export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch, token) {
|
|
87
96
|
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
88
97
|
return false;
|
|
89
98
|
const ac = new AbortController();
|
|
@@ -91,7 +100,7 @@ export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch
|
|
|
91
100
|
try {
|
|
92
101
|
const res = await fetchImpl(`http://127.0.0.1:${port}/query`, {
|
|
93
102
|
method: "POST",
|
|
94
|
-
headers: { "content-type": "application/json" },
|
|
103
|
+
headers: { "content-type": "application/json", ...relayAuthHeaders(token) },
|
|
95
104
|
body: JSON.stringify({ kind: "log", limit: 0 }),
|
|
96
105
|
signal: ac.signal,
|
|
97
106
|
});
|
|
@@ -132,14 +141,15 @@ export async function readLivePreview(cwd, deps = {}) {
|
|
|
132
141
|
if (!info)
|
|
133
142
|
return undefined;
|
|
134
143
|
const processAlive = deps.processAlive ?? isProcessAlive;
|
|
135
|
-
const relayResponding = deps.relayResponding ??
|
|
144
|
+
const relayResponding = deps.relayResponding ??
|
|
145
|
+
((p, t) => isRelayResponding(p, undefined, undefined, t));
|
|
136
146
|
// The cheap gate first: a dead pid needs no network round trip.
|
|
137
147
|
if (info.pid !== undefined && !processAlive(info.pid)) {
|
|
138
148
|
await clearRelayInfo(cwd);
|
|
139
149
|
return undefined;
|
|
140
150
|
}
|
|
141
151
|
// The identity gate: alive-but-not-ours (a recycled pid) fails here.
|
|
142
|
-
if (!(await relayResponding(info.relayPort))) {
|
|
152
|
+
if (!(await relayResponding(info.relayPort, info.token))) {
|
|
143
153
|
await clearRelayInfo(cwd);
|
|
144
154
|
return undefined;
|
|
145
155
|
}
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -136,6 +136,8 @@ export interface RunningPreview {
|
|
|
136
136
|
/** ISO8601, the hard max. Never extended by a heartbeat. */
|
|
137
137
|
expiresAt: string;
|
|
138
138
|
relayPort: number;
|
|
139
|
+
/** The relay's `/query` and `feedback/next` secret. Goes only into the 0600 marker. */
|
|
140
|
+
relayToken: string;
|
|
139
141
|
appPort: number;
|
|
140
142
|
/** Whether the app was responding when we returned. */
|
|
141
143
|
appReady: boolean;
|
package/dist/preview/runner.js
CHANGED
|
@@ -18,6 +18,7 @@ import { realpath } from "node:fs/promises";
|
|
|
18
18
|
import { createServer } from "node:http";
|
|
19
19
|
import { TunnelClient, resolveForwardTarget, } from "@vincentt-xr/harness/tunnel";
|
|
20
20
|
import { startRelay } from "../relay/server.js";
|
|
21
|
+
import { mintRelayToken } from "../relay/guard.js";
|
|
21
22
|
import { isHiddenAppPath, proxyWeb, proxyWs, relayRoute } from "./proxy.js";
|
|
22
23
|
import { getFreePort, isPortListening, waitForApp } from "./net.js";
|
|
23
24
|
import { endPreviewSession, heartbeatPreviewSession, mintPreviewSession, workingTreeKey, PreviewEndedError, } from "./api.js";
|
|
@@ -37,6 +38,7 @@ export async function startPreview(opts) {
|
|
|
37
38
|
const relayPort = opts.relayPort ?? (await getFreePort());
|
|
38
39
|
const frontPort = opts.frontPort ?? (await getFreePort());
|
|
39
40
|
const reuseApp = await isPortListening(appPort);
|
|
41
|
+
const relayToken = mintRelayToken();
|
|
40
42
|
const hiddenRoot = await realpath(projectRoot).catch(() => projectRoot);
|
|
41
43
|
let app;
|
|
42
44
|
let killApp;
|
|
@@ -136,9 +138,13 @@ export async function startPreview(opts) {
|
|
|
136
138
|
process.once("exit", killApp);
|
|
137
139
|
}
|
|
138
140
|
}
|
|
141
|
+
// Fixed here, from the server-computed URL, and never from a request.
|
|
142
|
+
const previewOrigin = new URL(minted.url).origin;
|
|
139
143
|
relay = startRelay({
|
|
140
144
|
port: relayPort,
|
|
141
145
|
path: harnessPath,
|
|
146
|
+
allowedOrigins: [previewOrigin],
|
|
147
|
+
token: relayToken,
|
|
142
148
|
// Annotations land beside the binding, so `vincentt feedback` finds them
|
|
143
149
|
// from anywhere in the tree rather than only from the folder that started
|
|
144
150
|
// the preview.
|
|
@@ -149,8 +155,6 @@ export async function startPreview(opts) {
|
|
|
149
155
|
onCapReached: opts.onCapReached,
|
|
150
156
|
onAnnotation: opts.onAnnotation,
|
|
151
157
|
});
|
|
152
|
-
// Fixed here, from the server-computed URL, and never from a request.
|
|
153
|
-
const previewOrigin = new URL(minted.url).origin;
|
|
154
158
|
front = createServer((req, res) => {
|
|
155
159
|
const route = relayRoute(req.url ?? "/", harnessPath, "http");
|
|
156
160
|
if (route === "refuse") {
|
|
@@ -253,6 +257,7 @@ export async function startPreview(opts) {
|
|
|
253
257
|
sessionId: minted.sessionId,
|
|
254
258
|
expiresAt: minted.expiresAt,
|
|
255
259
|
relayPort,
|
|
260
|
+
relayToken,
|
|
256
261
|
appPort,
|
|
257
262
|
appReady,
|
|
258
263
|
stop,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The header carrying the per-session token on the local-only routes. */
|
|
2
|
+
export declare const RELAY_TOKEN_HEADER = "x-vincentt-relay-token";
|
|
3
|
+
export declare function mintRelayToken(): string;
|
|
4
|
+
/**
|
|
5
|
+
* Host must name the loopback socket the relay bound. A DNS-rebinding page
|
|
6
|
+
* reaches the same socket under its own hostname, and this is where it stops.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isAllowedHost(raw: string | string[] | undefined, port: number, bindHost?: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* No Origin is a non-browser caller (the CLI, the tunnel's own requests).
|
|
11
|
+
* Otherwise only an exact configured origin passes, or, when none is
|
|
12
|
+
* configured, a page served from this machine's loopback. The literal `null`
|
|
13
|
+
* origin (sandboxed frames, file pages) is a browser and is always refused.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isAllowedOrigin(raw: string | string[] | undefined, allowed: readonly string[] | undefined): boolean;
|
|
16
|
+
export declare function isLoopbackOrigin(raw: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* A cross-site form or `no-cors` fetch can only send `text/plain`, form or
|
|
19
|
+
* multipart bodies without a preflight. Requiring JSON forces a preflight the
|
|
20
|
+
* relay never answers.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isJsonContentType(raw: string | undefined): boolean;
|
|
23
|
+
export declare function hasRelayToken(raw: string | string[] | undefined, token: string): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The relay's request gate. Every caller the relay serves is on this machine:
|
|
2
|
+
// the CLI (no Origin), the tunnel client and the front proxy (both rewrite Host
|
|
3
|
+
// to the loopback target), and the preview page itself (its own minted origin).
|
|
4
|
+
// A web page the creator happens to have open is none of those, and these checks
|
|
5
|
+
// are what tell it apart.
|
|
6
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
|
+
/** The header carrying the per-session token on the local-only routes. */
|
|
8
|
+
export const RELAY_TOKEN_HEADER = "x-vincentt-relay-token";
|
|
9
|
+
export function mintRelayToken() {
|
|
10
|
+
return randomBytes(32).toString("base64url");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Host must name the loopback socket the relay bound. A DNS-rebinding page
|
|
14
|
+
* reaches the same socket under its own hostname, and this is where it stops.
|
|
15
|
+
*/
|
|
16
|
+
export function isAllowedHost(raw, port, bindHost = "127.0.0.1") {
|
|
17
|
+
if (typeof raw !== "string")
|
|
18
|
+
return false;
|
|
19
|
+
const host = raw.toLowerCase();
|
|
20
|
+
const allowed = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
21
|
+
if (bindHost === "::1")
|
|
22
|
+
allowed.add(`[::1]:${port}`);
|
|
23
|
+
else
|
|
24
|
+
allowed.add(`${bindHost.toLowerCase()}:${port}`);
|
|
25
|
+
return allowed.has(host);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* No Origin is a non-browser caller (the CLI, the tunnel's own requests).
|
|
29
|
+
* Otherwise only an exact configured origin passes, or, when none is
|
|
30
|
+
* configured, a page served from this machine's loopback. The literal `null`
|
|
31
|
+
* origin (sandboxed frames, file pages) is a browser and is always refused.
|
|
32
|
+
*/
|
|
33
|
+
export function isAllowedOrigin(raw, allowed) {
|
|
34
|
+
if (raw === undefined)
|
|
35
|
+
return true;
|
|
36
|
+
if (typeof raw !== "string")
|
|
37
|
+
return false;
|
|
38
|
+
if (allowed !== undefined)
|
|
39
|
+
return allowed.includes(raw);
|
|
40
|
+
return isLoopbackOrigin(raw);
|
|
41
|
+
}
|
|
42
|
+
export function isLoopbackOrigin(raw) {
|
|
43
|
+
let url;
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(raw);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (url.origin !== raw)
|
|
51
|
+
return false;
|
|
52
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
53
|
+
return false;
|
|
54
|
+
return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A cross-site form or `no-cors` fetch can only send `text/plain`, form or
|
|
58
|
+
* multipart bodies without a preflight. Requiring JSON forces a preflight the
|
|
59
|
+
* relay never answers.
|
|
60
|
+
*/
|
|
61
|
+
export function isJsonContentType(raw) {
|
|
62
|
+
if (raw === undefined)
|
|
63
|
+
return false;
|
|
64
|
+
const type = raw.split(";")[0]?.trim().toLowerCase();
|
|
65
|
+
return type === "application/json";
|
|
66
|
+
}
|
|
67
|
+
export function hasRelayToken(raw, token) {
|
|
68
|
+
if (typeof raw !== "string")
|
|
69
|
+
return false;
|
|
70
|
+
const a = Buffer.from(raw);
|
|
71
|
+
const b = Buffer.from(token);
|
|
72
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
73
|
+
}
|
package/dist/relay/server.d.ts
CHANGED
|
@@ -57,6 +57,19 @@ export interface RelayOptions {
|
|
|
57
57
|
onAnnotation?: (tags: EventTags) => void;
|
|
58
58
|
/** Injected for tests, so cap behavior is drivable without 64 real clients. */
|
|
59
59
|
testerRegistry?: TesterRegistry;
|
|
60
|
+
/**
|
|
61
|
+
* Browser origins allowed on the WebSocket and the annotation POST: the
|
|
62
|
+
* preview's own minted origin. A request with no Origin (the CLI) always
|
|
63
|
+
* passes; any other origin is refused. Omitted, only pages served from
|
|
64
|
+
* loopback pass, which is what a standalone `vincentt relay` serves.
|
|
65
|
+
*/
|
|
66
|
+
allowedOrigins?: readonly string[];
|
|
67
|
+
/**
|
|
68
|
+
* The per-session secret `/query` and `feedback/next` require in
|
|
69
|
+
* `x-vincentt-relay-token`. The preview always sets it; without it those
|
|
70
|
+
* routes are still loopback-Host and no-Origin only.
|
|
71
|
+
*/
|
|
72
|
+
token?: string;
|
|
60
73
|
}
|
|
61
74
|
/** Who produced a batch: the connection's viewer label and its resolved tester. */
|
|
62
75
|
export interface EventTags {
|
package/dist/relay/server.js
CHANGED
|
@@ -9,6 +9,7 @@ import { WebSocketServer } from "ws";
|
|
|
9
9
|
import { EventStore } from "./store.js";
|
|
10
10
|
import { TesterRegistry, isTesterId } from "./testers.js";
|
|
11
11
|
import { AnnotationStore } from "./annotations.js";
|
|
12
|
+
import { RELAY_TOKEN_HEADER, hasRelayToken, isAllowedHost, isAllowedOrigin, isJsonContentType, } from "./guard.js";
|
|
12
13
|
import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "@vincentt-xr/harness/tunnel";
|
|
13
14
|
/** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
|
|
14
15
|
const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
|
|
@@ -45,7 +46,17 @@ export function startRelay(opts = {}) {
|
|
|
45
46
|
}
|
|
46
47
|
return { connections, testers: open.size };
|
|
47
48
|
};
|
|
49
|
+
const host = opts.host ?? "127.0.0.1";
|
|
50
|
+
const allowedOrigins = opts.allowedOrigins;
|
|
51
|
+
const boundPort = () => {
|
|
52
|
+
const addr = http.address();
|
|
53
|
+
return typeof addr === "object" && addr !== null ? addr.port : port;
|
|
54
|
+
};
|
|
48
55
|
const http = createServer((req, res) => handleHttp(req, res, {
|
|
56
|
+
host,
|
|
57
|
+
port: boundPort(),
|
|
58
|
+
allowedOrigins,
|
|
59
|
+
token: opts.token,
|
|
49
60
|
store,
|
|
50
61
|
annotations,
|
|
51
62
|
testers,
|
|
@@ -56,7 +67,23 @@ export function startRelay(opts = {}) {
|
|
|
56
67
|
onCapReached: opts.onCapReached,
|
|
57
68
|
onAnnotation: opts.onAnnotation,
|
|
58
69
|
}));
|
|
59
|
-
const wss = new WebSocketServer({
|
|
70
|
+
const wss = new WebSocketServer({
|
|
71
|
+
server: http,
|
|
72
|
+
path: wsPath,
|
|
73
|
+
verifyClient: ({ req }, done) => {
|
|
74
|
+
if (!isAllowedHost(req.headers.host, boundPort(), host)) {
|
|
75
|
+
log("refused a WebSocket with a foreign Host");
|
|
76
|
+
done(false, 403);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!isAllowedOrigin(req.headers.origin, allowedOrigins)) {
|
|
80
|
+
log("refused a WebSocket from a foreign Origin");
|
|
81
|
+
done(false, 403);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
done(true);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
60
87
|
wss.on("connection", (ws, req) => {
|
|
61
88
|
// The label comes from the CONNECTION, stamped by the tunnel client on this
|
|
62
89
|
// machine after it re-validated the edge's value. It is re-validated ONCE
|
|
@@ -117,7 +144,6 @@ export function startRelay(opts = {}) {
|
|
|
117
144
|
});
|
|
118
145
|
ws.on("close", () => log("client disconnected"));
|
|
119
146
|
});
|
|
120
|
-
const host = opts.host ?? "127.0.0.1";
|
|
121
147
|
http.listen(port, host, () => log(`relay listening on ${host}:${port} (ws ${wsPath}, http /query)`));
|
|
122
148
|
return {
|
|
123
149
|
store,
|
|
@@ -177,6 +203,28 @@ function handleHttp(req, res, ctx) {
|
|
|
177
203
|
res.writeHead(404).end();
|
|
178
204
|
return;
|
|
179
205
|
}
|
|
206
|
+
if (!isAllowedHost(req.headers.host, ctx.port, ctx.host)) {
|
|
207
|
+
ctx.log("refused a request with a foreign Host");
|
|
208
|
+
res.writeHead(403).end();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// The local-only routes have no browser caller at all, so any Origin is foreign.
|
|
212
|
+
const origins = localOnly ? [] : ctx.allowedOrigins;
|
|
213
|
+
if (!isAllowedOrigin(req.headers.origin, origins)) {
|
|
214
|
+
ctx.log("refused a request from a foreign Origin");
|
|
215
|
+
res.writeHead(403).end();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (localOnly && ctx.token !== undefined) {
|
|
219
|
+
if (!hasRelayToken(req.headers[RELAY_TOKEN_HEADER], ctx.token)) {
|
|
220
|
+
res.writeHead(401).end();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
|
|
225
|
+
res.writeHead(415).end();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
180
228
|
if (p === "/query") {
|
|
181
229
|
handleQuery(req, res, url, ctx);
|
|
182
230
|
return;
|
|
@@ -191,8 +239,8 @@ function handleHttp(req, res, ctx) {
|
|
|
191
239
|
}
|
|
192
240
|
res.writeHead(404).end();
|
|
193
241
|
}
|
|
194
|
-
// The
|
|
195
|
-
//
|
|
242
|
+
// The CLI (same laptop) GETs/POSTs /query with a RelayQuery, carrying the
|
|
243
|
+
// session token from the 0600 relay marker.
|
|
196
244
|
function handleQuery(req, res, url, ctx) {
|
|
197
245
|
readQuery(req, url)
|
|
198
246
|
.then((q) => {
|
package/package.json
CHANGED