@floegence/flowersec-core 0.19.7 → 0.19.9

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.
@@ -33,6 +33,12 @@ function ioWriteOpts(signal) {
33
33
  throwIfAborted(signal, "handshake aborted");
34
34
  return signal != null ? { signal } : {};
35
35
  }
36
+ function normalizeClockSkewSeconds(clockSkewSeconds) {
37
+ if (!Number.isFinite(clockSkewSeconds) || !Number.isSafeInteger(clockSkewSeconds) || clockSkewSeconds < 0) {
38
+ throw new Error("invalid clock_skew");
39
+ }
40
+ return clockSkewSeconds;
41
+ }
36
42
  function randomBytes(n) {
37
43
  const out = new Uint8Array(n);
38
44
  crypto.getRandomValues(out);
@@ -228,6 +234,7 @@ export class ServerHandshakeCache {
228
234
  export async function serverHandshake(transport, cache, opts) {
229
235
  if (opts.initExpireAtUnixS <= 0)
230
236
  throw new Error("missing init_exp");
237
+ const clockSkewSeconds = normalizeClockSkewSeconds(opts.clockSkewSeconds);
231
238
  const deadlineMs = handshakeDeadlineMs(opts.timeoutMs);
232
239
  const initFrame = await transport.readBinary(ioReadOpts(opts.signal, deadlineMs));
233
240
  const decodedInit = decodeHandshakeFrame(initFrame, opts.maxHandshakePayload);
@@ -279,9 +286,9 @@ export async function serverHandshake(transport, cache, opts) {
279
286
  if (ack.handshake_id !== entry.handshakeId)
280
287
  throw new Error("handshake_id mismatch");
281
288
  const now = Math.floor(Date.now() / 1000);
282
- if (Math.abs(now - ack.timestamp_unix_s) > opts.clockSkewSeconds)
289
+ if (Math.abs(now - ack.timestamp_unix_s) > clockSkewSeconds)
283
290
  throw new E2EEHandshakeError("timestamp_out_of_skew", "timestamp skew");
284
- if (ack.timestamp_unix_s > opts.initExpireAtUnixS + opts.clockSkewSeconds)
291
+ if (ack.timestamp_unix_s > opts.initExpireAtUnixS + clockSkewSeconds)
285
292
  throw new E2EEHandshakeError("timestamp_after_init_exp", "timestamp after init_exp");
286
293
  const th = transcriptHash({
287
294
  version: PROTOCOL_VERSION,
@@ -5,6 +5,12 @@ const te = new TextEncoder();
5
5
  // RecordError marks record parsing or cryptographic failures.
6
6
  export class RecordError extends Error {
7
7
  }
8
+ // maxRecordSeq is the sequence boundary where a key epoch must stop before uint64 wrap.
9
+ const maxRecordSeq = (1n << 64n) - 1n;
10
+ function assertRecordSeq(seq) {
11
+ if (seq < 0n || seq > maxRecordSeq)
12
+ throw new RecordError("record seq out of range");
13
+ }
8
14
  // maxPlaintextBytes returns the payload cap derived from a record size limit.
9
15
  export function maxPlaintextBytes(maxRecordBytes) {
10
16
  if (maxRecordBytes <= 0)
@@ -17,6 +23,7 @@ export function encryptRecord(key, noncePrefix, flags, seq, plaintext, maxRecord
17
23
  throw new RecordError("key must be 32 bytes");
18
24
  if (noncePrefix.length !== 4)
19
25
  throw new RecordError("noncePrefix must be 4 bytes");
26
+ assertRecordSeq(seq);
20
27
  const cipherLen = plaintext.length + 16;
21
28
  if (cipherLen > 0xffffffff)
22
29
  throw new RecordError("record too large");
@@ -76,6 +76,7 @@ export declare class SecureChannel {
76
76
  private wakeSendWaiters;
77
77
  private rejectQueuedSenders;
78
78
  private failSend;
79
+ private reserveSendSeq;
79
80
  private sendLoop;
80
81
  private readLoop;
81
82
  }
@@ -1,6 +1,13 @@
1
1
  import { RECORD_FLAG_APP, RECORD_FLAG_PING, RECORD_FLAG_REKEY } from "./constants.js";
2
2
  import { decryptRecord, encryptRecord, maxPlaintextBytes } from "./record.js";
3
3
  import { deriveRekeyKey } from "./kdf.js";
4
+ const maxRecordSeq = (1n << 64n) - 1n;
5
+ class RecordSeqExhaustedError extends Error {
6
+ constructor() {
7
+ super("record seq exhausted");
8
+ this.name = "RecordSeqExhaustedError";
9
+ }
10
+ }
4
11
  // SecureChannel encrypts/decrypts records and buffers application payloads.
5
12
  export class SecureChannel {
6
13
  // Underlying transport for encrypted record frames.
@@ -183,6 +190,16 @@ export class SecureChannel {
183
190
  this.rejectQueuedSenders(err);
184
191
  this.wakeSendWaiters();
185
192
  }
193
+ reserveSendSeq() {
194
+ if (this.sendSeq >= maxRecordSeq) {
195
+ const err = new RecordSeqExhaustedError();
196
+ this.failSend(err);
197
+ throw err;
198
+ }
199
+ const seq = this.sendSeq;
200
+ this.sendSeq++;
201
+ return seq;
202
+ }
186
203
  async sendLoop() {
187
204
  while (true) {
188
205
  const req = await this.nextSend();
@@ -199,15 +216,15 @@ export class SecureChannel {
199
216
  try {
200
217
  let frame;
201
218
  if (req.kind === "app") {
202
- const seq = this.sendSeq++;
219
+ const seq = this.reserveSendSeq();
203
220
  frame = encryptRecord(this.sendKey, this.sendNoncePrefix, RECORD_FLAG_APP, seq, req.payload ?? new Uint8Array(), this.maxRecordBytes);
204
221
  }
205
222
  else if (req.kind === "ping") {
206
- const seq = this.sendSeq++;
223
+ const seq = this.reserveSendSeq();
207
224
  frame = encryptRecord(this.sendKey, this.sendNoncePrefix, RECORD_FLAG_PING, seq, new Uint8Array(), this.maxRecordBytes);
208
225
  }
209
226
  else {
210
- const seq = this.sendSeq++;
227
+ const seq = this.reserveSendSeq();
211
228
  frame = encryptRecord(this.sendKey, this.sendNoncePrefix, RECORD_FLAG_REKEY, seq, new Uint8Array(), this.maxRecordBytes);
212
229
  // Update the send key after enqueuing the rekey frame.
213
230
  this.sendKey = deriveRekeyKey(this.rekeyBase, this.transcriptHash, seq, this.sendDir);
@@ -228,6 +245,9 @@ export class SecureChannel {
228
245
  while (!this.closed) {
229
246
  const frame = await this.transport.readBinary();
230
247
  const { flags, seq, plaintext } = decryptRecord(this.recvKey, this.recvNoncePrefix, frame, this.recvSeq, this.maxRecordBytes);
248
+ if (seq >= maxRecordSeq) {
249
+ throw new RecordSeqExhaustedError();
250
+ }
231
251
  this.recvSeq = seq + 1n;
232
252
  if (flags === RECORD_FLAG_APP) {
233
253
  if (this.maxBufferedBytes > 0 && this.recvQueueBytes + plaintext.length > this.maxBufferedBytes) {
@@ -6,6 +6,7 @@ export type RegisterProxyAppWindowOptions = Readonly<{
6
6
  controllerWindow?: Window | null;
7
7
  targetWindow?: Window;
8
8
  maxWsFrameBytes?: number;
9
+ capabilityNonce?: string;
9
10
  }>;
10
11
  export type ProxyAppWindowHandle = Readonly<{
11
12
  runtime: Readonly<{
@@ -40,6 +40,17 @@ function postFetchError(port, message) {
40
40
  // Best-effort.
41
41
  }
42
42
  }
43
+ function normalizeCapabilityNonce(value) {
44
+ if (value == null)
45
+ return "";
46
+ const s = String(value);
47
+ if (s === "")
48
+ return "";
49
+ if (s.trim() !== s || /[\s\u0000-\u001f\u007f]/.test(s)) {
50
+ throw new Error("capabilityNonce must not contain whitespace or control characters");
51
+ }
52
+ return s;
53
+ }
43
54
  export function registerProxyAppWindow(opts) {
44
55
  const controllerOrigin = String(opts.controllerOrigin ?? "").trim();
45
56
  if (controllerOrigin === "") {
@@ -47,6 +58,7 @@ export function registerProxyAppWindow(opts) {
47
58
  }
48
59
  const targetWindow = resolveTargetWindow(opts.targetWindow);
49
60
  const controllerWindow = resolveControllerWindow(targetWindow, opts.controllerWindow);
61
+ const capabilityNonce = normalizeCapabilityNonce(opts.capabilityNonce);
50
62
  const sw = targetWindow.navigator?.serviceWorker;
51
63
  const onServiceWorkerMessage = (ev) => {
52
64
  const data = ev.data;
@@ -58,7 +70,11 @@ export function registerProxyAppWindow(opts) {
58
70
  if (!port)
59
71
  return;
60
72
  try {
61
- controllerWindow.postMessage({ type: PROXY_WINDOW_FETCH_MSG_TYPE, req: data.req }, controllerOrigin, [port]);
73
+ controllerWindow.postMessage({
74
+ type: PROXY_WINDOW_FETCH_MSG_TYPE,
75
+ req: data.req,
76
+ ...(capabilityNonce === "" ? {} : { capabilityNonce }),
77
+ }, controllerOrigin, [port]);
62
78
  }
63
79
  catch (error) {
64
80
  const message = error instanceof Error ? error.message : String(error);
@@ -117,6 +133,7 @@ export function registerProxyAppWindow(opts) {
117
133
  type: PROXY_WINDOW_WS_OPEN_MSG_TYPE,
118
134
  path,
119
135
  ...(wsOpts.protocols === undefined ? {} : { protocols: wsOpts.protocols }),
136
+ ...(capabilityNonce === "" ? {} : { capabilityNonce }),
120
137
  }, controllerOrigin, [channel.port2]);
121
138
  }
122
139
  catch (error) {
@@ -33,6 +33,7 @@ export type ConnectTunnelProxyControllerBrowserOptions = Readonly<{
33
33
  allowedOrigins: RegisterProxyControllerWindowOptions["allowedOrigins"];
34
34
  targetWindow?: RegisterProxyControllerWindowOptions["targetWindow"];
35
35
  expectedSource?: RegisterProxyControllerWindowOptions["expectedSource"];
36
+ capabilityNonce?: RegisterProxyControllerWindowOptions["capabilityNonce"];
36
37
  }>;
37
38
  export type ConnectTunnelProxyControllerBrowserHandle = Readonly<{
38
39
  client: Client;
@@ -45,6 +46,7 @@ export type ConnectArtifactProxyControllerBrowserOptions = Readonly<{
45
46
  allowedOrigins?: RegisterProxyControllerWindowOptions["allowedOrigins"];
46
47
  targetWindow?: RegisterProxyControllerWindowOptions["targetWindow"];
47
48
  expectedSource?: RegisterProxyControllerWindowOptions["expectedSource"];
49
+ capabilityNonce?: RegisterProxyControllerWindowOptions["capabilityNonce"];
48
50
  }>;
49
51
  export declare function connectTunnelProxyBrowser(grant: ChannelInitGrant, opts: ConnectTunnelProxyBrowserOptions): Promise<ConnectTunnelProxyBrowserHandle>;
50
52
  export declare function connectArtifactProxyBrowser(artifact: ConnectArtifact, opts?: ConnectArtifactProxyBrowserOptions): Promise<ConnectTunnelProxyBrowserHandle>;
@@ -80,6 +80,7 @@ function connectProxyControllerClient(client, opts) {
80
80
  allowedOrigins: opts.allowedOrigins,
81
81
  ...(opts.targetWindow === undefined ? {} : { targetWindow: opts.targetWindow }),
82
82
  ...(opts.expectedSource === undefined ? {} : { expectedSource: opts.expectedSource }),
83
+ ...(opts.capabilityNonce === undefined ? {} : { capabilityNonce: opts.capabilityNonce }),
83
84
  });
84
85
  }
85
86
  catch (error) {
@@ -123,5 +124,6 @@ export async function connectArtifactProxyControllerBrowser(artifact, opts = {})
123
124
  allowedOrigins: opts.allowedOrigins ?? scope.controllerBridge.allowedOrigins,
124
125
  ...(opts.targetWindow === undefined ? {} : { targetWindow: opts.targetWindow }),
125
126
  ...(opts.expectedSource === undefined ? {} : { expectedSource: opts.expectedSource }),
127
+ ...(opts.capabilityNonce === undefined ? {} : { capabilityNonce: opts.capabilityNonce }),
126
128
  });
127
129
  }
@@ -4,6 +4,7 @@ export type RegisterProxyControllerWindowOptions = Readonly<{
4
4
  allowedOrigins: readonly string[];
5
5
  targetWindow?: Window;
6
6
  expectedSource?: Window | null;
7
+ capabilityNonce?: string;
7
8
  }>;
8
9
  export type ProxyControllerWindowHandle = Readonly<{
9
10
  dispose: () => void;
@@ -16,6 +16,29 @@ function cloneChunk(chunk) {
16
16
  out.set(chunk);
17
17
  return out.buffer;
18
18
  }
19
+ function normalizeCapabilityNonce(value) {
20
+ if (value == null)
21
+ return "";
22
+ const s = String(value);
23
+ if (s === "")
24
+ return "";
25
+ if (s.trim() !== s || /[\s\u0000-\u001f\u007f]/.test(s)) {
26
+ throw new Error("capabilityNonce must not contain whitespace or control characters");
27
+ }
28
+ return s;
29
+ }
30
+ function requireBridgeCapability(expectedSource, capabilityNonce) {
31
+ if (expectedSource == null && capabilityNonce === "") {
32
+ throw new Error("expectedSource or capabilityNonce is required");
33
+ }
34
+ }
35
+ function hasExpectedCapability(data, capabilityNonce) {
36
+ if (capabilityNonce === "")
37
+ return true;
38
+ if (data == null || typeof data !== "object")
39
+ return false;
40
+ return data.capabilityNonce === capabilityNonce;
41
+ }
19
42
  function bridgeWebSocket(runtime, msg, port) {
20
43
  const ac = new AbortController();
21
44
  let streamClosed = false;
@@ -88,6 +111,8 @@ export function registerProxyControllerWindow(opts) {
88
111
  if (allowedOrigins.length === 0) {
89
112
  throw new Error("allowedOrigins is required");
90
113
  }
114
+ const capabilityNonce = normalizeCapabilityNonce(opts.capabilityNonce);
115
+ requireBridgeCapability(opts.expectedSource, capabilityNonce);
91
116
  const targetWindow = opts.targetWindow ?? globalThis.window;
92
117
  if (targetWindow == null) {
93
118
  throw new Error("targetWindow is not available");
@@ -100,6 +125,8 @@ export function registerProxyControllerWindow(opts) {
100
125
  const data = ev.data;
101
126
  if (data == null || typeof data !== "object")
102
127
  return;
128
+ if (!hasExpectedCapability(data, capabilityNonce))
129
+ return;
103
130
  const type = typeof data.type === "string" ? data.type : "";
104
131
  const port = ev.ports?.[0];
105
132
  if (!port)
@@ -1,6 +1,6 @@
1
1
  import type { Client } from "../client.js";
2
2
  import { type ProxyPresetInput, type ResolvedProxyPreset } from "./preset.js";
3
- import { type ProxyRuntime } from "./runtime.js";
3
+ import { type ProxyRuntime, type ProxyRuntimePathPolicy } from "./runtime.js";
4
4
  import { type ProxyServiceWorkerScriptOptions } from "./serviceWorker.js";
5
5
  export type ProxyIntegrationMonitorOptions = Readonly<{
6
6
  enabled?: boolean;
@@ -34,6 +34,9 @@ export type RegisterProxyIntegrationOptions = Readonly<{
34
34
  maxBodyBytes?: number;
35
35
  maxWsFrameBytes?: number;
36
36
  timeoutMs?: number;
37
+ pathPolicy?: ProxyRuntimePathPolicy;
38
+ externalOrigin?: string;
39
+ runtimeRegistrationToken?: string;
37
40
  }>;
38
41
  serviceWorker: ProxyIntegrationServiceWorkerOptions;
39
42
  plugins?: readonly ProxyIntegrationPlugin[];
@@ -181,6 +181,9 @@ function buildRuntimeOptions(preset, runtime) {
181
181
  maxBodyBytes: runtime?.maxBodyBytes ?? preset.limits.max_body_bytes,
182
182
  maxWsFrameBytes: runtime?.maxWsFrameBytes ?? preset.limits.max_ws_frame_bytes,
183
183
  timeoutMs: runtime?.timeoutMs ?? preset.limits.timeout_ms ?? 0,
184
+ ...(runtime?.pathPolicy === undefined ? {} : { pathPolicy: runtime.pathPolicy }),
185
+ ...(runtime?.externalOrigin === undefined ? {} : { externalOrigin: runtime.externalOrigin }),
186
+ ...(runtime?.runtimeRegistrationToken === undefined ? {} : { runtimeRegistrationToken: runtime.runtimeRegistrationToken }),
184
187
  };
185
188
  }
186
189
  function bindRuntimeGlobal(runtime, key) {
@@ -252,7 +255,10 @@ export async function registerProxyIntegration(input) {
252
255
  maxRepairAttempts,
253
256
  controllerTimeoutMs,
254
257
  });
255
- await ensureServiceWorkerRuntimeRegistered({ timeoutMs: controllerTimeoutMs });
258
+ await ensureServiceWorkerRuntimeRegistered({
259
+ timeoutMs: controllerTimeoutMs,
260
+ ...(runtimeOpts.runtimeRegistrationToken === undefined ? {} : { runtimeRegistrationToken: runtimeOpts.runtimeRegistrationToken }),
261
+ });
256
262
  if (expectedScriptPathSuffix !== "") {
257
263
  const ok = await waitForControllerSuffix(expectedScriptPathSuffix, controllerTimeoutMs);
258
264
  if (!ok) {
@@ -28,6 +28,12 @@ export type ProxyRuntime = Readonly<{
28
28
  protocol: string;
29
29
  }>>;
30
30
  }>;
31
+ export type ProxyRuntimePathPolicy = Readonly<{
32
+ allowedPathPrefixes?: readonly string[];
33
+ deniedPathPrefixes?: readonly string[];
34
+ allowedWebSocketPathPrefixes?: readonly string[];
35
+ deniedWebSocketPathPrefixes?: readonly string[];
36
+ }>;
31
37
  export type ProxyRuntimeOptions = Readonly<{
32
38
  client: Client;
33
39
  maxJsonFrameBytes?: number;
@@ -39,9 +45,13 @@ export type ProxyRuntimeOptions = Readonly<{
39
45
  extraResponseHeaders?: readonly string[];
40
46
  extraWsHeaders?: readonly string[];
41
47
  cookieJar?: CookieJar;
48
+ pathPolicy?: ProxyRuntimePathPolicy;
49
+ externalOrigin?: string;
50
+ runtimeRegistrationToken?: string;
42
51
  }>;
43
52
  export type EnsureServiceWorkerRuntimeRegisteredOptions = Readonly<{
44
53
  timeoutMs?: number;
54
+ runtimeRegistrationToken?: string;
45
55
  }>;
46
56
  export declare function createProxyRuntime(opts: ProxyRuntimeOptions): ProxyRuntime;
47
57
  export declare function ensureServiceWorkerRuntimeRegistered(opts?: EnsureServiceWorkerRuntimeRegisteredOptions): Promise<void>;
@@ -5,6 +5,9 @@ import { readU32be, u32be } from "../utils/bin.js";
5
5
  import { CookieJar } from "./cookieJar.js";
6
6
  import { DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_CHUNK_BYTES, DEFAULT_MAX_WS_FRAME_BYTES, PROXY_KIND_HTTP1, PROXY_KIND_WS, PROXY_PROTOCOL_VERSION } from "./constants.js";
7
7
  import { filterRequestHeaders, filterResponseHeaders, filterWsOpenHeaders } from "./headerPolicy.js";
8
+ class ProxyRuntimePolicyError extends Error {
9
+ status = 403;
10
+ }
8
11
  function randomB64u(bytes) {
9
12
  const b = new Uint8Array(bytes);
10
13
  if (globalThis.crypto?.getRandomValues) {
@@ -28,10 +31,13 @@ function pathOnly(path) {
28
31
  throw new Error("path must not include scheme/host");
29
32
  return p;
30
33
  }
31
- function cookiePathFromRequestPath(path) {
34
+ function requestPathname(path) {
32
35
  const q = path.indexOf("?");
33
36
  return q >= 0 ? path.slice(0, q) : path;
34
37
  }
38
+ function cookiePathFromRequestPath(path) {
39
+ return requestPathname(path);
40
+ }
35
41
  function normalizeTimeoutMs(timeoutMs) {
36
42
  const v = Math.floor(timeoutMs ?? 0);
37
43
  if (v < 0)
@@ -59,6 +65,56 @@ function normalizeExternalOrigin(externalOriginRaw) {
59
65
  }
60
66
  return parsed.origin;
61
67
  }
68
+ function normalizeOptionalToken(name, input) {
69
+ if (input == null)
70
+ return undefined;
71
+ const s = String(input);
72
+ if (s === "")
73
+ return undefined;
74
+ if (s.trim() !== s || /[\s\u0000-\u001f\u007f]/.test(s)) {
75
+ throw new Error(`${name} must not contain whitespace or control characters`);
76
+ }
77
+ return s;
78
+ }
79
+ function normalizePathPolicyPrefixes(name, input) {
80
+ const out = [];
81
+ if (input == null || input.length === 0)
82
+ return out;
83
+ for (const raw of input) {
84
+ const s = pathOnly(String(raw ?? ""));
85
+ if (s.includes("?"))
86
+ throw new Error(`${name} must not include query`);
87
+ if (!out.includes(s))
88
+ out.push(s);
89
+ }
90
+ return out;
91
+ }
92
+ function normalizePathPolicy(policy) {
93
+ return {
94
+ allowedPathPrefixes: normalizePathPolicyPrefixes("pathPolicy.allowedPathPrefixes", policy?.allowedPathPrefixes),
95
+ deniedPathPrefixes: normalizePathPolicyPrefixes("pathPolicy.deniedPathPrefixes", policy?.deniedPathPrefixes),
96
+ allowedWebSocketPathPrefixes: normalizePathPolicyPrefixes("pathPolicy.allowedWebSocketPathPrefixes", policy?.allowedWebSocketPathPrefixes),
97
+ deniedWebSocketPathPrefixes: normalizePathPolicyPrefixes("pathPolicy.deniedWebSocketPathPrefixes", policy?.deniedWebSocketPathPrefixes),
98
+ };
99
+ }
100
+ function assertPathPolicyAllows(kind, path, policy) {
101
+ const pathname = requestPathname(path);
102
+ const denied = kind === "websocket" ? [...policy.deniedPathPrefixes, ...policy.deniedWebSocketPathPrefixes] : policy.deniedPathPrefixes;
103
+ for (const prefix of denied) {
104
+ if (pathname.startsWith(prefix))
105
+ throw new ProxyRuntimePolicyError(`${kind} path is denied by proxy runtime policy`);
106
+ }
107
+ const allowed = kind === "websocket" && policy.allowedWebSocketPathPrefixes.length > 0
108
+ ? policy.allowedWebSocketPathPrefixes
109
+ : policy.allowedPathPrefixes;
110
+ if (allowed.length === 0)
111
+ return;
112
+ for (const prefix of allowed) {
113
+ if (pathname.startsWith(prefix))
114
+ return;
115
+ }
116
+ throw new ProxyRuntimePolicyError(`${kind} path is not allowed by proxy runtime policy`);
117
+ }
62
118
  function normalizeMaxBytes(name, v, defaultValue) {
63
119
  if (v == null)
64
120
  return defaultValue;
@@ -117,8 +173,14 @@ export function createProxyRuntime(opts) {
117
173
  const extraRequestHeaders = opts.extraRequestHeaders ?? [];
118
174
  const extraResponseHeaders = opts.extraResponseHeaders ?? [];
119
175
  const extraWsHeaders = opts.extraWsHeaders ?? [];
176
+ const pathPolicy = normalizePathPolicy(opts.pathPolicy);
177
+ const externalOriginOverride = normalizeExternalOrigin(opts.externalOrigin);
178
+ const runtimeRegistrationToken = normalizeOptionalToken("runtimeRegistrationToken", opts.runtimeRegistrationToken);
120
179
  const registerRuntime = () => {
121
- void ensureServiceWorkerRuntimeRegistered({ timeoutMs: 2_000 }).catch(() => {
180
+ void ensureServiceWorkerRuntimeRegistered({
181
+ timeoutMs: 2_000,
182
+ ...(runtimeRegistrationToken === undefined ? {} : { runtimeRegistrationToken }),
183
+ }).catch(() => {
122
184
  // Best-effort: controllerchange will retry once the active Service Worker is ready.
123
185
  });
124
186
  };
@@ -150,8 +212,9 @@ export function createProxyRuntime(opts) {
150
212
  void (async () => {
151
213
  try {
152
214
  const path = pathOnly(req.path);
215
+ assertPathPolicyAllows("http", path, pathPolicy);
153
216
  const requestID = req.id.trim() !== "" ? req.id : randomB64u(18);
154
- const externalOrigin = normalizeExternalOrigin(req.external_origin);
217
+ const externalOrigin = externalOriginOverride ?? normalizeExternalOrigin(req.external_origin);
155
218
  stream = await client.openStream(PROXY_KIND_HTTP1, { signal: ac.signal });
156
219
  const reader = createByteReader(stream, { signal: ac.signal });
157
220
  const filteredReqHeaders = filterRequestHeaders(req.headers, { extraAllowed: extraRequestHeaders });
@@ -201,7 +264,8 @@ export function createProxyRuntime(opts) {
201
264
  }
202
265
  catch (e) {
203
266
  const msg = e instanceof Error ? e.message : String(e);
204
- port.postMessage({ type: "flowersec-proxy:response_error", status: 502, message: msg });
267
+ const status = e instanceof ProxyRuntimePolicyError ? e.status : 502;
268
+ port.postMessage({ type: "flowersec-proxy:response_error", status, message: msg });
205
269
  try {
206
270
  stream?.reset(new Error(msg));
207
271
  }
@@ -221,6 +285,7 @@ export function createProxyRuntime(opts) {
221
285
  };
222
286
  async function openWebSocketStream(pathRaw, wsOpts = {}) {
223
287
  const path = pathOnly(pathRaw);
288
+ assertPathPolicyAllows("websocket", path, pathPolicy);
224
289
  const openOpts = wsOpts.signal ? { signal: wsOpts.signal } : undefined;
225
290
  const stream = await client.openStream(PROXY_KIND_WS, openOpts);
226
291
  const reader = createByteReader(stream, openOpts);
@@ -261,6 +326,7 @@ export async function ensureServiceWorkerRuntimeRegistered(opts = {}) {
261
326
  if (!ctl || typeof ctl.postMessage !== "function")
262
327
  return;
263
328
  const timeoutMs = Math.max(0, Math.floor(opts.timeoutMs ?? 2_000));
329
+ const runtimeRegistrationToken = normalizeOptionalToken("runtimeRegistrationToken", opts.runtimeRegistrationToken);
264
330
  const ch = new MessageChannel();
265
331
  await new Promise((resolve, reject) => {
266
332
  let done = false;
@@ -306,7 +372,10 @@ export async function ensureServiceWorkerRuntimeRegistered(opts = {}) {
306
372
  }, timeoutMs);
307
373
  }
308
374
  try {
309
- ctl.postMessage({ type: "flowersec-proxy:register-runtime" }, [ch.port2]);
375
+ ctl.postMessage({
376
+ type: "flowersec-proxy:register-runtime",
377
+ ...(runtimeRegistrationToken === undefined ? {} : { token: runtimeRegistrationToken }),
378
+ }, [ch.port2]);
310
379
  }
311
380
  catch (error) {
312
381
  finish(error);
@@ -33,6 +33,8 @@ export type ProxyServiceWorkerScriptOptions = Readonly<{
33
33
  forwardFetchMessageTypes?: readonly string[];
34
34
  windowTarget?: "registered_runtime" | "request_client";
35
35
  windowClientMessageType?: string;
36
+ runtimeRegistrationToken?: string;
37
+ runtimeClientPathPrefix?: string;
36
38
  conflictHints?: Readonly<{
37
39
  keepScriptPathSuffixes?: readonly string[];
38
40
  }>;
@@ -61,6 +61,18 @@ function normalizeMessageTypeList(name, input) {
61
61
  }
62
62
  return Array.from(new Set(out));
63
63
  }
64
+ function normalizeOptionalToken(name, input) {
65
+ if (input == null)
66
+ return "";
67
+ const s = String(input);
68
+ if (s === "")
69
+ return "";
70
+ if (s.trim() !== s)
71
+ throw new Error(`${name} must not contain leading or trailing whitespace`);
72
+ if (/[\s\u0000-\u001f\u007f]/.test(s))
73
+ throw new Error(`${name} must not contain whitespace or control characters`);
74
+ return s;
75
+ }
64
76
  const defaultMaxInjectHTMLBytes = 2 * 1024 * 1024;
65
77
  function normalizeMaxBytes(name, v, defaultValue) {
66
78
  if (v == null)
@@ -108,6 +120,8 @@ export function createProxyServiceWorkerScript(opts = {}) {
108
120
  throw new Error("windowClientMessageType must not contain newline");
109
121
  return normalized;
110
122
  })();
123
+ const runtimeRegistrationToken = normalizeOptionalToken("runtimeRegistrationToken", opts.runtimeRegistrationToken);
124
+ const runtimeClientPathPrefix = normalizePathPrefix("runtimeClientPathPrefix", opts.runtimeClientPathPrefix);
111
125
  const injectHTML = opts.injectHTML ?? null;
112
126
  // Injection mode defaults to inline_module when injectHTML is provided.
113
127
  const injectMode = injectHTML?.mode ?? "inline_module";
@@ -165,6 +179,8 @@ const MAX_INJECT_HTML_BYTES = ${JSON.stringify(maxInjectHTMLBytes)};
165
179
  const FORWARD_FETCH_MESSAGE_TYPES = new Set(${JSON.stringify(forwardFetchMessageTypes)});
166
180
  const WINDOW_TARGET = ${JSON.stringify(windowTarget)};
167
181
  const WINDOW_CLIENT_MESSAGE_TYPE = ${JSON.stringify(windowClientMessageType)};
182
+ const RUNTIME_REGISTRATION_TOKEN = ${JSON.stringify(runtimeRegistrationToken)};
183
+ const RUNTIME_CLIENT_PATH_PREFIX = ${JSON.stringify(runtimeClientPathPrefix)};
168
184
  const CONFLICT_HINT_KEEP_SCRIPT_SUFFIXES = ${JSON.stringify(keepScriptPathSuffixes)};
169
185
 
170
186
  const INJECT_STRIP_HEADER_NAMES = new Set(["content-length", "etag", "last-modified", "content-md5"]);
@@ -186,13 +202,40 @@ self.addEventListener("message", (event) => {
186
202
  if (!data || typeof data !== "object") return;
187
203
  const msgType = typeof data.type === "string" ? data.type : "";
188
204
  if (msgType === "flowersec-proxy:register-runtime") {
189
- const ok = Boolean(event.source && typeof event.source.id === "string");
190
- if (ok) runtimeClientId = event.source.id;
191
- const port = event.ports && event.ports[0];
192
- if (port) {
193
- try { port.postMessage({ type: "flowersec-proxy:register-runtime-ack", ok }); } catch {}
194
- try { port.close(); } catch {}
195
- }
205
+ event.waitUntil((async () => {
206
+ const sourceId = event.source && typeof event.source.id === "string" ? event.source.id : "";
207
+ let ok = sourceId !== "";
208
+ if (ok && RUNTIME_REGISTRATION_TOKEN) {
209
+ ok = typeof data.token === "string" && data.token === RUNTIME_REGISTRATION_TOKEN;
210
+ }
211
+ if (ok && RUNTIME_CLIENT_PATH_PREFIX) {
212
+ const c = await self.clients.get(sourceId);
213
+ if (!c || typeof c.url !== "string") {
214
+ ok = false;
215
+ } else {
216
+ try {
217
+ const u = new URL(c.url);
218
+ ok = u.origin === self.location.origin && u.pathname.startsWith(RUNTIME_CLIENT_PATH_PREFIX);
219
+ } catch {
220
+ ok = false;
221
+ }
222
+ }
223
+ }
224
+ if (ok && runtimeClientId && runtimeClientId !== sourceId) {
225
+ const existing = await self.clients.get(runtimeClientId);
226
+ if (existing) {
227
+ ok = false;
228
+ } else {
229
+ runtimeClientId = null;
230
+ }
231
+ }
232
+ if (ok) runtimeClientId = sourceId;
233
+ const port = event.ports && event.ports[0];
234
+ if (port) {
235
+ try { port.postMessage({ type: "flowersec-proxy:register-runtime-ack", ok }); } catch {}
236
+ try { port.close(); } catch {}
237
+ }
238
+ })());
196
239
  return;
197
240
  }
198
241
  if (!FORWARD_FETCH_MESSAGE_TYPES.has(msgType)) return;
@@ -23,11 +23,13 @@ export type ProxyWindowFetchForwardMsg = Readonly<{
23
23
  export type ProxyWindowFetchMsg = Readonly<{
24
24
  type: typeof PROXY_WINDOW_FETCH_MSG_TYPE;
25
25
  req: ProxyWindowFetchRequest;
26
+ capabilityNonce?: string;
26
27
  }>;
27
28
  export type ProxyWindowWsOpenMsg = Readonly<{
28
29
  type: typeof PROXY_WINDOW_WS_OPEN_MSG_TYPE;
29
30
  path: string;
30
31
  protocols?: readonly string[];
32
+ capabilityNonce?: string;
31
33
  }>;
32
34
  export type ProxyWindowWsOpenAckMsg = Readonly<{
33
35
  type: typeof PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.19.7",
3
+ "version": "0.19.9",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {