aicodeman 1.8.1 → 1.8.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.
- package/dist/config/webview-limits.d.ts +36 -0
- package/dist/config/webview-limits.d.ts.map +1 -0
- package/dist/config/webview-limits.js +40 -0
- package/dist/config/webview-limits.js.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/webview.d.ts +84 -0
- package/dist/types/webview.d.ts.map +1 -0
- package/dist/types/webview.js +21 -0
- package/dist/types/webview.js.map +1 -0
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +75 -3
- package/dist/web/middleware/auth.js.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.adc992cd.js +38 -0
- package/dist/web/public/app.adc992cd.js.br +0 -0
- package/dist/web/public/app.adc992cd.js.gz +0 -0
- package/dist/web/public/{constants.21a0a83e.js → constants.e7175b95.js} +3 -0
- package/dist/web/public/constants.e7175b95.js.br +0 -0
- package/dist/web/public/{constants.21a0a83e.js.gz → constants.e7175b95.js.gz} +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/i18n.4dcf1b87.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +91 -19
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
- package/dist/web/public/mobile-handlers.bc2b8c1d.js.gz +0 -0
- package/dist/web/public/{mobile.2ebdbb03.css → mobile.6153d275.css} +1 -1
- package/dist/web/public/mobile.6153d275.css.br +0 -0
- package/dist/web/public/mobile.6153d275.css.gz +0 -0
- package/dist/web/public/notification-manager.fca811fd.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.58582d4c.js → panels-ui.3910e1e2.js} +16 -16
- package/dist/web/public/panels-ui.3910e1e2.js.br +0 -0
- package/dist/web/public/panels-ui.3910e1e2.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-ui.fd39c53c.js.gz +0 -0
- package/dist/web/public/{settings-ui.478c8242.js → settings-ui.b8def032.js} +1 -1
- package/dist/web/public/settings-ui.b8def032.js.br +0 -0
- package/dist/web/public/settings-ui.b8def032.js.gz +0 -0
- package/dist/web/public/styles.bd210c71.css +1 -0
- package/dist/web/public/styles.bd210c71.css.br +0 -0
- package/dist/web/public/styles.bd210c71.css.gz +0 -0
- package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.feaf75c2.js +3 -0
- package/dist/web/public/terminal-ui.feaf75c2.js.br +0 -0
- package/dist/web/public/terminal-ui.feaf75c2.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
- package/dist/web/public/webview-tabs.js +445 -0
- package/dist/web/public/webview-tabs.js.br +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/webview-routes.d.ts +50 -0
- package/dist/web/routes/webview-routes.d.ts.map +1 -0
- package/dist/web/routes/webview-routes.js +541 -0
- package/dist/web/routes/webview-routes.js.map +1 -0
- package/dist/web/schemas.d.ts +26 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +34 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +11 -5
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +7 -1
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +8 -1
- package/dist/web/sse-events.js.map +1 -1
- package/dist/web/webview-proxy.d.ts +181 -0
- package/dist/web/webview-proxy.d.ts.map +1 -0
- package/dist/web/webview-proxy.js +491 -0
- package/dist/web/webview-proxy.js.map +1 -0
- package/dist/webview-capabilities.d.ts +55 -0
- package/dist/webview-capabilities.d.ts.map +1 -0
- package/dist/webview-capabilities.js +102 -0
- package/dist/webview-capabilities.js.map +1 -0
- package/dist/webview-store.d.ts +16 -0
- package/dist/webview-store.d.ts.map +1 -0
- package/dist/webview-store.js +34 -0
- package/dist/webview-store.js.map +1 -0
- package/package.json +2 -1
- package/dist/web/public/app.2e95c82d.js +0 -38
- package/dist/web/public/app.2e95c82d.js.br +0 -0
- package/dist/web/public/app.2e95c82d.js.gz +0 -0
- package/dist/web/public/constants.21a0a83e.js.br +0 -0
- package/dist/web/public/mobile.2ebdbb03.css.br +0 -0
- package/dist/web/public/mobile.2ebdbb03.css.gz +0 -0
- package/dist/web/public/panels-ui.58582d4c.js.br +0 -0
- package/dist/web/public/panels-ui.58582d4c.js.gz +0 -0
- package/dist/web/public/settings-ui.478c8242.js.br +0 -0
- package/dist/web/public/settings-ui.478c8242.js.gz +0 -0
- package/dist/web/public/styles.5339e2f0.css +0 -1
- package/dist/web/public/styles.5339e2f0.css.br +0 -0
- package/dist/web/public/styles.5339e2f0.css.gz +0 -0
- package/dist/web/public/terminal-ui.9ea677e9.js +0 -3
- package/dist/web/public/terminal-ui.9ea677e9.js.br +0 -0
- package/dist/web/public/terminal-ui.9ea677e9.js.gz +0 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Pure helpers for the web-tab reverse proxy. No I/O, no Fastify.
|
|
3
|
+
*
|
|
4
|
+
* The proxy exists because an iframe pointing straight at a dashboard cannot work
|
|
5
|
+
* in the deployment that matters: prod serves HTTPS (behind `tailscale serve`), so
|
|
6
|
+
* a plain-HTTP dashboard is hard-blocked as mixed content; many dashboards also
|
|
7
|
+
* refuse framing outright via `X-Frame-Options` / `frame-ancestors`; and Codeman's
|
|
8
|
+
* own CSP (`default-src 'self'`) blocks cross-origin frames anyway. Serving the
|
|
9
|
+
* dashboard through Codeman's own origin dissolves all three at once, and keeps
|
|
10
|
+
* the production CSP byte-for-byte unchanged because `/webview/...` is `'self'`.
|
|
11
|
+
*
|
|
12
|
+
* ## Origin-scoped, not path-scoped
|
|
13
|
+
*
|
|
14
|
+
* `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never to
|
|
15
|
+
* `<upstream origin><saved path>/x/y`. Dashboards reference assets with
|
|
16
|
+
* root-absolute paths (`/public/build/app.js`), so origin-scoping is the only
|
|
17
|
+
* mapping under which those resolve. The saved URL's own path+query is used for
|
|
18
|
+
* exactly one thing: what `/webview/<cap>/` itself serves (the landing page).
|
|
19
|
+
*
|
|
20
|
+
* ## What gets rewritten, and why each one is load-bearing
|
|
21
|
+
*
|
|
22
|
+
* - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
|
|
23
|
+
* to render the frame. This is the whole point of the proxy.
|
|
24
|
+
* - `content-encoding` / `content-length`: dropped, because undici's `fetch`
|
|
25
|
+
* already decoded the body. Forwarding them makes the browser try to gunzip
|
|
26
|
+
* plaintext.
|
|
27
|
+
* - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
|
|
28
|
+
* trusted mode the iframe is same-origin, so the browser attaches Codeman's own
|
|
29
|
+
* Basic-auth header and session cookie to every proxied request. Forwarding
|
|
30
|
+
* those would hand CODEMAN_PASSWORD to the dashboard.
|
|
31
|
+
* - `Location` and `Set-Cookie`: remapped into the proxy path, else a redirect or
|
|
32
|
+
* a login cookie escapes the prefix and lands on Codeman's root.
|
|
33
|
+
* - `<base href>` + root-absolute attribute rewriting: relative and `/`-rooted
|
|
34
|
+
* URLs in the HTML resolve back through the proxy instead of hitting Codeman.
|
|
35
|
+
*
|
|
36
|
+
* No `X-Forwarded-*` is sent deliberately: apps that honor it generate absolute
|
|
37
|
+
* URLs against Codeman's root, which would bypass the `/webview/<cap>/` prefix
|
|
38
|
+
* that everything else here works to preserve.
|
|
39
|
+
*/
|
|
40
|
+
/** The same-origin path prefix an iframe loads for a given capability. */
|
|
41
|
+
export declare function proxyPrefixFor(capability: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Parse and validate a user-supplied dashboard URL.
|
|
44
|
+
*
|
|
45
|
+
* Rejects everything that is not plain `http:`/`https:`, anything carrying
|
|
46
|
+
* embedded credentials (they would be silently forwarded and logged), and
|
|
47
|
+
* anything without a hostname. Returns the normalized `URL` or null.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseWebviewUrl(raw: string): URL | null;
|
|
50
|
+
/** Convenience predicate for Zod refinements. */
|
|
51
|
+
export declare function isValidWebviewUrl(raw: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Map a proxy request path to its upstream URL.
|
|
54
|
+
*
|
|
55
|
+
* `wildcard` is Fastify's `*` param: the path after `/webview/<cap>/`, without a
|
|
56
|
+
* leading slash. An empty wildcard means the landing page, which is the saved
|
|
57
|
+
* URL's own path and query.
|
|
58
|
+
*
|
|
59
|
+
* Returns null when the result would escape the upstream origin (a `..` chain, a
|
|
60
|
+
* protocol-relative `//evil.com` wildcard, or an absolute URL smuggled into the
|
|
61
|
+
* path). That check is what keeps this from being an open proxy.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveUpstreamUrl(savedUrl: string, wildcard: string, search: string): URL | null;
|
|
64
|
+
/** Extract the capability from a `/webview/<cap>/...` pathname, or null. */
|
|
65
|
+
export declare function capabilityFromProxyPath(pathname: string): string | null;
|
|
66
|
+
/**
|
|
67
|
+
* Extract the capability a `Referer` belongs to. Backs the 404 fallback that
|
|
68
|
+
* catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix.
|
|
69
|
+
*/
|
|
70
|
+
export declare function capabilityFromReferer(referer: string | undefined): string | null;
|
|
71
|
+
/** Remove the `frame-ancestors` directive from a CSP, preserving the rest. */
|
|
72
|
+
export declare function stripFrameAncestors(csp: string): string;
|
|
73
|
+
/** The `frame-ancestors` directive value from a CSP, or undefined. */
|
|
74
|
+
export declare function extractFrameAncestors(csp: string | undefined): string | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Whether a target permits being framed by a different origin, judged from its
|
|
77
|
+
* `X-Frame-Options` and CSP. Used only to recommend proxy vs direct mode in the
|
|
78
|
+
* editor; the proxy path works either way.
|
|
79
|
+
*/
|
|
80
|
+
export declare function isFramableCrossOrigin(xFrameOptions: string | undefined, csp: string | undefined): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Rewrite an upstream `Location` into the proxy path.
|
|
83
|
+
*
|
|
84
|
+
* Same-origin redirects (relative or absolute) are remapped so the browser stays
|
|
85
|
+
* inside the frame. Cross-origin redirects are returned unchanged rather than
|
|
86
|
+
* proxied: relaying them would turn this into an open proxy for any host the
|
|
87
|
+
* upstream chooses to name.
|
|
88
|
+
*/
|
|
89
|
+
export declare function rewriteLocation(location: string, requestUrl: URL, capability: string): string;
|
|
90
|
+
/**
|
|
91
|
+
* Rewrite an upstream `Set-Cookie` so it applies to the proxy path only.
|
|
92
|
+
*
|
|
93
|
+
* `Domain` is dropped (the cookie now belongs to Codeman's host), `Path` is
|
|
94
|
+
* rebased onto the proxy prefix so two dashboards cannot collide on a shared
|
|
95
|
+
* cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP
|
|
96
|
+
* in dev, where a Secure cookie would simply be discarded.
|
|
97
|
+
*/
|
|
98
|
+
export declare function rewriteSetCookie(cookie: string, capability: string, secureContext: boolean): string;
|
|
99
|
+
/** Drop named cookies from a `Cookie` request header, returning undefined if none remain. */
|
|
100
|
+
export declare function filterCookieHeader(cookie: string | undefined, drop: string[]): string | undefined;
|
|
101
|
+
/** Build the header set sent upstream, from the browser's request headers. */
|
|
102
|
+
export declare function buildUpstreamRequestHeaders(incoming: Record<string, string | string[] | undefined>, upstream: URL, opts: {
|
|
103
|
+
forwardCookies: boolean;
|
|
104
|
+
sessionCookieName: string;
|
|
105
|
+
refererPath?: string;
|
|
106
|
+
}): Record<string, string>;
|
|
107
|
+
/**
|
|
108
|
+
* Build the response headers sent to the browser.
|
|
109
|
+
*
|
|
110
|
+
* Also returns the CSP to apply: the upstream's, minus `frame-ancestors`. Callers
|
|
111
|
+
* MUST set (or explicitly clear) this, because `registerSecurityHeaders` has
|
|
112
|
+
* already stamped Codeman's own `default-src 'self'` policy onto the reply, and
|
|
113
|
+
* that policy would break virtually every dashboard.
|
|
114
|
+
*/
|
|
115
|
+
export declare function buildDownstreamResponseHeaders(upstreamHeaders: Iterable<[string, string]>,
|
|
116
|
+
/**
|
|
117
|
+
* Upstream `Set-Cookie` values, already separated. Passed in rather than read
|
|
118
|
+
* from `upstreamHeaders` because iterating a `Headers` object JOINS duplicate
|
|
119
|
+
* set-cookie values into one comma-separated string, which cannot be split back
|
|
120
|
+
* apart reliably (Expires dates contain commas). Callers use
|
|
121
|
+
* `response.headers.getSetCookie()`.
|
|
122
|
+
*/
|
|
123
|
+
setCookies: string[], capability: string, requestUrl: URL, secureContext: boolean): {
|
|
124
|
+
headers: Record<string, string>;
|
|
125
|
+
setCookie: string[];
|
|
126
|
+
csp: string | null;
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* A tiny script injected at the top of every proxied document, rewriting
|
|
130
|
+
* ROOT-ABSOLUTE URLs built at runtime so they stay inside the proxy prefix.
|
|
131
|
+
*
|
|
132
|
+
* `<base href>` only governs URLs the HTML parser resolves. A dashboard that calls
|
|
133
|
+
* `fetch('/api/data')` bypasses it entirely and the request lands on Codeman's own
|
|
134
|
+
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
|
|
135
|
+
* their own backend, and it presents as the dashboard's own "Failed to fetch".
|
|
136
|
+
*
|
|
137
|
+
* The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
|
|
138
|
+
* paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
|
|
139
|
+
* header skip auth on Codeman's own API). Rewriting inside the iframe removes the
|
|
140
|
+
* whole class instead of trading security for it: the page never emits a
|
|
141
|
+
* root-absolute request in the first place.
|
|
142
|
+
*
|
|
143
|
+
* Runs before any page script because it is injected immediately after `<base>`.
|
|
144
|
+
* Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
|
|
145
|
+
* (already handled by `<base>`) and cross-origin URLs are passed through.
|
|
146
|
+
*/
|
|
147
|
+
export declare function runtimeUrlShim(prefix: string): string;
|
|
148
|
+
/**
|
|
149
|
+
* Inject `<base href="/webview/<cap>/">` plus the runtime URL shim, and rebase
|
|
150
|
+
* root-absolute `src`/`href`/`action` attributes, which `<base>` alone does not
|
|
151
|
+
* affect.
|
|
152
|
+
*
|
|
153
|
+
* Three layers, because no single one is sufficient: `<base>` for parser-resolved
|
|
154
|
+
* relative URLs, attribute rewriting for root-absolute markup, and the shim for
|
|
155
|
+
* URLs built at runtime.
|
|
156
|
+
*/
|
|
157
|
+
export declare function rewriteHtml(html: string, capability: string): string;
|
|
158
|
+
/**
|
|
159
|
+
* CORS headers for a proxied response.
|
|
160
|
+
*
|
|
161
|
+
* Non-obvious but load-bearing: a SANDBOXED iframe (no `allow-same-origin`) runs
|
|
162
|
+
* in an OPAQUE origin, so every `fetch`/XHR it makes is a cross-origin request even
|
|
163
|
+
* though the URL is on this very host, and the browser requires CORS headers to
|
|
164
|
+
* hand back the response. Without this, a dashboard renders fine (script/css/img
|
|
165
|
+
* loads are not CORS-checked) while every one of its API calls fails with an opaque
|
|
166
|
+
* `net::ERR_FAILED` and the page shows its own "failed to load" state. `curl`
|
|
167
|
+
* cannot reproduce it, because curl does not enforce CORS.
|
|
168
|
+
*
|
|
169
|
+
* The origin is echoed rather than `*` so credentialed requests still work in
|
|
170
|
+
* trusted mode. `null` (the opaque-origin case) is echoed as-is, but WITHOUT
|
|
171
|
+
* `allow-credentials`, which browsers reject in combination.
|
|
172
|
+
*
|
|
173
|
+
* This grants nothing extra: the URL is already gated by the capability, and only
|
|
174
|
+
* a document that was handed the capability can construct these requests.
|
|
175
|
+
*/
|
|
176
|
+
export declare function buildProxyCorsHeaders(origin: string | undefined, requestedHeaders?: string): Record<string, string>;
|
|
177
|
+
/** Whether a content-type identifies HTML worth rewriting. */
|
|
178
|
+
export declare function isHtmlContentType(contentType: string | undefined): boolean;
|
|
179
|
+
/** Map an upstream http(s) URL to its ws(s) equivalent for the WebSocket leg. */
|
|
180
|
+
export declare function upstreamWebSocketUrl(target: URL): string;
|
|
181
|
+
//# sourceMappingURL=webview-proxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webview-proxy.d.ts","sourceRoot":"","sources":["../../src/web/webview-proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AA2DH,0EAA0E;AAC1E,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,GAAG,IAAI,CAYvD;AAED,iDAAiD;AACjD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,GAAG,GAAG,IAAI,CAoBjG;AAED,4EAA4E;AAC5E,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQvE;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAOhF;AAED,8EAA8E;AAC9E,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAMvD;AAED,sEAAsE;AACtE,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CASjF;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CASzG;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAU7F;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,GAAG,MAAM,CAsBnG;AAED,6FAA6F;AAC7F,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,SAAS,CAQjG;AAED,8EAA8E;AAC9E,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,EACvD,QAAQ,EAAE,GAAG,EACb,IAAI,EAAE;IAAE,cAAc,EAAE,OAAO,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GACjF,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAsBxB;AAED;;;;;;;GAOG;AACH,wBAAgB,8BAA8B,CAC5C,eAAe,EAAE,QAAQ,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAC3C;;;;;;GAMG;AACH,UAAU,EAAE,MAAM,EAAE,EACpB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,GAAG,EACf,aAAa,EAAE,OAAO,GACrB;IAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAsB9E;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CA6CrD;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAwBpE;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,gBAAgB,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAcnH;AAED,8DAA8D;AAC9D,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAI1E;AAED,iFAAiF;AACjF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,GAAG,GAAG,MAAM,CAIxD"}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Pure helpers for the web-tab reverse proxy. No I/O, no Fastify.
|
|
3
|
+
*
|
|
4
|
+
* The proxy exists because an iframe pointing straight at a dashboard cannot work
|
|
5
|
+
* in the deployment that matters: prod serves HTTPS (behind `tailscale serve`), so
|
|
6
|
+
* a plain-HTTP dashboard is hard-blocked as mixed content; many dashboards also
|
|
7
|
+
* refuse framing outright via `X-Frame-Options` / `frame-ancestors`; and Codeman's
|
|
8
|
+
* own CSP (`default-src 'self'`) blocks cross-origin frames anyway. Serving the
|
|
9
|
+
* dashboard through Codeman's own origin dissolves all three at once, and keeps
|
|
10
|
+
* the production CSP byte-for-byte unchanged because `/webview/...` is `'self'`.
|
|
11
|
+
*
|
|
12
|
+
* ## Origin-scoped, not path-scoped
|
|
13
|
+
*
|
|
14
|
+
* `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never to
|
|
15
|
+
* `<upstream origin><saved path>/x/y`. Dashboards reference assets with
|
|
16
|
+
* root-absolute paths (`/public/build/app.js`), so origin-scoping is the only
|
|
17
|
+
* mapping under which those resolve. The saved URL's own path+query is used for
|
|
18
|
+
* exactly one thing: what `/webview/<cap>/` itself serves (the landing page).
|
|
19
|
+
*
|
|
20
|
+
* ## What gets rewritten, and why each one is load-bearing
|
|
21
|
+
*
|
|
22
|
+
* - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
|
|
23
|
+
* to render the frame. This is the whole point of the proxy.
|
|
24
|
+
* - `content-encoding` / `content-length`: dropped, because undici's `fetch`
|
|
25
|
+
* already decoded the body. Forwarding them makes the browser try to gunzip
|
|
26
|
+
* plaintext.
|
|
27
|
+
* - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
|
|
28
|
+
* trusted mode the iframe is same-origin, so the browser attaches Codeman's own
|
|
29
|
+
* Basic-auth header and session cookie to every proxied request. Forwarding
|
|
30
|
+
* those would hand CODEMAN_PASSWORD to the dashboard.
|
|
31
|
+
* - `Location` and `Set-Cookie`: remapped into the proxy path, else a redirect or
|
|
32
|
+
* a login cookie escapes the prefix and lands on Codeman's root.
|
|
33
|
+
* - `<base href>` + root-absolute attribute rewriting: relative and `/`-rooted
|
|
34
|
+
* URLs in the HTML resolve back through the proxy instead of hitting Codeman.
|
|
35
|
+
*
|
|
36
|
+
* No `X-Forwarded-*` is sent deliberately: apps that honor it generate absolute
|
|
37
|
+
* URLs against Codeman's root, which would bypass the `/webview/<cap>/` prefix
|
|
38
|
+
* that everything else here works to preserve.
|
|
39
|
+
*/
|
|
40
|
+
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
|
|
41
|
+
/** Headers that are per-connection and must never be relayed in either direction. */
|
|
42
|
+
const HOP_BY_HOP = new Set([
|
|
43
|
+
'connection',
|
|
44
|
+
'keep-alive',
|
|
45
|
+
'proxy-authenticate',
|
|
46
|
+
'proxy-authorization',
|
|
47
|
+
'proxy-connection',
|
|
48
|
+
'te',
|
|
49
|
+
'trailer',
|
|
50
|
+
'transfer-encoding',
|
|
51
|
+
'upgrade',
|
|
52
|
+
]);
|
|
53
|
+
/**
|
|
54
|
+
* Request headers dropped on the way to the upstream. `authorization` and `cookie`
|
|
55
|
+
* carry Codeman's own credentials on a same-origin (trusted) frame; `host`,
|
|
56
|
+
* `content-length` and `accept-encoding` are recomputed by undici.
|
|
57
|
+
*/
|
|
58
|
+
const DROP_REQUEST_HEADERS = new Set([
|
|
59
|
+
...HOP_BY_HOP,
|
|
60
|
+
'host',
|
|
61
|
+
'content-length',
|
|
62
|
+
'accept-encoding',
|
|
63
|
+
'authorization',
|
|
64
|
+
'cookie',
|
|
65
|
+
'origin',
|
|
66
|
+
'referer',
|
|
67
|
+
'x-codeman-hook-secret',
|
|
68
|
+
]);
|
|
69
|
+
/** Response headers dropped on the way back to the browser. */
|
|
70
|
+
const DROP_RESPONSE_HEADERS = new Set([
|
|
71
|
+
...HOP_BY_HOP,
|
|
72
|
+
'content-encoding',
|
|
73
|
+
'content-length',
|
|
74
|
+
'x-frame-options',
|
|
75
|
+
'content-security-policy-report-only',
|
|
76
|
+
// Cross-origin isolation headers describe the UPSTREAM's origin policy; applied
|
|
77
|
+
// to a frame on Codeman's origin they only produce blocked-resource surprises.
|
|
78
|
+
'cross-origin-opener-policy',
|
|
79
|
+
'cross-origin-embedder-policy',
|
|
80
|
+
'cross-origin-resource-policy',
|
|
81
|
+
'set-cookie',
|
|
82
|
+
'location',
|
|
83
|
+
'content-security-policy',
|
|
84
|
+
// The upstream's CORS answer describes ITS origin; the frame asking is
|
|
85
|
+
// opaque-origin on ours, so ours must replace it (see buildProxyCorsHeaders).
|
|
86
|
+
'access-control-allow-origin',
|
|
87
|
+
'access-control-allow-credentials',
|
|
88
|
+
'access-control-allow-methods',
|
|
89
|
+
'access-control-allow-headers',
|
|
90
|
+
'access-control-expose-headers',
|
|
91
|
+
'access-control-max-age',
|
|
92
|
+
]);
|
|
93
|
+
/** The same-origin path prefix an iframe loads for a given capability. */
|
|
94
|
+
export function proxyPrefixFor(capability) {
|
|
95
|
+
return `${WEBVIEW_PROXY_PREFIX}/${capability}/`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse and validate a user-supplied dashboard URL.
|
|
99
|
+
*
|
|
100
|
+
* Rejects everything that is not plain `http:`/`https:`, anything carrying
|
|
101
|
+
* embedded credentials (they would be silently forwarded and logged), and
|
|
102
|
+
* anything without a hostname. Returns the normalized `URL` or null.
|
|
103
|
+
*/
|
|
104
|
+
export function parseWebviewUrl(raw) {
|
|
105
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
106
|
+
return null;
|
|
107
|
+
let url;
|
|
108
|
+
try {
|
|
109
|
+
url = new URL(raw.trim());
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
115
|
+
return null;
|
|
116
|
+
if (url.username !== '' || url.password !== '')
|
|
117
|
+
return null;
|
|
118
|
+
if (!url.hostname)
|
|
119
|
+
return null;
|
|
120
|
+
return url;
|
|
121
|
+
}
|
|
122
|
+
/** Convenience predicate for Zod refinements. */
|
|
123
|
+
export function isValidWebviewUrl(raw) {
|
|
124
|
+
return parseWebviewUrl(raw) !== null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Map a proxy request path to its upstream URL.
|
|
128
|
+
*
|
|
129
|
+
* `wildcard` is Fastify's `*` param: the path after `/webview/<cap>/`, without a
|
|
130
|
+
* leading slash. An empty wildcard means the landing page, which is the saved
|
|
131
|
+
* URL's own path and query.
|
|
132
|
+
*
|
|
133
|
+
* Returns null when the result would escape the upstream origin (a `..` chain, a
|
|
134
|
+
* protocol-relative `//evil.com` wildcard, or an absolute URL smuggled into the
|
|
135
|
+
* path). That check is what keeps this from being an open proxy.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveUpstreamUrl(savedUrl, wildcard, search) {
|
|
138
|
+
const base = parseWebviewUrl(savedUrl);
|
|
139
|
+
if (!base)
|
|
140
|
+
return null;
|
|
141
|
+
if (wildcard === '' || wildcard === '/') {
|
|
142
|
+
const landing = new URL(base.pathname + (search || base.search), base.origin);
|
|
143
|
+
return landing.origin === base.origin ? landing : null;
|
|
144
|
+
}
|
|
145
|
+
// A wildcard starting with `//` would parse as protocol-relative and jump host.
|
|
146
|
+
const path = wildcard.startsWith('/') ? wildcard : `/${wildcard}`;
|
|
147
|
+
if (path.startsWith('//'))
|
|
148
|
+
return null;
|
|
149
|
+
let target;
|
|
150
|
+
try {
|
|
151
|
+
target = new URL(path + (search || ''), base.origin);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return target.origin === base.origin ? target : null;
|
|
157
|
+
}
|
|
158
|
+
/** Extract the capability from a `/webview/<cap>/...` pathname, or null. */
|
|
159
|
+
export function capabilityFromProxyPath(pathname) {
|
|
160
|
+
if (typeof pathname !== 'string')
|
|
161
|
+
return null;
|
|
162
|
+
const prefix = `${WEBVIEW_PROXY_PREFIX}/`;
|
|
163
|
+
if (!pathname.startsWith(prefix))
|
|
164
|
+
return null;
|
|
165
|
+
const rest = pathname.slice(prefix.length);
|
|
166
|
+
const slash = rest.indexOf('/');
|
|
167
|
+
const cap = slash === -1 ? rest : rest.slice(0, slash);
|
|
168
|
+
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Extract the capability a `Referer` belongs to. Backs the 404 fallback that
|
|
172
|
+
* catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix.
|
|
173
|
+
*/
|
|
174
|
+
export function capabilityFromReferer(referer) {
|
|
175
|
+
if (!referer)
|
|
176
|
+
return null;
|
|
177
|
+
try {
|
|
178
|
+
return capabilityFromProxyPath(new URL(referer).pathname);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Remove the `frame-ancestors` directive from a CSP, preserving the rest. */
|
|
185
|
+
export function stripFrameAncestors(csp) {
|
|
186
|
+
return csp
|
|
187
|
+
.split(';')
|
|
188
|
+
.map((d) => d.trim())
|
|
189
|
+
.filter((d) => d !== '' && !/^frame-ancestors\b/i.test(d))
|
|
190
|
+
.join('; ');
|
|
191
|
+
}
|
|
192
|
+
/** The `frame-ancestors` directive value from a CSP, or undefined. */
|
|
193
|
+
export function extractFrameAncestors(csp) {
|
|
194
|
+
if (!csp)
|
|
195
|
+
return undefined;
|
|
196
|
+
for (const directive of csp.split(';')) {
|
|
197
|
+
const trimmed = directive.trim();
|
|
198
|
+
if (/^frame-ancestors\b/i.test(trimmed)) {
|
|
199
|
+
return trimmed.slice('frame-ancestors'.length).trim();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Whether a target permits being framed by a different origin, judged from its
|
|
206
|
+
* `X-Frame-Options` and CSP. Used only to recommend proxy vs direct mode in the
|
|
207
|
+
* editor; the proxy path works either way.
|
|
208
|
+
*/
|
|
209
|
+
export function isFramableCrossOrigin(xFrameOptions, csp) {
|
|
210
|
+
const xfo = xFrameOptions?.trim().toLowerCase();
|
|
211
|
+
if (xfo === 'deny' || xfo === 'sameorigin')
|
|
212
|
+
return false;
|
|
213
|
+
const ancestors = extractFrameAncestors(csp)?.toLowerCase();
|
|
214
|
+
if (ancestors === undefined)
|
|
215
|
+
return true;
|
|
216
|
+
if (ancestors.includes("'none'"))
|
|
217
|
+
return false;
|
|
218
|
+
// 'self' alone means same-origin only, which a cross-origin embed is not.
|
|
219
|
+
if (ancestors === "'self'")
|
|
220
|
+
return false;
|
|
221
|
+
return ancestors.includes('*') || ancestors.includes('http');
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Rewrite an upstream `Location` into the proxy path.
|
|
225
|
+
*
|
|
226
|
+
* Same-origin redirects (relative or absolute) are remapped so the browser stays
|
|
227
|
+
* inside the frame. Cross-origin redirects are returned unchanged rather than
|
|
228
|
+
* proxied: relaying them would turn this into an open proxy for any host the
|
|
229
|
+
* upstream chooses to name.
|
|
230
|
+
*/
|
|
231
|
+
export function rewriteLocation(location, requestUrl, capability) {
|
|
232
|
+
let resolved;
|
|
233
|
+
try {
|
|
234
|
+
resolved = new URL(location, requestUrl);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return location;
|
|
238
|
+
}
|
|
239
|
+
if (resolved.origin !== requestUrl.origin)
|
|
240
|
+
return location;
|
|
241
|
+
const suffix = resolved.pathname.replace(/^\//, '');
|
|
242
|
+
return `${proxyPrefixFor(capability)}${suffix}${resolved.search}${resolved.hash}`;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Rewrite an upstream `Set-Cookie` so it applies to the proxy path only.
|
|
246
|
+
*
|
|
247
|
+
* `Domain` is dropped (the cookie now belongs to Codeman's host), `Path` is
|
|
248
|
+
* rebased onto the proxy prefix so two dashboards cannot collide on a shared
|
|
249
|
+
* cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP
|
|
250
|
+
* in dev, where a Secure cookie would simply be discarded.
|
|
251
|
+
*/
|
|
252
|
+
export function rewriteSetCookie(cookie, capability, secureContext) {
|
|
253
|
+
const parts = cookie.split(';');
|
|
254
|
+
const out = [parts[0]];
|
|
255
|
+
let sawPath = false;
|
|
256
|
+
for (const raw of parts.slice(1)) {
|
|
257
|
+
const attr = raw.trim();
|
|
258
|
+
const lower = attr.toLowerCase();
|
|
259
|
+
if (lower.startsWith('domain='))
|
|
260
|
+
continue;
|
|
261
|
+
if (lower === 'secure' && !secureContext)
|
|
262
|
+
continue;
|
|
263
|
+
if (lower.startsWith('path=')) {
|
|
264
|
+
sawPath = true;
|
|
265
|
+
const value = attr.slice('path='.length);
|
|
266
|
+
const suffix = value.replace(/^\//, '');
|
|
267
|
+
out.push(`Path=${proxyPrefixFor(capability)}${suffix}`);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
out.push(attr);
|
|
271
|
+
}
|
|
272
|
+
if (!sawPath)
|
|
273
|
+
out.push(`Path=${proxyPrefixFor(capability)}`);
|
|
274
|
+
return out.join('; ');
|
|
275
|
+
}
|
|
276
|
+
/** Drop named cookies from a `Cookie` request header, returning undefined if none remain. */
|
|
277
|
+
export function filterCookieHeader(cookie, drop) {
|
|
278
|
+
if (!cookie)
|
|
279
|
+
return undefined;
|
|
280
|
+
const dropSet = new Set(drop.map((n) => n.toLowerCase()));
|
|
281
|
+
const kept = cookie
|
|
282
|
+
.split(';')
|
|
283
|
+
.map((c) => c.trim())
|
|
284
|
+
.filter((c) => c !== '' && !dropSet.has(c.slice(0, c.indexOf('=')).trim().toLowerCase()));
|
|
285
|
+
return kept.length > 0 ? kept.join('; ') : undefined;
|
|
286
|
+
}
|
|
287
|
+
/** Build the header set sent upstream, from the browser's request headers. */
|
|
288
|
+
export function buildUpstreamRequestHeaders(incoming, upstream, opts) {
|
|
289
|
+
const headers = {};
|
|
290
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
291
|
+
const lower = key.toLowerCase();
|
|
292
|
+
if (DROP_REQUEST_HEADERS.has(lower))
|
|
293
|
+
continue;
|
|
294
|
+
if (value === undefined)
|
|
295
|
+
continue;
|
|
296
|
+
headers[lower] = Array.isArray(value) ? value.join(', ') : value;
|
|
297
|
+
}
|
|
298
|
+
if (opts.forwardCookies) {
|
|
299
|
+
const raw = incoming['cookie'];
|
|
300
|
+
const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]);
|
|
301
|
+
if (cookie)
|
|
302
|
+
headers['cookie'] = cookie;
|
|
303
|
+
}
|
|
304
|
+
// Present as if the browser were talking to the dashboard directly. Apps that
|
|
305
|
+
// check Origin on writes (CSRF defenses) need this to match their own origin.
|
|
306
|
+
headers['origin'] = upstream.origin;
|
|
307
|
+
headers['referer'] = opts.refererPath ? new URL(opts.refererPath, upstream.origin).href : upstream.href;
|
|
308
|
+
return headers;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Build the response headers sent to the browser.
|
|
312
|
+
*
|
|
313
|
+
* Also returns the CSP to apply: the upstream's, minus `frame-ancestors`. Callers
|
|
314
|
+
* MUST set (or explicitly clear) this, because `registerSecurityHeaders` has
|
|
315
|
+
* already stamped Codeman's own `default-src 'self'` policy onto the reply, and
|
|
316
|
+
* that policy would break virtually every dashboard.
|
|
317
|
+
*/
|
|
318
|
+
export function buildDownstreamResponseHeaders(upstreamHeaders,
|
|
319
|
+
/**
|
|
320
|
+
* Upstream `Set-Cookie` values, already separated. Passed in rather than read
|
|
321
|
+
* from `upstreamHeaders` because iterating a `Headers` object JOINS duplicate
|
|
322
|
+
* set-cookie values into one comma-separated string, which cannot be split back
|
|
323
|
+
* apart reliably (Expires dates contain commas). Callers use
|
|
324
|
+
* `response.headers.getSetCookie()`.
|
|
325
|
+
*/
|
|
326
|
+
setCookies, capability, requestUrl, secureContext) {
|
|
327
|
+
const headers = {};
|
|
328
|
+
let csp = null;
|
|
329
|
+
for (const [key, value] of upstreamHeaders) {
|
|
330
|
+
const lower = key.toLowerCase();
|
|
331
|
+
if (lower === 'content-security-policy') {
|
|
332
|
+
const stripped = stripFrameAncestors(value);
|
|
333
|
+
csp = stripped === '' ? null : stripped;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (lower === 'location') {
|
|
337
|
+
headers['location'] = rewriteLocation(value, requestUrl, capability);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (DROP_RESPONSE_HEADERS.has(lower))
|
|
341
|
+
continue;
|
|
342
|
+
headers[lower] = value;
|
|
343
|
+
}
|
|
344
|
+
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
|
|
345
|
+
return { headers, setCookie, csp };
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* A tiny script injected at the top of every proxied document, rewriting
|
|
349
|
+
* ROOT-ABSOLUTE URLs built at runtime so they stay inside the proxy prefix.
|
|
350
|
+
*
|
|
351
|
+
* `<base href>` only governs URLs the HTML parser resolves. A dashboard that calls
|
|
352
|
+
* `fetch('/api/data')` bypasses it entirely and the request lands on Codeman's own
|
|
353
|
+
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
|
|
354
|
+
* their own backend, and it presents as the dashboard's own "Failed to fetch".
|
|
355
|
+
*
|
|
356
|
+
* The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
|
|
357
|
+
* paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
|
|
358
|
+
* header skip auth on Codeman's own API). Rewriting inside the iframe removes the
|
|
359
|
+
* whole class instead of trading security for it: the page never emits a
|
|
360
|
+
* root-absolute request in the first place.
|
|
361
|
+
*
|
|
362
|
+
* Runs before any page script because it is injected immediately after `<base>`.
|
|
363
|
+
* Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
|
|
364
|
+
* (already handled by `<base>`) and cross-origin URLs are passed through.
|
|
365
|
+
*/
|
|
366
|
+
export function runtimeUrlShim(prefix) {
|
|
367
|
+
// Kept dependency-free and defensive: it runs inside a page we do not control,
|
|
368
|
+
// and a throw here would break the dashboard rather than fix it.
|
|
369
|
+
return `<script>(function(){try{
|
|
370
|
+
var P=${JSON.stringify(prefix)};
|
|
371
|
+
function rw(u){
|
|
372
|
+
try{
|
|
373
|
+
if(u==null)return u;
|
|
374
|
+
if(typeof u!=='string'){
|
|
375
|
+
if(typeof URL!=='undefined'&&u instanceof URL)return rw(u.href);
|
|
376
|
+
return u;
|
|
377
|
+
}
|
|
378
|
+
if(u.indexOf(P)===0)return u;
|
|
379
|
+
if(u.charAt(0)==='/'&&u.charAt(1)!=='/')return P+u.slice(1);
|
|
380
|
+
if(/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(u)||u.indexOf('//')===0){
|
|
381
|
+
var a=new URL(u,location.href);
|
|
382
|
+
if(a.host===location.host&&a.pathname.indexOf(P)!==0){
|
|
383
|
+
a.pathname=P+a.pathname.replace(/^\\//,'');
|
|
384
|
+
return a.href;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return u;
|
|
388
|
+
}catch(e){return u;}
|
|
389
|
+
}
|
|
390
|
+
var of=window.fetch;
|
|
391
|
+
if(of)window.fetch=function(i,o){
|
|
392
|
+
try{
|
|
393
|
+
if(typeof Request!=='undefined'&&i instanceof Request)return of.call(this,new Request(rw(i.url),i),o);
|
|
394
|
+
return of.call(this,rw(i),o);
|
|
395
|
+
}catch(e){return of.call(this,i,o);}
|
|
396
|
+
};
|
|
397
|
+
if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
|
|
398
|
+
var oo=XMLHttpRequest.prototype.open;
|
|
399
|
+
XMLHttpRequest.prototype.open=function(m,u){
|
|
400
|
+
var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a);
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
['WebSocket','EventSource'].forEach(function(k){
|
|
404
|
+
var C=window[k];if(!C)return;
|
|
405
|
+
function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
|
|
406
|
+
W.prototype=C.prototype;
|
|
407
|
+
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
|
|
408
|
+
window[k]=W;
|
|
409
|
+
});
|
|
410
|
+
}catch(e){}})();</script>`;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Inject `<base href="/webview/<cap>/">` plus the runtime URL shim, and rebase
|
|
414
|
+
* root-absolute `src`/`href`/`action` attributes, which `<base>` alone does not
|
|
415
|
+
* affect.
|
|
416
|
+
*
|
|
417
|
+
* Three layers, because no single one is sufficient: `<base>` for parser-resolved
|
|
418
|
+
* relative URLs, attribute rewriting for root-absolute markup, and the shim for
|
|
419
|
+
* URLs built at runtime.
|
|
420
|
+
*/
|
|
421
|
+
export function rewriteHtml(html, capability) {
|
|
422
|
+
const prefix = proxyPrefixFor(capability);
|
|
423
|
+
// Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls.
|
|
424
|
+
const rebased = html
|
|
425
|
+
.replace(/(\s(?:src|href|action)\s*=\s*")\/(?!\/)/gi, `$1${prefix}`)
|
|
426
|
+
.replace(/(\s(?:src|href|action)\s*=\s*')\/(?!\/)/gi, `$1${prefix}`);
|
|
427
|
+
// A page that ships its own <base> keeps it (overriding it would break the
|
|
428
|
+
// author's intent), but it STILL needs the shim, which is the layer that
|
|
429
|
+
// catches runtime-built URLs. So only the base tag is conditional.
|
|
430
|
+
const injected = (/<base\b/i.test(rebased) ? '' : `<base href="${prefix}">`) + runtimeUrlShim(prefix);
|
|
431
|
+
const headMatch = /<head\b[^>]*>/i.exec(rebased);
|
|
432
|
+
if (headMatch) {
|
|
433
|
+
const at = headMatch.index + headMatch[0].length;
|
|
434
|
+
return rebased.slice(0, at) + injected + rebased.slice(at);
|
|
435
|
+
}
|
|
436
|
+
const htmlMatch = /<html\b[^>]*>/i.exec(rebased);
|
|
437
|
+
if (htmlMatch) {
|
|
438
|
+
const at = htmlMatch.index + htmlMatch[0].length;
|
|
439
|
+
return rebased.slice(0, at) + injected + rebased.slice(at);
|
|
440
|
+
}
|
|
441
|
+
return injected + rebased;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* CORS headers for a proxied response.
|
|
445
|
+
*
|
|
446
|
+
* Non-obvious but load-bearing: a SANDBOXED iframe (no `allow-same-origin`) runs
|
|
447
|
+
* in an OPAQUE origin, so every `fetch`/XHR it makes is a cross-origin request even
|
|
448
|
+
* though the URL is on this very host, and the browser requires CORS headers to
|
|
449
|
+
* hand back the response. Without this, a dashboard renders fine (script/css/img
|
|
450
|
+
* loads are not CORS-checked) while every one of its API calls fails with an opaque
|
|
451
|
+
* `net::ERR_FAILED` and the page shows its own "failed to load" state. `curl`
|
|
452
|
+
* cannot reproduce it, because curl does not enforce CORS.
|
|
453
|
+
*
|
|
454
|
+
* The origin is echoed rather than `*` so credentialed requests still work in
|
|
455
|
+
* trusted mode. `null` (the opaque-origin case) is echoed as-is, but WITHOUT
|
|
456
|
+
* `allow-credentials`, which browsers reject in combination.
|
|
457
|
+
*
|
|
458
|
+
* This grants nothing extra: the URL is already gated by the capability, and only
|
|
459
|
+
* a document that was handed the capability can construct these requests.
|
|
460
|
+
*/
|
|
461
|
+
export function buildProxyCorsHeaders(origin, requestedHeaders) {
|
|
462
|
+
if (!origin)
|
|
463
|
+
return {};
|
|
464
|
+
const headers = {
|
|
465
|
+
'access-control-allow-origin': origin,
|
|
466
|
+
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS',
|
|
467
|
+
'access-control-allow-headers': requestedHeaders && requestedHeaders.trim() !== '' ? requestedHeaders : '*',
|
|
468
|
+
'access-control-expose-headers': '*',
|
|
469
|
+
'access-control-max-age': '600',
|
|
470
|
+
vary: 'Origin',
|
|
471
|
+
};
|
|
472
|
+
// `Access-Control-Allow-Credentials: true` alongside a `null` origin is rejected
|
|
473
|
+
// by browsers; a sandboxed frame sends no credentials anyway.
|
|
474
|
+
if (origin !== 'null' && origin !== '*')
|
|
475
|
+
headers['access-control-allow-credentials'] = 'true';
|
|
476
|
+
return headers;
|
|
477
|
+
}
|
|
478
|
+
/** Whether a content-type identifies HTML worth rewriting. */
|
|
479
|
+
export function isHtmlContentType(contentType) {
|
|
480
|
+
if (!contentType)
|
|
481
|
+
return false;
|
|
482
|
+
const type = contentType.split(';')[0].trim().toLowerCase();
|
|
483
|
+
return type === 'text/html' || type === 'application/xhtml+xml';
|
|
484
|
+
}
|
|
485
|
+
/** Map an upstream http(s) URL to its ws(s) equivalent for the WebSocket leg. */
|
|
486
|
+
export function upstreamWebSocketUrl(target) {
|
|
487
|
+
const ws = new URL(target.href);
|
|
488
|
+
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
489
|
+
return ws.href;
|
|
490
|
+
}
|
|
491
|
+
//# sourceMappingURL=webview-proxy.js.map
|