@floegence/flowersec-core 0.6.0 → 0.8.0

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.
@@ -0,0 +1,8 @@
1
+ export declare const PROXY_PROTOCOL_VERSION: 1;
2
+ export declare const PROXY_KIND_HTTP1: "flowersec-proxy/http1";
3
+ export declare const PROXY_KIND_WS: "flowersec-proxy/ws";
4
+ export declare const DEFAULT_MAX_CHUNK_BYTES: number;
5
+ export declare const DEFAULT_MAX_BODY_BYTES: number;
6
+ export declare const DEFAULT_MAX_WS_FRAME_BYTES: number;
7
+ export declare const DEFAULT_DEFAULT_TIMEOUT_MS = 30000;
8
+ export declare const DEFAULT_MAX_TIMEOUT_MS: number;
@@ -0,0 +1,8 @@
1
+ export const PROXY_PROTOCOL_VERSION = 1;
2
+ export const PROXY_KIND_HTTP1 = "flowersec-proxy/http1";
3
+ export const PROXY_KIND_WS = "flowersec-proxy/ws";
4
+ export const DEFAULT_MAX_CHUNK_BYTES = 256 * 1024;
5
+ export const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
6
+ export const DEFAULT_MAX_WS_FRAME_BYTES = 1024 * 1024;
7
+ export const DEFAULT_DEFAULT_TIMEOUT_MS = 30_000;
8
+ export const DEFAULT_MAX_TIMEOUT_MS = 5 * 60_000;
@@ -0,0 +1,6 @@
1
+ export declare class CookieJar {
2
+ private readonly cookies;
3
+ setCookie(setCookieHeader: string): void;
4
+ updateFromSetCookieHeaders(headers: readonly string[]): void;
5
+ getCookieHeader(path: string): string;
6
+ }
@@ -0,0 +1,88 @@
1
+ function nowMs() {
2
+ return Date.now();
3
+ }
4
+ function parseCookieNameValue(s) {
5
+ const idx = s.indexOf("=");
6
+ if (idx <= 0)
7
+ return null;
8
+ const name = s.slice(0, idx).trim();
9
+ const value = s.slice(idx + 1).trim();
10
+ if (name === "")
11
+ return null;
12
+ return { name, value };
13
+ }
14
+ export class CookieJar {
15
+ cookies = new Map();
16
+ setCookie(setCookieHeader) {
17
+ const raw = setCookieHeader.trim();
18
+ if (raw === "")
19
+ return;
20
+ const parts = raw.split(";").map((p) => p.trim()).filter((p) => p !== "");
21
+ if (parts.length === 0)
22
+ return;
23
+ const nv = parseCookieNameValue(parts[0] ?? "");
24
+ if (nv == null)
25
+ return;
26
+ let path = "/";
27
+ let expiresAtMs;
28
+ for (let i = 1; i < parts.length; i++) {
29
+ const p = parts[i];
30
+ const lower = p.toLowerCase();
31
+ if (lower.startsWith("path=")) {
32
+ const v = p.slice("path=".length).trim();
33
+ if (v !== "")
34
+ path = v;
35
+ continue;
36
+ }
37
+ if (lower.startsWith("max-age=")) {
38
+ const v = p.slice("max-age=".length).trim();
39
+ const n = Number.parseInt(v, 10);
40
+ if (!Number.isFinite(n))
41
+ continue;
42
+ if (n <= 0) {
43
+ this.cookies.delete(nv.name);
44
+ return;
45
+ }
46
+ expiresAtMs = nowMs() + n * 1000;
47
+ continue;
48
+ }
49
+ if (lower.startsWith("expires=")) {
50
+ const v = p.slice("expires=".length).trim();
51
+ const t = Date.parse(v);
52
+ if (!Number.isFinite(t))
53
+ continue;
54
+ expiresAtMs = t;
55
+ continue;
56
+ }
57
+ }
58
+ // Expired via Expires.
59
+ if (expiresAtMs != null && expiresAtMs <= nowMs()) {
60
+ this.cookies.delete(nv.name);
61
+ return;
62
+ }
63
+ if (expiresAtMs == null) {
64
+ this.cookies.set(nv.name, { name: nv.name, value: nv.value, path });
65
+ }
66
+ else {
67
+ this.cookies.set(nv.name, { name: nv.name, value: nv.value, path, expiresAtMs });
68
+ }
69
+ }
70
+ updateFromSetCookieHeaders(headers) {
71
+ for (const h of headers)
72
+ this.setCookie(h);
73
+ }
74
+ getCookieHeader(path) {
75
+ const now = nowMs();
76
+ const pairs = [];
77
+ for (const c of this.cookies.values()) {
78
+ if (c.expiresAtMs != null && c.expiresAtMs <= now) {
79
+ this.cookies.delete(c.name);
80
+ continue;
81
+ }
82
+ if (c.path !== "/" && !path.startsWith(c.path))
83
+ continue;
84
+ pairs.push(`${c.name}=${c.value}`);
85
+ }
86
+ return pairs.join("; ");
87
+ }
88
+ }
@@ -0,0 +1 @@
1
+ export declare function disableUpstreamServiceWorkerRegister(): void;
@@ -0,0 +1,23 @@
1
+ // disableUpstreamServiceWorkerRegister blocks upstream apps from registering their own Service Worker.
2
+ //
3
+ // This is required for runtime-mode proxying where a proxy SW must control the app scope.
4
+ export function disableUpstreamServiceWorkerRegister() {
5
+ try {
6
+ const sw = globalThis.navigator?.serviceWorker;
7
+ if (!sw || typeof sw.register !== "function")
8
+ return;
9
+ const blocked = () => {
10
+ throw new Error("service worker register is disabled by flowersec-proxy runtime");
11
+ };
12
+ // Best-effort. Some environments may not allow overriding the property.
13
+ try {
14
+ sw.register = blocked;
15
+ }
16
+ catch {
17
+ // Ignore.
18
+ }
19
+ }
20
+ catch {
21
+ // Ignore.
22
+ }
23
+ }
@@ -0,0 +1,14 @@
1
+ import type { Header } from "./types.js";
2
+ export declare function normalizeHeaderName(name: string): string;
3
+ export declare function isValidHeaderName(name: string): boolean;
4
+ export declare function isSafeHeaderValue(value: string): boolean;
5
+ export type FilterHeadersOptions = Readonly<{
6
+ extraAllowed?: readonly string[];
7
+ }>;
8
+ export declare function filterRequestHeaders(input: readonly Header[], opts?: FilterHeadersOptions): Header[];
9
+ export type FilterResponseResult = Readonly<{
10
+ passthrough: Header[];
11
+ setCookie: string[];
12
+ }>;
13
+ export declare function filterResponseHeaders(input: readonly Header[], opts?: FilterHeadersOptions): FilterResponseResult;
14
+ export declare function filterWsOpenHeaders(input: readonly Header[], opts?: FilterHeadersOptions): Header[];
@@ -0,0 +1,124 @@
1
+ const DEFAULT_REQUEST_HEADER_ALLOWLIST = new Set([
2
+ "accept",
3
+ "accept-language",
4
+ "cache-control",
5
+ "content-type",
6
+ "if-match",
7
+ "if-modified-since",
8
+ "if-none-match",
9
+ "if-unmodified-since",
10
+ "pragma",
11
+ "range",
12
+ "x-requested-with"
13
+ ]);
14
+ const DEFAULT_RESPONSE_HEADER_ALLOWLIST = new Set([
15
+ "cache-control",
16
+ "content-disposition",
17
+ "content-encoding",
18
+ "content-language",
19
+ "content-type",
20
+ "etag",
21
+ "expires",
22
+ "last-modified",
23
+ "location",
24
+ "pragma",
25
+ "vary",
26
+ "www-authenticate",
27
+ "set-cookie"
28
+ ]);
29
+ const DEFAULT_WS_HEADER_ALLOWLIST = new Set(["sec-websocket-protocol", "cookie"]);
30
+ export function normalizeHeaderName(name) {
31
+ return name.trim().toLowerCase();
32
+ }
33
+ export function isValidHeaderName(name) {
34
+ // Minimal RFC 7230 token validation; keep strict to avoid smuggling bugs.
35
+ for (let i = 0; i < name.length; i++) {
36
+ const c = name.charCodeAt(i);
37
+ const isAlpha = c >= 0x61 && c <= 0x7a;
38
+ const isDigit = c >= 0x30 && c <= 0x39;
39
+ if (isAlpha || isDigit)
40
+ continue;
41
+ // ! # $ % & ' * + - . ^ _ ` | ~
42
+ if (c === 0x21 || c === 0x23 || c === 0x24 || c === 0x25 || c === 0x26 || c === 0x27)
43
+ continue;
44
+ if (c === 0x2a || c === 0x2b || c === 0x2d || c === 0x2e)
45
+ continue;
46
+ if (c === 0x5e || c === 0x5f || c === 0x60 || c === 0x7c || c === 0x7e)
47
+ continue;
48
+ return false;
49
+ }
50
+ return name.length > 0;
51
+ }
52
+ export function isSafeHeaderValue(value) {
53
+ return !value.includes("\r") && !value.includes("\n");
54
+ }
55
+ function buildExtraAllowedSet(extraAllowed) {
56
+ if (extraAllowed == null || extraAllowed.length === 0)
57
+ return null;
58
+ const s = new Set();
59
+ for (const n of extraAllowed) {
60
+ const nn = normalizeHeaderName(n);
61
+ if (nn !== "" && isValidHeaderName(nn))
62
+ s.add(nn);
63
+ }
64
+ return s;
65
+ }
66
+ function isAllowed(allow, extra, name) {
67
+ return allow.has(name) || (extra != null && extra.has(name));
68
+ }
69
+ export function filterRequestHeaders(input, opts = {}) {
70
+ const extra = buildExtraAllowedSet(opts.extraAllowed);
71
+ const out = [];
72
+ for (const h of input) {
73
+ const name = normalizeHeaderName(h.name);
74
+ if (name === "" || !isValidHeaderName(name))
75
+ continue;
76
+ if (!isSafeHeaderValue(h.value))
77
+ continue;
78
+ // Never forward these from the client runtime.
79
+ if (name === "host" || name === "authorization")
80
+ continue;
81
+ // Cookie is injected by the runtime CookieJar (not copied from browser cookie store).
82
+ if (name === "cookie")
83
+ continue;
84
+ if (!isAllowed(DEFAULT_REQUEST_HEADER_ALLOWLIST, extra, name))
85
+ continue;
86
+ out.push({ name, value: h.value });
87
+ }
88
+ return out;
89
+ }
90
+ export function filterResponseHeaders(input, opts = {}) {
91
+ const extra = buildExtraAllowedSet(opts.extraAllowed);
92
+ const passthrough = [];
93
+ const setCookie = [];
94
+ for (const h of input) {
95
+ const name = normalizeHeaderName(h.name);
96
+ if (name === "" || !isValidHeaderName(name))
97
+ continue;
98
+ if (!isSafeHeaderValue(h.value))
99
+ continue;
100
+ if (!isAllowed(DEFAULT_RESPONSE_HEADER_ALLOWLIST, extra, name))
101
+ continue;
102
+ if (name === "set-cookie") {
103
+ setCookie.push(h.value);
104
+ continue;
105
+ }
106
+ passthrough.push({ name, value: h.value });
107
+ }
108
+ return { passthrough, setCookie };
109
+ }
110
+ export function filterWsOpenHeaders(input, opts = {}) {
111
+ const extra = buildExtraAllowedSet(opts.extraAllowed);
112
+ const out = [];
113
+ for (const h of input) {
114
+ const name = normalizeHeaderName(h.name);
115
+ if (name === "" || !isValidHeaderName(name))
116
+ continue;
117
+ if (!isSafeHeaderValue(h.value))
118
+ continue;
119
+ if (!isAllowed(DEFAULT_WS_HEADER_ALLOWLIST, extra, name))
120
+ continue;
121
+ out.push({ name, value: h.value });
122
+ }
123
+ return out;
124
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./constants.js";
2
+ export * from "./types.js";
3
+ export * from "./cookieJar.js";
4
+ export * from "./runtime.js";
5
+ export * from "./serviceWorker.js";
6
+ export { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
7
+ export * from "./wsPatch.js";
8
+ export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -0,0 +1,8 @@
1
+ export * from "./constants.js";
2
+ export * from "./types.js";
3
+ export * from "./cookieJar.js";
4
+ export * from "./runtime.js";
5
+ export * from "./serviceWorker.js";
6
+ export { registerServiceWorkerAndEnsureControl } from "./registerServiceWorker.js";
7
+ export * from "./wsPatch.js";
8
+ export * from "./disableUpstreamServiceWorkerRegister.js";
@@ -0,0 +1,17 @@
1
+ export type RegisterServiceWorkerOptions = Readonly<{
2
+ scriptUrl: string;
3
+ scope?: string;
4
+ repairQueryKey?: string;
5
+ maxRepairAttempts?: number;
6
+ controllerTimeoutMs?: number;
7
+ }>;
8
+ declare function parseRepairAttemptFromHref(href: string, queryKey: string): number;
9
+ declare function buildHrefWithRepairAttempt(href: string, queryKey: string, attempt: number): string;
10
+ declare function buildHrefWithoutRepairQueryParam(href: string, queryKey: string): string;
11
+ export declare function registerServiceWorkerAndEnsureControl(opts: RegisterServiceWorkerOptions): Promise<void>;
12
+ export declare const __testOnly: {
13
+ parseRepairAttemptFromHref: typeof parseRepairAttemptFromHref;
14
+ buildHrefWithRepairAttempt: typeof buildHrefWithRepairAttempt;
15
+ buildHrefWithoutRepairQueryParam: typeof buildHrefWithoutRepairQueryParam;
16
+ };
17
+ export {};
@@ -0,0 +1,116 @@
1
+ function parseRepairAttemptFromHref(href, queryKey) {
2
+ try {
3
+ const u = new URL(href);
4
+ const raw = String(u.searchParams.get(queryKey) ?? "").trim();
5
+ const n = raw ? Number(raw) : 0;
6
+ if (!Number.isFinite(n) || n < 0)
7
+ return 0;
8
+ return Math.min(9, Math.floor(n));
9
+ }
10
+ catch {
11
+ return 0;
12
+ }
13
+ }
14
+ function buildHrefWithRepairAttempt(href, queryKey, attempt) {
15
+ const u = new URL(href);
16
+ u.searchParams.set(queryKey, String(Math.max(0, Math.floor(attempt))));
17
+ return u.toString();
18
+ }
19
+ function buildHrefWithoutRepairQueryParam(href, queryKey) {
20
+ const u = new URL(href);
21
+ u.searchParams.delete(queryKey);
22
+ const search = u.searchParams.toString();
23
+ return u.pathname + (search ? `?${search}` : "") + u.hash;
24
+ }
25
+ function waitForController(timeoutMs) {
26
+ if (navigator.serviceWorker.controller)
27
+ return Promise.resolve(true);
28
+ const ms = Math.max(0, Math.floor(timeoutMs));
29
+ return new Promise((resolve) => {
30
+ let done = false;
31
+ let t = null;
32
+ function finish(ok) {
33
+ if (done)
34
+ return;
35
+ done = true;
36
+ if (t != null)
37
+ window.clearTimeout(t);
38
+ navigator.serviceWorker.removeEventListener("controllerchange", onChange);
39
+ resolve(ok);
40
+ }
41
+ function onChange() {
42
+ finish(true);
43
+ }
44
+ navigator.serviceWorker.addEventListener("controllerchange", onChange);
45
+ // Avoid race: controller can become available before/after the listener registration.
46
+ if (navigator.serviceWorker.controller) {
47
+ finish(true);
48
+ return;
49
+ }
50
+ if (ms > 0) {
51
+ t = window.setTimeout(() => finish(Boolean(navigator.serviceWorker.controller)), ms);
52
+ }
53
+ });
54
+ }
55
+ // registerServiceWorkerAndEnsureControl registers a Service Worker and ensures the current page load is controlled.
56
+ //
57
+ // In DevTools hard reload flows, the SW may be installed but not control the current page load.
58
+ // When this happens, we perform a limited "soft navigation" repair to recover control.
59
+ export async function registerServiceWorkerAndEnsureControl(opts) {
60
+ const scriptUrl = String(opts.scriptUrl ?? "").trim();
61
+ if (!scriptUrl)
62
+ throw new Error("scriptUrl is required");
63
+ if (globalThis.navigator?.serviceWorker == null) {
64
+ throw new Error("service worker is not available in this environment");
65
+ }
66
+ const scope = String(opts.scope ?? "/").trim() || "/";
67
+ const queryKey = String(opts.repairQueryKey ?? "_flowersec_sw_repair").trim() || "_flowersec_sw_repair";
68
+ const maxRepairAttempts = Math.max(0, Math.floor(opts.maxRepairAttempts ?? 2));
69
+ const controllerTimeoutMs = Math.max(0, Math.floor(opts.controllerTimeoutMs ?? 2_000));
70
+ await navigator.serviceWorker.register(scriptUrl, { scope });
71
+ await navigator.serviceWorker.ready;
72
+ const attempt = parseRepairAttemptFromHref(window.location.href, queryKey);
73
+ if (navigator.serviceWorker.controller) {
74
+ if (attempt > 0) {
75
+ try {
76
+ const next = buildHrefWithoutRepairQueryParam(window.location.href, queryKey);
77
+ history.replaceState(null, document.title, next);
78
+ }
79
+ catch {
80
+ // ignore
81
+ }
82
+ }
83
+ return;
84
+ }
85
+ const controlled = await waitForController(controllerTimeoutMs);
86
+ if (controlled) {
87
+ if (attempt > 0) {
88
+ try {
89
+ const next = buildHrefWithoutRepairQueryParam(window.location.href, queryKey);
90
+ history.replaceState(null, document.title, next);
91
+ }
92
+ catch {
93
+ // ignore
94
+ }
95
+ }
96
+ return;
97
+ }
98
+ if (attempt < maxRepairAttempts) {
99
+ try {
100
+ const next = buildHrefWithRepairAttempt(window.location.href, queryKey, attempt + 1);
101
+ window.location.replace(next);
102
+ }
103
+ catch {
104
+ window.location.reload();
105
+ }
106
+ // Navigation will interrupt JS; keep pending so callers don't proceed with dependent work.
107
+ await new Promise(() => { });
108
+ }
109
+ throw new Error("Service Worker is installed but not controlling this page");
110
+ }
111
+ // Test-only exports (not re-exported from the public proxy entrypoint).
112
+ export const __testOnly = {
113
+ parseRepairAttemptFromHref,
114
+ buildHrefWithRepairAttempt,
115
+ buildHrefWithoutRepairQueryParam,
116
+ };
@@ -0,0 +1,33 @@
1
+ import type { Client } from "../client.js";
2
+ import type { YamuxStream } from "../yamux/stream.js";
3
+ import { CookieJar } from "./cookieJar.js";
4
+ export type ProxyRuntimeLimits = Readonly<{
5
+ maxJsonFrameBytes: number;
6
+ maxChunkBytes: number;
7
+ maxBodyBytes: number;
8
+ maxWsFrameBytes: number;
9
+ }>;
10
+ export type ProxyRuntime = Readonly<{
11
+ cookieJar: CookieJar;
12
+ limits: ProxyRuntimeLimits;
13
+ dispose: () => void;
14
+ openWebSocketStream: (path: string, opts?: Readonly<{
15
+ protocols?: readonly string[];
16
+ signal?: AbortSignal;
17
+ }>) => Promise<Readonly<{
18
+ stream: YamuxStream;
19
+ protocol: string;
20
+ }>>;
21
+ }>;
22
+ export type ProxyRuntimeOptions = Readonly<{
23
+ client: Client;
24
+ maxJsonFrameBytes?: number;
25
+ maxChunkBytes?: number;
26
+ maxBodyBytes?: number;
27
+ maxWsFrameBytes?: number;
28
+ timeoutMs?: number;
29
+ extraRequestHeaders?: readonly string[];
30
+ extraResponseHeaders?: readonly string[];
31
+ extraWsHeaders?: readonly string[];
32
+ }>;
33
+ export declare function createProxyRuntime(opts: ProxyRuntimeOptions): ProxyRuntime;