@floegence/flowersec-core 0.19.1 → 0.19.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.
@@ -1,7 +1,7 @@
1
1
  import { profileToPresetManifest } from "./profiles.js";
2
2
  import { resolveProxyPreset } from "./preset.js";
3
3
  import { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
4
- import { createProxyRuntime } from "./runtime.js";
4
+ import { createProxyRuntime, ensureServiceWorkerRuntimeRegistered } from "./runtime.js";
5
5
  import { createProxyServiceWorkerScript } from "./serviceWorker.js";
6
6
  function dedupeStrings(values) {
7
7
  const out = [];
@@ -252,6 +252,7 @@ export async function registerProxyIntegration(input) {
252
252
  maxRepairAttempts,
253
253
  controllerTimeoutMs,
254
254
  });
255
+ await ensureServiceWorkerRuntimeRegistered({ timeoutMs: controllerTimeoutMs });
255
256
  if (expectedScriptPathSuffix !== "") {
256
257
  const ok = await waitForControllerSuffix(expectedScriptPathSuffix, controllerTimeoutMs);
257
258
  if (!ok) {
@@ -7,6 +7,7 @@ type ProxyFetchReq = Readonly<{
7
7
  method: string;
8
8
  path: string;
9
9
  headers: readonly Header[];
10
+ external_origin?: string;
10
11
  body?: ArrayBuffer;
11
12
  }>;
12
13
  export type ProxyRuntimeLimits = Readonly<{
@@ -39,5 +40,9 @@ export type ProxyRuntimeOptions = Readonly<{
39
40
  extraWsHeaders?: readonly string[];
40
41
  cookieJar?: CookieJar;
41
42
  }>;
43
+ export type EnsureServiceWorkerRuntimeRegisteredOptions = Readonly<{
44
+ timeoutMs?: number;
45
+ }>;
42
46
  export declare function createProxyRuntime(opts: ProxyRuntimeOptions): ProxyRuntime;
47
+ export declare function ensureServiceWorkerRuntimeRegistered(opts?: EnsureServiceWorkerRuntimeRegisteredOptions): Promise<void>;
43
48
  export {};
@@ -38,6 +38,27 @@ function normalizeTimeoutMs(timeoutMs) {
38
38
  throw new Error("timeout_ms must be >= 0");
39
39
  return v;
40
40
  }
41
+ function normalizeExternalOrigin(externalOriginRaw) {
42
+ if (typeof externalOriginRaw !== "string")
43
+ return undefined;
44
+ const externalOrigin = externalOriginRaw.trim();
45
+ if (externalOrigin === "")
46
+ return undefined;
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(externalOrigin);
50
+ }
51
+ catch {
52
+ throw new Error("external_origin must be an http(s) origin");
53
+ }
54
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.host === "") {
55
+ throw new Error("external_origin must be an http(s) origin");
56
+ }
57
+ if (parsed.username !== "" || parsed.password !== "" || (parsed.pathname !== "" && parsed.pathname !== "/") || parsed.search !== "" || parsed.hash !== "") {
58
+ throw new Error("external_origin must be an origin without credentials, path, query, or fragment");
59
+ }
60
+ return parsed.origin;
61
+ }
41
62
  function normalizeMaxBytes(name, v, defaultValue) {
42
63
  if (v == null)
43
64
  return defaultValue;
@@ -97,13 +118,9 @@ export function createProxyRuntime(opts) {
97
118
  const extraResponseHeaders = opts.extraResponseHeaders ?? [];
98
119
  const extraWsHeaders = opts.extraWsHeaders ?? [];
99
120
  const registerRuntime = () => {
100
- try {
101
- const ctl = globalThis.navigator?.serviceWorker?.controller;
102
- ctl?.postMessage({ type: "flowersec-proxy:register-runtime" });
103
- }
104
- catch {
121
+ void ensureServiceWorkerRuntimeRegistered({ timeoutMs: 2_000 }).catch(() => {
105
122
  // Best-effort: controllerchange will retry once the active Service Worker is ready.
106
- }
123
+ });
107
124
  };
108
125
  const onMessage = (ev) => {
109
126
  const data = ev.data;
@@ -134,6 +151,7 @@ export function createProxyRuntime(opts) {
134
151
  try {
135
152
  const path = pathOnly(req.path);
136
153
  const requestID = req.id.trim() !== "" ? req.id : randomB64u(18);
154
+ const externalOrigin = normalizeExternalOrigin(req.external_origin);
137
155
  stream = await client.openStream(PROXY_KIND_HTTP1, { signal: ac.signal });
138
156
  const reader = createByteReader(stream, { signal: ac.signal });
139
157
  const filteredReqHeaders = filterRequestHeaders(req.headers, { extraAllowed: extraRequestHeaders });
@@ -145,6 +163,7 @@ export function createProxyRuntime(opts) {
145
163
  method: req.method,
146
164
  path,
147
165
  headers: reqHeaders,
166
+ ...(externalOrigin === undefined ? {} : { external_origin: externalOrigin }),
148
167
  timeout_ms: timeoutMs
149
168
  });
150
169
  const body = req.body != null ? new Uint8Array(req.body) : new Uint8Array();
@@ -237,3 +256,60 @@ export function createProxyRuntime(opts) {
237
256
  }
238
257
  };
239
258
  }
259
+ export async function ensureServiceWorkerRuntimeRegistered(opts = {}) {
260
+ const ctl = globalThis.navigator?.serviceWorker?.controller;
261
+ if (!ctl || typeof ctl.postMessage !== "function")
262
+ return;
263
+ const timeoutMs = Math.max(0, Math.floor(opts.timeoutMs ?? 2_000));
264
+ const ch = new MessageChannel();
265
+ await new Promise((resolve, reject) => {
266
+ let done = false;
267
+ let timer = null;
268
+ const finish = (error) => {
269
+ if (done)
270
+ return;
271
+ done = true;
272
+ if (timer != null)
273
+ clearTimeout(timer);
274
+ ch.port1.onmessage = null;
275
+ ch.port1.onmessageerror = null;
276
+ try {
277
+ ch.port1.close();
278
+ }
279
+ catch {
280
+ // Best-effort.
281
+ }
282
+ if (error == null) {
283
+ resolve();
284
+ return;
285
+ }
286
+ reject(error instanceof Error ? error : new Error(String(error)));
287
+ };
288
+ ch.port1.onmessage = (ev) => {
289
+ const data = ev.data;
290
+ if (data == null || typeof data !== "object")
291
+ return;
292
+ if (data.type !== "flowersec-proxy:register-runtime-ack")
293
+ return;
294
+ if (data.ok !== true) {
295
+ finish(new Error("service worker runtime registration was rejected"));
296
+ return;
297
+ }
298
+ finish();
299
+ };
300
+ ch.port1.onmessageerror = () => {
301
+ finish(new Error("service worker runtime registration failed"));
302
+ };
303
+ if (timeoutMs > 0) {
304
+ timer = setTimeout(() => {
305
+ finish(new Error("service worker runtime registration timed out"));
306
+ }, timeoutMs);
307
+ }
308
+ try {
309
+ ctl.postMessage({ type: "flowersec-proxy:register-runtime" }, [ch.port2]);
310
+ }
311
+ catch (error) {
312
+ finish(error);
313
+ }
314
+ });
315
+ }
@@ -13,6 +13,28 @@ function normalizePathPrefix(name, v) {
13
13
  throw new Error(`${name} must not include scheme/host`);
14
14
  return s;
15
15
  }
16
+ function normalizeRootRelativeScriptURL(name, v) {
17
+ if (typeof v !== "string")
18
+ throw new Error(`${name} must be a string`);
19
+ const s = v.trim();
20
+ if (s === "")
21
+ throw new Error(`${name} must be non-empty`);
22
+ if (s !== v)
23
+ throw new Error(`${name} must not contain leading or trailing whitespace`);
24
+ if (!s.startsWith("/"))
25
+ throw new Error(`${name} must start with "/"`);
26
+ if (s.startsWith("//"))
27
+ throw new Error(`${name} must not start with "//"`);
28
+ if (s.includes("://"))
29
+ throw new Error(`${name} must not include scheme/host`);
30
+ if (s.includes("\\"))
31
+ throw new Error(`${name} must not contain backslash`);
32
+ if (/[\s\u0000-\u001f\u007f]/.test(s))
33
+ throw new Error(`${name} must not contain whitespace or control characters`);
34
+ if (/[<>"'`]/.test(s))
35
+ throw new Error(`${name} must not contain HTML attribute delimiters`);
36
+ return s;
37
+ }
16
38
  function normalizePathList(name, input) {
17
39
  const out = [];
18
40
  if (input == null || input.length === 0)
@@ -116,13 +138,9 @@ export function createProxyServiceWorkerScript(opts = {}) {
116
138
  }
117
139
  }
118
140
  else {
119
- injectScriptUrl =
120
- "scriptUrl" in injectHTML && typeof injectHTML.scriptUrl === "string"
121
- ? injectHTML.scriptUrl.trim()
122
- : "";
123
- if (injectScriptUrl === "") {
141
+ if (!("scriptUrl" in injectHTML))
124
142
  throw new Error("injectHTML.scriptUrl must be non-empty");
125
- }
143
+ injectScriptUrl = normalizeRootRelativeScriptURL("injectHTML.scriptUrl", injectHTML.scriptUrl);
126
144
  }
127
145
  }
128
146
  return `// Generated by @floegence/flowersec-core/proxy
@@ -168,7 +186,13 @@ self.addEventListener("message", (event) => {
168
186
  if (!data || typeof data !== "object") return;
169
187
  const msgType = typeof data.type === "string" ? data.type : "";
170
188
  if (msgType === "flowersec-proxy:register-runtime") {
171
- if (event.source && typeof event.source.id === "string") runtimeClientId = event.source.id;
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
+ }
172
196
  return;
173
197
  }
174
198
  if (!FORWARD_FETCH_MESSAGE_TYPES.has(msgType)) return;
@@ -309,16 +333,16 @@ function injectBootstrap(html) {
309
333
  } else if (INJECT_MODE === "external_module") {
310
334
  snippet =
311
335
  '<script type="module" src="' +
312
- INJECT_SCRIPT_URL +
336
+ escapeHTMLAttributeValue(INJECT_SCRIPT_URL) +
313
337
  '"' +
314
- (RUNTIME_GLOBAL ? ' data-flowersec-runtime-global="' + RUNTIME_GLOBAL + '"' : "") +
338
+ (RUNTIME_GLOBAL ? ' data-flowersec-runtime-global="' + escapeHTMLAttributeValue(RUNTIME_GLOBAL) + '"' : "") +
315
339
  "></script>";
316
340
  } else if (INJECT_MODE === "external_script") {
317
341
  snippet =
318
342
  '<script src="' +
319
- INJECT_SCRIPT_URL +
343
+ escapeHTMLAttributeValue(INJECT_SCRIPT_URL) +
320
344
  '"' +
321
- (RUNTIME_GLOBAL ? ' data-flowersec-runtime-global="' + RUNTIME_GLOBAL + '"' : "") +
345
+ (RUNTIME_GLOBAL ? ' data-flowersec-runtime-global="' + escapeHTMLAttributeValue(RUNTIME_GLOBAL) + '"' : "") +
322
346
  "></script>";
323
347
  }
324
348
 
@@ -335,6 +359,21 @@ function injectBootstrap(html) {
335
359
  return snippet + html;
336
360
  }
337
361
 
362
+ function escapeHTMLAttributeValue(value) {
363
+ return String(value).replace(/[&<>"'\`=]/g, (ch) => {
364
+ switch (ch) {
365
+ case "&": return "&amp;";
366
+ case "<": return "&lt;";
367
+ case ">": return "&gt;";
368
+ case '"': return "&quot;";
369
+ case "'": return "&#39;";
370
+ case "\`": return "&#96;";
371
+ case "=": return "&#61;";
372
+ default: return ch;
373
+ }
374
+ });
375
+ }
376
+
338
377
  self.addEventListener("fetch", (event) => {
339
378
  const url = new URL(event.request.url);
340
379
 
@@ -362,6 +401,7 @@ async function handleFetch(event) {
362
401
  const req = event.request;
363
402
  const url = new URL(req.url);
364
403
  const id = Math.random().toString(16).slice(2) + Date.now().toString(16);
404
+ const externalOrigin = url.origin === self.location.origin ? url.origin : "";
365
405
 
366
406
  let body;
367
407
  if (req.method === "GET" || req.method === "HEAD") {
@@ -483,7 +523,14 @@ async function handleFetch(event) {
483
523
 
484
524
  target.postMessage({
485
525
  type: WINDOW_CLIENT_MESSAGE_TYPE,
486
- req: { id, method: req.method, path, headers: headersToPairs(req.headers), body }
526
+ req: {
527
+ id,
528
+ method: req.method,
529
+ path,
530
+ headers: headersToPairs(req.headers),
531
+ ...(externalOrigin ? { external_origin: externalOrigin } : {}),
532
+ body
533
+ }
487
534
  }, [port2]);
488
535
 
489
536
  const meta = await metaPromise;
@@ -12,6 +12,7 @@ export type HttpRequestMetaV1 = Readonly<{
12
12
  method: string;
13
13
  path: string;
14
14
  headers: Header[];
15
+ external_origin?: string;
15
16
  timeout_ms?: number;
16
17
  }>;
17
18
  export type HttpResponseMetaV1 = Readonly<{
@@ -13,6 +13,7 @@ export type ProxyWindowFetchRequest = Readonly<{
13
13
  method: string;
14
14
  path: string;
15
15
  headers: readonly Header[];
16
+ external_origin?: string;
16
17
  body?: ArrayBuffer;
17
18
  }>;
18
19
  export type ProxyWindowFetchForwardMsg = Readonly<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.19.1",
3
+ "version": "0.19.3",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {