@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.
- package/dist/e2ee/handshake.js +9 -2
- package/dist/e2ee/record.js +7 -0
- package/dist/e2ee/secureChannel.d.ts +1 -0
- package/dist/e2ee/secureChannel.js +23 -3
- package/dist/proxy/appWindow.d.ts +1 -0
- package/dist/proxy/appWindow.js +18 -1
- package/dist/proxy/bootstrap.d.ts +2 -0
- package/dist/proxy/bootstrap.js +2 -0
- package/dist/proxy/controllerWindow.d.ts +1 -0
- package/dist/proxy/controllerWindow.js +27 -0
- package/dist/proxy/integration.d.ts +4 -1
- package/dist/proxy/integration.js +7 -1
- package/dist/proxy/runtime.d.ts +10 -0
- package/dist/proxy/runtime.js +74 -5
- package/dist/proxy/serviceWorker.d.ts +2 -0
- package/dist/proxy/serviceWorker.js +50 -7
- package/dist/proxy/windowBridgeProtocol.d.ts +2 -0
- package/package.json +1 -1
package/dist/e2ee/handshake.js
CHANGED
|
@@ -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) >
|
|
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 +
|
|
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,
|
package/dist/e2ee/record.js
CHANGED
|
@@ -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");
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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) {
|
package/dist/proxy/appWindow.js
CHANGED
|
@@ -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({
|
|
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>;
|
package/dist/proxy/bootstrap.js
CHANGED
|
@@ -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({
|
|
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) {
|
package/dist/proxy/runtime.d.ts
CHANGED
|
@@ -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>;
|
package/dist/proxy/runtime.js
CHANGED
|
@@ -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
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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;
|