sliccy 4.22.1 → 4.24.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.
- package/dist/node-server/bridge-security.d.ts +135 -0
- package/dist/node-server/bridge-security.js +245 -0
- package/dist/node-server/index.js +290 -48
- package/dist/node-server/launch-url.d.ts +7 -0
- package/dist/node-server/launch-url.js +12 -3
- package/dist/ui/assets/{account-store-ibgiPI6V.js → account-store-BXT0RMpF.js} +2 -2
- package/dist/ui/assets/{account-store-4ECV8DGT.js → account-store-CcqPXFk-.js} +2 -2
- package/dist/ui/assets/{adobe--TTRq2If.js → adobe-Dvkc5_oM.js} +1 -1
- package/dist/ui/assets/adobe-Dyvxqy9s.js +1 -0
- package/dist/ui/assets/{agent-message-to-chat-biPexDDJ.js → agent-message-to-chat-Cihra740.js} +1 -1
- package/dist/ui/assets/{apps-BOvmrPiX.js → apps-CPyXd9Mk.js} +1 -1
- package/dist/ui/assets/{azure-openai-Bf3TmJ0y.js → azure-openai-BEec8FHP.js} +1 -1
- package/dist/ui/assets/{azure-openai-DjY0l719.js → azure-openai-lMn0o-9C.js} +1 -1
- package/dist/ui/assets/{bsh-watchdog-BVbry18Y.js → bsh-watchdog-Dgr_OA1N.js} +1 -1
- package/dist/ui/assets/{cdp-Cbi5Katl.js → cdp-D4JkCTey.js} +3 -3
- package/dist/ui/assets/{connect-surface-DSA8Cz3z.js → connect-surface-BzhaxP0L.js} +1 -1
- package/dist/ui/assets/dip-CIhLisNq.js +1 -0
- package/dist/ui/assets/{dist-BElcUj_V.js → dist-ChTQy8at.js} +1 -1
- package/dist/ui/assets/{dist-DP3_V4hR.js → dist-DIjck1q7.js} +1 -1
- package/dist/ui/assets/{es-DcI8bAtA.js → es-B8GxLLfB.js} +1 -1
- package/dist/ui/assets/{fs-DKIJdRIl.js → fs-ATai1P5Q.js} +2 -2
- package/dist/ui/assets/{fs-BQEkHWJV.js → fs-xQUivn-8.js} +1 -1
- package/dist/ui/assets/{github-BQBIaABC.js → github-C6vLSHER.js} +2 -2
- package/dist/ui/assets/{github-0YLv6nLk.js → github-CUVBUETF.js} +1 -1
- package/dist/ui/assets/{github-copilot-DwyTNkwX.js → github-copilot-BqMS3_4S.js} +1 -1
- package/dist/ui/assets/{github-copilot-Co_dxCXe.js → github-copilot-ggchxbZd.js} +1 -1
- package/dist/ui/assets/{hear-DyUkFgOW.js → hear-Bm8tL0z2.js} +1 -1
- package/dist/ui/assets/{kernel-worker-DvmZjl24.js → kernel-worker-CqATTm08.js} +1026 -1026
- package/dist/ui/assets/{kokoro-engine-BnI4fL_p.js → kokoro-engine-BzgKX1A1.js} +1 -1
- package/dist/ui/assets/{lick-ws-bridge-B0_yyU80.js → lick-ws-bridge-DFQu2sM8.js} +1 -1
- package/dist/ui/assets/{local-llm-DnPuhfxf.js → local-llm-D09l_nRy.js} +1 -1
- package/dist/ui/assets/{main-cherry-Bap_NTtV.js → main-cherry-B1mI8ArD.js} +1 -1
- package/dist/ui/assets/{main-DRGnmneg.js → main-sWMjBmPA.js} +3 -3
- package/dist/ui/assets/{mount-ByUTNmzv.js → mount-D7RXkT90.js} +1 -1
- package/dist/ui/assets/{mount-CguScujU.js → mount-sEjqfa02.js} +2 -2
- package/dist/ui/assets/{new-session-CwnHjnd0.js → new-session-B0xwp5hO.js} +2 -2
- package/dist/ui/assets/{oauth-bootstrap-Py4nRtkM.js → oauth-bootstrap-Uz3oksBR.js} +2 -2
- package/dist/ui/assets/{openai-codex-B2qaIik0.js → openai-codex-BMr_sYj2.js} +1 -1
- package/dist/ui/assets/{openai-codex-0L44_lSb.js → openai-codex-d2uBt8CN.js} +1 -1
- package/dist/ui/assets/{panel-rpc-handlers-D7cbHQYp.js → panel-rpc-handlers-1U8M-tQU.js} +1 -1
- package/dist/ui/assets/{provider-BI7Y3RBy.js → provider-B_3MhsjL.js} +2 -2
- package/dist/ui/assets/{provider-riIj2TBx.js → provider-BbwD43pe.js} +1 -1
- package/dist/ui/assets/provider-store-access-Cop2VuvO.js +1 -0
- package/dist/ui/assets/provider-store-access-_8NADMDP.js +1 -0
- package/dist/ui/assets/{providers-fz0Sm6ju.js → providers-DRQ-n4v0.js} +1 -1
- package/dist/ui/assets/proxied-fetch-3ooUUAsF.js +1 -0
- package/dist/ui/assets/{quick-llm-B9FBP8Bs.js → quick-llm-CHWi-jSD.js} +1 -1
- package/dist/ui/assets/session-freezer-mzV5cASX.js +1 -0
- package/dist/ui/assets/setup-sudo-DSt9eI8G.js +1 -0
- package/dist/ui/assets/{speak-DVyJlZaU.js → speak-Cr05k3rj.js} +1 -1
- package/dist/ui/assets/{sprinkle-manager-O6TFBdwJ.js → sprinkle-manager-B7xN0WFf.js} +1 -1
- package/dist/ui/assets/{store-BsGfQ4o5.js → store-CfuBHukr.js} +1 -1
- package/dist/ui/assets/{sudo-C-n2ZT92.js → sudo-CnG9mh3w.js} +1 -1
- package/dist/ui/assets/{transformers-env-Dpnex-yX.js → transformers-env-BEM0HkoC.js} +1 -1
- package/dist/ui/assets/{tray-leave-runtime-MYPnSSR_.js → tray-leave-runtime-CD-xjbXc.js} +1 -1
- package/dist/ui/assets/{upgrade-detection-uSF5N6Ew.js → upgrade-detection-CwNs_M6E.js} +1 -1
- package/dist/ui/assets/{wc-attach--tUlEIYg.js → wc-attach-D7JTvNE3.js} +2 -2
- package/dist/ui/assets/{wc-detached-D6RhlJwc.js → wc-detached-DfgIMcuk.js} +1 -1
- package/dist/ui/assets/{wc-extension-DeqnwDO0.js → wc-extension-BNpR2nAA.js} +2 -2
- package/dist/ui/assets/{wc-live-DIowT4eF.js → wc-live-cbtCpYYl.js} +12 -12
- package/dist/ui/assets/{wc-nav-DI67gu77.js → wc-nav-BVFjm1zp.js} +2 -2
- package/dist/ui/assets/{wc-onboarding-BIG01YkV.js → wc-onboarding-C-b3gfT3.js} +2 -2
- package/dist/ui/assets/{wc-placeholder-Nry9IKS2.js → wc-placeholder-CLNwZTGs.js} +3 -3
- package/dist/ui/assets/{wc-settings-C8nodqL5.js → wc-settings-BRCPA5ys.js} +2 -2
- package/dist/ui/assets/{wc-shell-Ng-YAcjd.js → wc-shell-CeWID7O0.js} +3 -3
- package/dist/ui/assets/{wc-sprinkles-BWzmaQEm.js → wc-sprinkles-DYTck3a-.js} +2 -2
- package/dist/ui/assets/{wc-tray-CbkvHS3n.js → wc-tray-ByE1fpIQ.js} +3 -3
- package/dist/ui/assets/{xai-grok-uc4Un-5T.js → xai-grok-BcQs9t1L.js} +1 -1
- package/dist/ui/assets/{xai-grok-CZfi469b.js → xai-grok-Bx-LJDfy.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/dist/ui/packages/webapp/index.html +2 -2
- package/package.json +1 -1
- package/dist/ui/assets/adobe-CQr1UAcb.js +0 -1
- package/dist/ui/assets/dip-6KkNyCHS.js +0 -1
- package/dist/ui/assets/provider-store-access-BHYTqSuW.js +0 -1
- package/dist/ui/assets/provider-store-access-BP2Nfrp_.js +0 -1
- package/dist/ui/assets/proxied-fetch-Dg114MVx.js +0 -1
- package/dist/ui/assets/proxy-error-9UvXMpzG.js +0 -1
- package/dist/ui/assets/session-freezer-BAK6UVLW.js +0 -1
- package/dist/ui/assets/setup-sudo-BMcUrWtK.js +0 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge security primitives for the standalone thin /cdp bridge.
|
|
3
|
+
*
|
|
4
|
+
* The standalone CLI runs a thin Express server that proxies CDP from a
|
|
5
|
+
* sliccy.ai-hosted leader tab to the local Chrome over `/cdp`. Full CDP
|
|
6
|
+
* pass-through = full control of the user's Chrome, so the WebSocket
|
|
7
|
+
* upgrade is gated by two factors plus PNA:
|
|
8
|
+
* 1. Origin allowlist (`isAllowedBridgeOrigin`).
|
|
9
|
+
* 2. Per-process subprotocol token in `Sec-WebSocket-Protocol`
|
|
10
|
+
* (`SUBPROTOCOL_PREFIX` + token). Never appears in a query string,
|
|
11
|
+
* so it does not leak into Referer / logs.
|
|
12
|
+
* 3. PNA preflight (`buildPnaPreflightHeaders`) — Chrome blocks
|
|
13
|
+
* public→private WS upgrades without `Access-Control-Allow-Private-Network`.
|
|
14
|
+
*
|
|
15
|
+
* Cross-origin /api calls from the hosted leader also need CORS; see
|
|
16
|
+
* `buildCorsHeaders` for the per-request response set.
|
|
17
|
+
*
|
|
18
|
+
* Pure module (no node-only imports) so the WS gate is trivially unit
|
|
19
|
+
* testable from `tests/bridge-security.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Origin allowlist. Production + staging worker plus the dev-mode loopback
|
|
23
|
+
* origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
|
|
24
|
+
* origin here and in the extension allowlist together — they MUST stay in
|
|
25
|
+
* sync, otherwise extension and standalone disagree on what's a leader.
|
|
26
|
+
*/
|
|
27
|
+
export declare const BRIDGE_ALLOWED_ORIGINS: readonly string[];
|
|
28
|
+
/** Subprotocol prefix advertised by the leader; the per-process token is appended. */
|
|
29
|
+
export declare const BRIDGE_SUBPROTOCOL_PREFIX = "slicc.bridge.v1.";
|
|
30
|
+
/** Query-param name the launch URL uses to forward the subprotocol token to the leader. */
|
|
31
|
+
export declare const BRIDGE_TOKEN_QUERY_PARAM = "bridgeToken";
|
|
32
|
+
/** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
|
|
33
|
+
export declare const BRIDGE_WS_QUERY_PARAM = "bridge";
|
|
34
|
+
/**
|
|
35
|
+
* Request header carrying the per-process bridge token on cross-origin
|
|
36
|
+
* /api/* calls from a remote allowlisted leader (e.g. sliccy.ai). The
|
|
37
|
+
* webapp's `proxied-fetch.ts` attaches it whenever a local API base
|
|
38
|
+
* origin is set; the thin-bridge CORS middleware validates it. Header
|
|
39
|
+
* is included in `CORS_BASE_ALLOW_HEADERS` so browsers don't strip it
|
|
40
|
+
* on the preflight.
|
|
41
|
+
*/
|
|
42
|
+
export declare const BRIDGE_TOKEN_HEADER = "X-Bridge-Token";
|
|
43
|
+
/** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
|
|
44
|
+
export declare function isAllowedBridgeOrigin(origin: string | undefined | null): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* True iff `origin` is a loopback host (localhost / 127.0.0.1 / ::1).
|
|
47
|
+
* Loopback allowlisted origins (e.g. the locally-served OAuth callback
|
|
48
|
+
* page at `http://localhost:5710/auth/callback` posting to
|
|
49
|
+
* `/api/oauth-result`) are exempt from the bridge-token requirement —
|
|
50
|
+
* the token's threat model is "remote allowlisted origin (sliccy.ai)
|
|
51
|
+
* with a hostile script", not "local server talking to itself".
|
|
52
|
+
*/
|
|
53
|
+
export declare function isLoopbackBridgeOrigin(origin: string | undefined | null): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Constant-time compare for the bridge token. `presented` may be missing
|
|
56
|
+
* or shaped wrong (Express delivers headers as `string | string[] |
|
|
57
|
+
* undefined`). Returns `false` for any non-string, length mismatch, or
|
|
58
|
+
* empty expected — never throws.
|
|
59
|
+
*/
|
|
60
|
+
export declare function validateBridgeToken(presented: string | string[] | undefined, expected: string | null): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Mint a per-process bridge token. Embedded in the leader launch URL and
|
|
63
|
+
* required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
|
|
64
|
+
* — 122 bits of entropy is plenty for a session-scoped capability.
|
|
65
|
+
*/
|
|
66
|
+
export declare function mintBridgeToken(): string;
|
|
67
|
+
/**
|
|
68
|
+
* Parse the `Sec-WebSocket-Protocol` request header into a trimmed list. The
|
|
69
|
+
* header is a comma-separated list per RFC 6455; `ws` exposes it raw.
|
|
70
|
+
*/
|
|
71
|
+
export declare function parseSubprotocolHeader(header: string | string[] | undefined): string[];
|
|
72
|
+
/**
|
|
73
|
+
* Pick the bridge subprotocol matching `expectedToken`, or null if absent.
|
|
74
|
+
* The matching protocol is what we MUST echo back in the upgrade response
|
|
75
|
+
* (RFC 6455 §1.9) — otherwise the browser closes the socket.
|
|
76
|
+
*/
|
|
77
|
+
export declare function selectBridgeSubprotocol(protocols: readonly string[], expectedToken: string): string | null;
|
|
78
|
+
export interface BridgeUpgradeGateResult {
|
|
79
|
+
ok: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* The subprotocol to echo back in the 101 response when `ok === true`.
|
|
82
|
+
* Always null when `ok === false`.
|
|
83
|
+
*/
|
|
84
|
+
acceptedSubprotocol: string | null;
|
|
85
|
+
/**
|
|
86
|
+
* Reason exposed in logs for rejection. Intentionally coarse — does not
|
|
87
|
+
* tell the caller WHICH check failed, mirroring `validateBridgePin` in
|
|
88
|
+
* the chrome-extension bridge SW.
|
|
89
|
+
*/
|
|
90
|
+
reason?: 'origin-not-allowed' | 'subprotocol-missing-or-mismatched';
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Combined origin + subprotocol gate for a `/cdp` upgrade request.
|
|
94
|
+
*
|
|
95
|
+
* Returns `{ ok: true, acceptedSubprotocol }` only when BOTH the origin is
|
|
96
|
+
* in the allowlist AND a matching `slicc.bridge.v1.<expectedToken>`
|
|
97
|
+
* subprotocol was offered. Closes-the-socket-before-emit semantics live at
|
|
98
|
+
* the call site in `index.ts`.
|
|
99
|
+
*/
|
|
100
|
+
export declare function validateBridgeUpgrade(input: {
|
|
101
|
+
origin: string | undefined | null;
|
|
102
|
+
subprotocolHeader: string | string[] | undefined;
|
|
103
|
+
expectedToken: string;
|
|
104
|
+
}): BridgeUpgradeGateResult;
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the `Access-Control-Allow-Headers` value for a request. Starts
|
|
107
|
+
* from `CORS_BASE_ALLOW_HEADERS` (the static set covering the documented
|
|
108
|
+
* /api endpoints + the `/api/fetch-proxy` transport headers) and unions
|
|
109
|
+
* in any header names from the request's `Access-Control-Request-Headers`
|
|
110
|
+
* that aren't already listed. This is the reflect-headers pattern: the
|
|
111
|
+
* agent's `bash curl -H X-Custom: …` can route through `/api/fetch-proxy`
|
|
112
|
+
* cross-origin without us having to enumerate every possible upstream
|
|
113
|
+
* header in advance. Comparison is case-insensitive; the static set's
|
|
114
|
+
* canonical casing wins on duplicates.
|
|
115
|
+
*/
|
|
116
|
+
export declare function resolveCorsAllowHeaders(requestHeadersHeader: string | undefined | null): string;
|
|
117
|
+
/**
|
|
118
|
+
* CORS headers for an allowlisted `Origin`. Returns `null` when the origin
|
|
119
|
+
* is not in the allowlist (caller should NOT set CORS headers).
|
|
120
|
+
*
|
|
121
|
+
* `Access-Control-Allow-Credentials: true` is included so the hosted leader
|
|
122
|
+
* can carry cookies to /api/* (e.g. auth) when that's added later. Today
|
|
123
|
+
* the bridge token is the auth factor, not cookies.
|
|
124
|
+
*
|
|
125
|
+
* `requestHeadersHeader` should be the request's `Access-Control-Request-Headers`
|
|
126
|
+
* value (preflight only); on a non-preflight request pass `null` and the
|
|
127
|
+
* caller can omit it.
|
|
128
|
+
*/
|
|
129
|
+
export declare function buildCorsHeaders(origin: string | undefined | null, requestHeadersHeader?: string | string[] | null): Record<string, string> | null;
|
|
130
|
+
/**
|
|
131
|
+
* PNA preflight extras. Added on OPTIONS responses for allowlisted origins
|
|
132
|
+
* when the request carries `Access-Control-Request-Private-Network: true`
|
|
133
|
+
* — Chrome blocks public→private fetch / WS otherwise.
|
|
134
|
+
*/
|
|
135
|
+
export declare function buildPnaPreflightHeaders(): Record<string, string>;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge security primitives for the standalone thin /cdp bridge.
|
|
3
|
+
*
|
|
4
|
+
* The standalone CLI runs a thin Express server that proxies CDP from a
|
|
5
|
+
* sliccy.ai-hosted leader tab to the local Chrome over `/cdp`. Full CDP
|
|
6
|
+
* pass-through = full control of the user's Chrome, so the WebSocket
|
|
7
|
+
* upgrade is gated by two factors plus PNA:
|
|
8
|
+
* 1. Origin allowlist (`isAllowedBridgeOrigin`).
|
|
9
|
+
* 2. Per-process subprotocol token in `Sec-WebSocket-Protocol`
|
|
10
|
+
* (`SUBPROTOCOL_PREFIX` + token). Never appears in a query string,
|
|
11
|
+
* so it does not leak into Referer / logs.
|
|
12
|
+
* 3. PNA preflight (`buildPnaPreflightHeaders`) — Chrome blocks
|
|
13
|
+
* public→private WS upgrades without `Access-Control-Allow-Private-Network`.
|
|
14
|
+
*
|
|
15
|
+
* Cross-origin /api calls from the hosted leader also need CORS; see
|
|
16
|
+
* `buildCorsHeaders` for the per-request response set.
|
|
17
|
+
*
|
|
18
|
+
* Pure module (no node-only imports) so the WS gate is trivially unit
|
|
19
|
+
* testable from `tests/bridge-security.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
22
|
+
/**
|
|
23
|
+
* Origin allowlist. Production + staging worker plus the dev-mode loopback
|
|
24
|
+
* origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
|
|
25
|
+
* origin here and in the extension allowlist together — they MUST stay in
|
|
26
|
+
* sync, otherwise extension and standalone disagree on what's a leader.
|
|
27
|
+
*/
|
|
28
|
+
export const BRIDGE_ALLOWED_ORIGINS = Object.freeze([
|
|
29
|
+
'https://www.sliccy.ai',
|
|
30
|
+
'https://slicc-tray-hub-staging.minivelos.workers.dev',
|
|
31
|
+
'http://localhost:5710',
|
|
32
|
+
'http://127.0.0.1:5710',
|
|
33
|
+
]);
|
|
34
|
+
/** Subprotocol prefix advertised by the leader; the per-process token is appended. */
|
|
35
|
+
export const BRIDGE_SUBPROTOCOL_PREFIX = 'slicc.bridge.v1.';
|
|
36
|
+
/** Query-param name the launch URL uses to forward the subprotocol token to the leader. */
|
|
37
|
+
export const BRIDGE_TOKEN_QUERY_PARAM = 'bridgeToken';
|
|
38
|
+
/** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
|
|
39
|
+
export const BRIDGE_WS_QUERY_PARAM = 'bridge';
|
|
40
|
+
/**
|
|
41
|
+
* Request header carrying the per-process bridge token on cross-origin
|
|
42
|
+
* /api/* calls from a remote allowlisted leader (e.g. sliccy.ai). The
|
|
43
|
+
* webapp's `proxied-fetch.ts` attaches it whenever a local API base
|
|
44
|
+
* origin is set; the thin-bridge CORS middleware validates it. Header
|
|
45
|
+
* is included in `CORS_BASE_ALLOW_HEADERS` so browsers don't strip it
|
|
46
|
+
* on the preflight.
|
|
47
|
+
*/
|
|
48
|
+
export const BRIDGE_TOKEN_HEADER = 'X-Bridge-Token';
|
|
49
|
+
/**
|
|
50
|
+
* Headers we allow on cross-origin /api requests from the hosted leader.
|
|
51
|
+
* Includes the `/api/fetch-proxy` transport headers (`X-Target-URL`, the
|
|
52
|
+
* forbidden-header `X-Proxy-*` bridges, `X-Slicc-Raw-Body`) so the
|
|
53
|
+
* webapp's `createProxiedFetch` can route through the local node-server
|
|
54
|
+
* cross-origin in thin-bridge mode. Custom upstream headers (any header
|
|
55
|
+
* the agent's `curl -H …` would pass through) are reflected via
|
|
56
|
+
* `Access-Control-Request-Headers` in `buildCorsHeaders` below.
|
|
57
|
+
*/
|
|
58
|
+
const CORS_BASE_ALLOW_HEADERS = [
|
|
59
|
+
'Content-Type',
|
|
60
|
+
'X-Slicc-Raw-Body',
|
|
61
|
+
'X-Session-Id',
|
|
62
|
+
'X-Bridge-Token',
|
|
63
|
+
'Authorization',
|
|
64
|
+
'X-Target-URL',
|
|
65
|
+
'X-Proxy-Cookie',
|
|
66
|
+
'X-Proxy-Origin',
|
|
67
|
+
'X-Proxy-Referer',
|
|
68
|
+
];
|
|
69
|
+
/**
|
|
70
|
+
* Response headers the browser is allowed to read after a cross-origin
|
|
71
|
+
* /api call — must include the proxy's infrastructure-error marker
|
|
72
|
+
* (`isProxyError` reads `X-Proxy-Error`) and the forbidden-response
|
|
73
|
+
* bridge (`decodeForbiddenResponseHeaders` reads `X-Proxy-Set-Cookie`).
|
|
74
|
+
*/
|
|
75
|
+
const CORS_EXPOSE_HEADERS = 'Link, X-Proxy-Error, X-Proxy-Set-Cookie';
|
|
76
|
+
/** Methods exposed to the hosted leader. */
|
|
77
|
+
const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
|
|
78
|
+
/** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
|
|
79
|
+
export function isAllowedBridgeOrigin(origin) {
|
|
80
|
+
if (!origin)
|
|
81
|
+
return false;
|
|
82
|
+
return BRIDGE_ALLOWED_ORIGINS.includes(origin);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* True iff `origin` is a loopback host (localhost / 127.0.0.1 / ::1).
|
|
86
|
+
* Loopback allowlisted origins (e.g. the locally-served OAuth callback
|
|
87
|
+
* page at `http://localhost:5710/auth/callback` posting to
|
|
88
|
+
* `/api/oauth-result`) are exempt from the bridge-token requirement —
|
|
89
|
+
* the token's threat model is "remote allowlisted origin (sliccy.ai)
|
|
90
|
+
* with a hostile script", not "local server talking to itself".
|
|
91
|
+
*/
|
|
92
|
+
export function isLoopbackBridgeOrigin(origin) {
|
|
93
|
+
if (!origin)
|
|
94
|
+
return false;
|
|
95
|
+
try {
|
|
96
|
+
const url = new URL(origin);
|
|
97
|
+
// Node's WHATWG URL parser keeps the brackets on IPv6 hostnames
|
|
98
|
+
// (`http://[::1]:5710` → `[::1]`); accept both bracketed and bare.
|
|
99
|
+
return (url.hostname === 'localhost' ||
|
|
100
|
+
url.hostname === '127.0.0.1' ||
|
|
101
|
+
url.hostname === '::1' ||
|
|
102
|
+
url.hostname === '[::1]');
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Constant-time compare for the bridge token. `presented` may be missing
|
|
110
|
+
* or shaped wrong (Express delivers headers as `string | string[] |
|
|
111
|
+
* undefined`). Returns `false` for any non-string, length mismatch, or
|
|
112
|
+
* empty expected — never throws.
|
|
113
|
+
*/
|
|
114
|
+
export function validateBridgeToken(presented, expected) {
|
|
115
|
+
if (!expected)
|
|
116
|
+
return false;
|
|
117
|
+
const value = Array.isArray(presented) ? presented[0] : presented;
|
|
118
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
119
|
+
return false;
|
|
120
|
+
const a = Buffer.from(value);
|
|
121
|
+
const b = Buffer.from(expected);
|
|
122
|
+
if (a.length !== b.length)
|
|
123
|
+
return false;
|
|
124
|
+
return timingSafeEqual(a, b);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Mint a per-process bridge token. Embedded in the leader launch URL and
|
|
128
|
+
* required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
|
|
129
|
+
* — 122 bits of entropy is plenty for a session-scoped capability.
|
|
130
|
+
*/
|
|
131
|
+
export function mintBridgeToken() {
|
|
132
|
+
return randomUUID();
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Parse the `Sec-WebSocket-Protocol` request header into a trimmed list. The
|
|
136
|
+
* header is a comma-separated list per RFC 6455; `ws` exposes it raw.
|
|
137
|
+
*/
|
|
138
|
+
export function parseSubprotocolHeader(header) {
|
|
139
|
+
if (!header)
|
|
140
|
+
return [];
|
|
141
|
+
const flat = Array.isArray(header) ? header.join(',') : header;
|
|
142
|
+
return flat
|
|
143
|
+
.split(',')
|
|
144
|
+
.map((s) => s.trim())
|
|
145
|
+
.filter(Boolean);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Pick the bridge subprotocol matching `expectedToken`, or null if absent.
|
|
149
|
+
* The matching protocol is what we MUST echo back in the upgrade response
|
|
150
|
+
* (RFC 6455 §1.9) — otherwise the browser closes the socket.
|
|
151
|
+
*/
|
|
152
|
+
export function selectBridgeSubprotocol(protocols, expectedToken) {
|
|
153
|
+
if (!expectedToken)
|
|
154
|
+
return null;
|
|
155
|
+
const expected = `${BRIDGE_SUBPROTOCOL_PREFIX}${expectedToken}`;
|
|
156
|
+
return protocols.includes(expected) ? expected : null;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Combined origin + subprotocol gate for a `/cdp` upgrade request.
|
|
160
|
+
*
|
|
161
|
+
* Returns `{ ok: true, acceptedSubprotocol }` only when BOTH the origin is
|
|
162
|
+
* in the allowlist AND a matching `slicc.bridge.v1.<expectedToken>`
|
|
163
|
+
* subprotocol was offered. Closes-the-socket-before-emit semantics live at
|
|
164
|
+
* the call site in `index.ts`.
|
|
165
|
+
*/
|
|
166
|
+
export function validateBridgeUpgrade(input) {
|
|
167
|
+
if (!isAllowedBridgeOrigin(input.origin)) {
|
|
168
|
+
return { ok: false, acceptedSubprotocol: null, reason: 'origin-not-allowed' };
|
|
169
|
+
}
|
|
170
|
+
const protocols = parseSubprotocolHeader(input.subprotocolHeader);
|
|
171
|
+
const accepted = selectBridgeSubprotocol(protocols, input.expectedToken);
|
|
172
|
+
if (!accepted) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
acceptedSubprotocol: null,
|
|
176
|
+
reason: 'subprotocol-missing-or-mismatched',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, acceptedSubprotocol: accepted };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Resolve the `Access-Control-Allow-Headers` value for a request. Starts
|
|
183
|
+
* from `CORS_BASE_ALLOW_HEADERS` (the static set covering the documented
|
|
184
|
+
* /api endpoints + the `/api/fetch-proxy` transport headers) and unions
|
|
185
|
+
* in any header names from the request's `Access-Control-Request-Headers`
|
|
186
|
+
* that aren't already listed. This is the reflect-headers pattern: the
|
|
187
|
+
* agent's `bash curl -H X-Custom: …` can route through `/api/fetch-proxy`
|
|
188
|
+
* cross-origin without us having to enumerate every possible upstream
|
|
189
|
+
* header in advance. Comparison is case-insensitive; the static set's
|
|
190
|
+
* canonical casing wins on duplicates.
|
|
191
|
+
*/
|
|
192
|
+
export function resolveCorsAllowHeaders(requestHeadersHeader) {
|
|
193
|
+
if (!requestHeadersHeader)
|
|
194
|
+
return CORS_BASE_ALLOW_HEADERS.join(', ');
|
|
195
|
+
const seen = new Set(CORS_BASE_ALLOW_HEADERS.map((h) => h.toLowerCase()));
|
|
196
|
+
const extras = [];
|
|
197
|
+
for (const raw of requestHeadersHeader.split(',')) {
|
|
198
|
+
const name = raw.trim();
|
|
199
|
+
if (!name)
|
|
200
|
+
continue;
|
|
201
|
+
const lower = name.toLowerCase();
|
|
202
|
+
if (seen.has(lower))
|
|
203
|
+
continue;
|
|
204
|
+
seen.add(lower);
|
|
205
|
+
extras.push(name);
|
|
206
|
+
}
|
|
207
|
+
if (extras.length === 0)
|
|
208
|
+
return CORS_BASE_ALLOW_HEADERS.join(', ');
|
|
209
|
+
return [...CORS_BASE_ALLOW_HEADERS, ...extras].join(', ');
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* CORS headers for an allowlisted `Origin`. Returns `null` when the origin
|
|
213
|
+
* is not in the allowlist (caller should NOT set CORS headers).
|
|
214
|
+
*
|
|
215
|
+
* `Access-Control-Allow-Credentials: true` is included so the hosted leader
|
|
216
|
+
* can carry cookies to /api/* (e.g. auth) when that's added later. Today
|
|
217
|
+
* the bridge token is the auth factor, not cookies.
|
|
218
|
+
*
|
|
219
|
+
* `requestHeadersHeader` should be the request's `Access-Control-Request-Headers`
|
|
220
|
+
* value (preflight only); on a non-preflight request pass `null` and the
|
|
221
|
+
* caller can omit it.
|
|
222
|
+
*/
|
|
223
|
+
export function buildCorsHeaders(origin, requestHeadersHeader) {
|
|
224
|
+
if (!isAllowedBridgeOrigin(origin))
|
|
225
|
+
return null;
|
|
226
|
+
const reqHeaders = Array.isArray(requestHeadersHeader)
|
|
227
|
+
? requestHeadersHeader.join(', ')
|
|
228
|
+
: (requestHeadersHeader ?? null);
|
|
229
|
+
return {
|
|
230
|
+
'Access-Control-Allow-Origin': origin,
|
|
231
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
232
|
+
'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
|
|
233
|
+
'Access-Control-Allow-Headers': resolveCorsAllowHeaders(reqHeaders),
|
|
234
|
+
'Access-Control-Expose-Headers': CORS_EXPOSE_HEADERS,
|
|
235
|
+
Vary: 'Origin, Access-Control-Request-Headers',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* PNA preflight extras. Added on OPTIONS responses for allowlisted origins
|
|
240
|
+
* when the request carries `Access-Control-Request-Private-Network: true`
|
|
241
|
+
* — Chrome blocks public→private fetch / WS otherwise.
|
|
242
|
+
*/
|
|
243
|
+
export function buildPnaPreflightHeaders() {
|
|
244
|
+
return { 'Access-Control-Allow-Private-Network': 'true' };
|
|
245
|
+
}
|