@ggui-ai/mcp-server 0.6.3 → 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.
- package/dist/api-renders-routes.d.ts.map +1 -1
- package/dist/api-renders-routes.js +9 -1
- package/dist/browser-cors.d.ts +29 -0
- package/dist/browser-cors.d.ts.map +1 -0
- package/dist/browser-cors.js +64 -0
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +5 -1
- package/dist/code-store-fs.d.ts +3 -0
- package/dist/code-store-fs.d.ts.map +1 -1
- package/dist/code-store-fs.js +27 -3
- package/dist/console-session-routes.d.ts +10 -5
- package/dist/console-session-routes.d.ts.map +1 -1
- package/dist/console-session-routes.js +10 -5
- package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.js +12 -0
- package/dist/ggui-session-channel/socket-router.d.ts +33 -0
- package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
- package/dist/ggui-session-channel/socket-router.js +155 -0
- package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscribe.js +43 -2
- package/dist/ggui-session-channel.d.ts +114 -0
- package/dist/ggui-session-channel.d.ts.map +1 -1
- package/dist/ggui-session-channel.js +77 -2
- package/dist/health-routes.d.ts +3 -0
- package/dist/health-routes.d.ts.map +1 -1
- package/dist/health-routes.js +11 -1
- package/dist/mcp-apps-outbound.d.ts +236 -31
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +940 -261
- package/dist/origin-validation.d.ts +120 -0
- package/dist/origin-validation.d.ts.map +1 -0
- package/dist/origin-validation.js +199 -0
- package/dist/render-read-gate.d.ts +50 -0
- package/dist/render-read-gate.d.ts.map +1 -0
- package/dist/render-read-gate.js +36 -0
- package/dist/runtime-bundle-route.d.ts +15 -0
- package/dist/runtime-bundle-route.d.ts.map +1 -1
- package/dist/runtime-bundle-route.js +20 -4
- package/dist/server.d.ts +227 -9
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +292 -28
- package/package.json +12 -12
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Origin/Host validation for the MCP wire — the DNS-rebinding defense
|
|
3
|
+
* (ggui#438a).
|
|
4
|
+
*
|
|
5
|
+
* Spec (Streamable HTTP, 2025-11-25): "Servers MUST validate the Origin
|
|
6
|
+
* header on all incoming connections to prevent DNS rebinding attacks…
|
|
7
|
+
* If the Origin header is present and invalid, servers MUST respond
|
|
8
|
+
* with HTTP 403 Forbidden."
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS IS NOT CORS: CORS governs whether a browser lets a page READ
|
|
11
|
+
* a cross-origin response; it never governs whether the request EXECUTES
|
|
12
|
+
* server-side. After a DNS rebind (attacker.com → 127.0.0.1) the page's
|
|
13
|
+
* requests are SAME-origin from the browser's view, so no preflight
|
|
14
|
+
* fires and no CORS header is consulted — only an inbound Host/Origin
|
|
15
|
+
* check catches it. See `./browser-cors.ts` for the (separate) CORS layer.
|
|
16
|
+
*
|
|
17
|
+
* WHY RAW HEADERS: `request-context.ts` honors `X-Forwarded-Host` when the
|
|
18
|
+
* TCP peer is loopback — and a rebinding attacker IS a loopback peer, so a
|
|
19
|
+
* check built on the derived host is bypassable with one attacker-supplied
|
|
20
|
+
* header. `Host` and `Origin` are forbidden header names for `fetch`, so
|
|
21
|
+
* page JS cannot forge them: the raw values are the trustworthy ones.
|
|
22
|
+
*
|
|
23
|
+
* WHY NOT THE SDK's enableDnsRebindingProtection: deprecated upstream in
|
|
24
|
+
* favor of external middleware, per-transport (never sees the WS upgrade
|
|
25
|
+
* ingress or non-transport routes), and runs after ggui's own pipeline.
|
|
26
|
+
* The 403 body + message wording here mirror the SDK's
|
|
27
|
+
* `createJsonErrorResponse` so clients see one rejection dialect.
|
|
28
|
+
*/
|
|
29
|
+
import type { RequestHandler } from "express";
|
|
30
|
+
import type { Logger } from "./logger.js";
|
|
31
|
+
/** Hostnames that mean "this machine". Compared without port. */
|
|
32
|
+
export declare const LOOPBACK_HOSTNAMES: ReadonlyArray<string>;
|
|
33
|
+
export interface OriginHostPolicy {
|
|
34
|
+
/**
|
|
35
|
+
* Allowed `Host` hostnames, port-agnostic and lowercased. `null`
|
|
36
|
+
* disables the Host check entirely — used when the server is bound to
|
|
37
|
+
* a non-loopback address, where the operator has explicitly opted into
|
|
38
|
+
* wide exposure and rebinding is not the applicable threat model.
|
|
39
|
+
* (Combined with --dev-allow-all that posture leaves a documented
|
|
40
|
+
* residual — the CLI warns loudly; see serve-command.ts.)
|
|
41
|
+
*/
|
|
42
|
+
readonly allowedHosts: ReadonlyArray<string> | null;
|
|
43
|
+
/**
|
|
44
|
+
* Allowed page origins, lowercased, exact (scheme + host + port):
|
|
45
|
+
* the operator's `browserOrigins` plus the publicBaseUrl origin
|
|
46
|
+
* (the server's own tunnel-served pages POST with it). Loopback
|
|
47
|
+
* origins and same-origin requests are allowed unconditionally on
|
|
48
|
+
* top of this list — see `validateOriginHost`.
|
|
49
|
+
*/
|
|
50
|
+
readonly allowedOrigins: ReadonlyArray<string>;
|
|
51
|
+
}
|
|
52
|
+
export interface OriginHostRejection {
|
|
53
|
+
readonly header: "host" | "origin";
|
|
54
|
+
/** The offending header value; empty string when the header was absent. */
|
|
55
|
+
readonly value: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build the effective policy from deployment inputs.
|
|
59
|
+
*
|
|
60
|
+
* The Host check is active only for loopback binds — that is exactly the
|
|
61
|
+
* configuration the spec's rebinding warning targets (a local server
|
|
62
|
+
* reachable because the attacker's domain resolves to 127.0.0.1). A
|
|
63
|
+
* server bound to 0.0.0.0 or a routable address was deliberately exposed
|
|
64
|
+
* by its operator; rejecting its own LAN hostname would break phone/LAN
|
|
65
|
+
* testing for no security gain — auth is the gate there.
|
|
66
|
+
*
|
|
67
|
+
* `browserOrigins` contribute ONLY to allowedOrigins, never to
|
|
68
|
+
* allowedHosts: a page at https://app.guuey.com talking to a local serve
|
|
69
|
+
* sends `Host: localhost:6781`, so widening the anti-rebinding Host
|
|
70
|
+
* allowlist with page hostnames would serve no flow.
|
|
71
|
+
*/
|
|
72
|
+
export declare function buildOriginHostPolicy(input: {
|
|
73
|
+
readonly bindHost: string;
|
|
74
|
+
readonly publicBaseUrl?: string;
|
|
75
|
+
readonly browserOrigins?: ReadonlyArray<string>;
|
|
76
|
+
}): OriginHostPolicy;
|
|
77
|
+
/**
|
|
78
|
+
* The two validation postures this function serves. They share every
|
|
79
|
+
* rule except one: `"ws-upgrade"` additionally admits the opaque-origin
|
|
80
|
+
* ("null") Origin — see the `ingress` param below. This is a posture
|
|
81
|
+
* selector, not a literal "which wire did this arrive on" tag: the WS
|
|
82
|
+
* upgrade handler in server.ts passes `"ws-upgrade"` only for upgrades
|
|
83
|
+
* that declare bootstrap intent (`?wsToken=` present); every other
|
|
84
|
+
* upgrade is validated as `"http"`.
|
|
85
|
+
*/
|
|
86
|
+
export type OriginValidationIngress = "http" | "ws-upgrade";
|
|
87
|
+
/**
|
|
88
|
+
* Validate one request's raw Host/Origin against the policy.
|
|
89
|
+
* Returns `null` when the request passes.
|
|
90
|
+
*
|
|
91
|
+
* Pure by design: the Express middleware and the WebSocket `upgrade`
|
|
92
|
+
* handler (a second ingress Express never sees) call the same function,
|
|
93
|
+
* so the two ingresses cannot drift. `ingress` is the one deliberate,
|
|
94
|
+
* documented point of difference between them — everything else is
|
|
95
|
+
* identical logic.
|
|
96
|
+
*
|
|
97
|
+
* @param ingress Defaults to `"http"`, so every existing HTTP call site
|
|
98
|
+
* is unaffected. The WS upgrade handler passes `"ws-upgrade"` only
|
|
99
|
+
* when the upgrade itself already declares bootstrap intent — see
|
|
100
|
+
* {@link OriginValidationIngress}.
|
|
101
|
+
*/
|
|
102
|
+
export declare function validateOriginHost(rawHost: string | undefined, rawOrigin: string | undefined, policy: OriginHostPolicy, ingress?: OriginValidationIngress): OriginHostRejection | null;
|
|
103
|
+
/** Message text for a rejection — mirrors the SDK's own wording. */
|
|
104
|
+
export declare function rejectionMessage(rejection: OriginHostRejection): string;
|
|
105
|
+
export declare function createOriginHostValidationMiddleware(opts: {
|
|
106
|
+
readonly policy: OriginHostPolicy;
|
|
107
|
+
/**
|
|
108
|
+
* Path prefixes where a present-and-disallowed Origin is 403'd — the
|
|
109
|
+
* MCP wire (universal /mcp, per-app prefix, /control, isolated
|
|
110
|
+
* services). The HOST check runs on EVERY path regardless: it is the
|
|
111
|
+
* actual rebinding defense and legitimate cross-origin consumers
|
|
112
|
+
* still send the server's own Host. Origin enforcement is scoped
|
|
113
|
+
* because several non-MCP surfaces are deliberately consumed
|
|
114
|
+
* cross-origin — the runtime-bundle and /code routes are fetched by
|
|
115
|
+
* sandboxed iframes whose Origin is literally `null`.
|
|
116
|
+
*/
|
|
117
|
+
readonly enforceOriginPathPrefixes: ReadonlyArray<string>;
|
|
118
|
+
readonly logger: Logger;
|
|
119
|
+
}): RequestHandler;
|
|
120
|
+
//# sourceMappingURL=origin-validation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"origin-validation.d.ts","sourceRoot":"","sources":["../src/origin-validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,iEAAiE;AACjE,eAAO,MAAM,kBAAkB,EAAE,aAAa,CAAC,MAAM,CAA8C,CAAC;AAEpG,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;OAOG;IACH,QAAQ,CAAC,YAAY,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IACpD;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAAC;IACnC,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AA8CD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE;IAC3C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,cAAc,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CACjD,GAAG,gBAAgB,CA2BnB;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,uBAAuB,GAAG,MAAM,GAAG,YAAY,CAAC;AAE5D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE,uBAAgC,GACxC,mBAAmB,GAAG,IAAI,CA8C5B;AAED,oEAAoE;AACpE,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,mBAAmB,GAAG,MAAM,CAGvE;AAED,wBAAgB,oCAAoC,CAAC,IAAI,EAAE;IACzD,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC;;;;;;;;;OASG;IACH,QAAQ,CAAC,yBAAyB,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC1D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB,GAAG,cAAc,CA+CjB"}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/** Hostnames that mean "this machine". Compared without port. */
|
|
2
|
+
export const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "::1", "[::1]"];
|
|
3
|
+
/** Strip the port and surrounding brackets from a Host/authority value. */
|
|
4
|
+
function hostnameOf(authority) {
|
|
5
|
+
const lower = authority.trim().toLowerCase();
|
|
6
|
+
if (lower.startsWith("[")) {
|
|
7
|
+
const close = lower.indexOf("]");
|
|
8
|
+
return close === -1 ? lower : lower.slice(0, close + 1);
|
|
9
|
+
}
|
|
10
|
+
// A bracket-less value with more than one colon is a bare IPv6
|
|
11
|
+
// address (`::1`), not host:port — return it whole. Slicing at the
|
|
12
|
+
// first colon would return "" and silently disable the Host check
|
|
13
|
+
// for an IPv6 loopback bind.
|
|
14
|
+
if (lower.indexOf(":") !== lower.lastIndexOf(":"))
|
|
15
|
+
return lower;
|
|
16
|
+
const colon = lower.indexOf(":");
|
|
17
|
+
return colon === -1 ? lower : lower.slice(0, colon);
|
|
18
|
+
}
|
|
19
|
+
function isLoopbackHostname(hostname) {
|
|
20
|
+
return LOOPBACK_HOSTNAMES.includes(hostname);
|
|
21
|
+
}
|
|
22
|
+
function isLoopbackOrigin(origin) {
|
|
23
|
+
try {
|
|
24
|
+
return isLoopbackHostname(hostnameOf(new URL(origin).host));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Same-origin allowance: does the Origin's authority (host:port) match
|
|
32
|
+
* the request's own Host? A page this server itself served (LAN bind,
|
|
33
|
+
* tunnel) sends exactly that on every POST and WS handshake; rejecting
|
|
34
|
+
* it would 403 the server's own console. Safe on loopback binds because
|
|
35
|
+
* a rebound page fails the HOST check before Origin is consulted.
|
|
36
|
+
*/
|
|
37
|
+
function originMatchesRequestHost(origin, rawHost) {
|
|
38
|
+
if (rawHost === undefined || rawHost.length === 0)
|
|
39
|
+
return false;
|
|
40
|
+
try {
|
|
41
|
+
return new URL(origin).host === rawHost.trim().toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build the effective policy from deployment inputs.
|
|
49
|
+
*
|
|
50
|
+
* The Host check is active only for loopback binds — that is exactly the
|
|
51
|
+
* configuration the spec's rebinding warning targets (a local server
|
|
52
|
+
* reachable because the attacker's domain resolves to 127.0.0.1). A
|
|
53
|
+
* server bound to 0.0.0.0 or a routable address was deliberately exposed
|
|
54
|
+
* by its operator; rejecting its own LAN hostname would break phone/LAN
|
|
55
|
+
* testing for no security gain — auth is the gate there.
|
|
56
|
+
*
|
|
57
|
+
* `browserOrigins` contribute ONLY to allowedOrigins, never to
|
|
58
|
+
* allowedHosts: a page at https://app.guuey.com talking to a local serve
|
|
59
|
+
* sends `Host: localhost:6781`, so widening the anti-rebinding Host
|
|
60
|
+
* allowlist with page hostnames would serve no flow.
|
|
61
|
+
*/
|
|
62
|
+
export function buildOriginHostPolicy(input) {
|
|
63
|
+
const boundToLoopback = isLoopbackHostname(hostnameOf(input.bindHost));
|
|
64
|
+
const hosts = [...LOOPBACK_HOSTNAMES];
|
|
65
|
+
const origins = (input.browserOrigins ?? [])
|
|
66
|
+
.map((o) => o.trim().toLowerCase().replace(/\/$/, ""))
|
|
67
|
+
.filter((o) => o.length > 0);
|
|
68
|
+
if (input.publicBaseUrl !== undefined) {
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(input.publicBaseUrl);
|
|
71
|
+
// The tunnel/proxy host arrives on forwarded requests' Host
|
|
72
|
+
// header, and the server's OWN pages served from that origin
|
|
73
|
+
// (console, /r/<code>) POST + open WS with it as Origin. Both
|
|
74
|
+
// must validate, or a tunnel deployment 403s itself.
|
|
75
|
+
hosts.push(hostnameOf(url.host));
|
|
76
|
+
origins.push(url.origin.toLowerCase());
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// A malformed publicBaseUrl is the caller's problem to report;
|
|
80
|
+
// silently contributing nothing keeps validation fail-closed.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
allowedHosts: boundToLoopback ? hosts : null,
|
|
85
|
+
allowedOrigins: origins,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Validate one request's raw Host/Origin against the policy.
|
|
90
|
+
* Returns `null` when the request passes.
|
|
91
|
+
*
|
|
92
|
+
* Pure by design: the Express middleware and the WebSocket `upgrade`
|
|
93
|
+
* handler (a second ingress Express never sees) call the same function,
|
|
94
|
+
* so the two ingresses cannot drift. `ingress` is the one deliberate,
|
|
95
|
+
* documented point of difference between them — everything else is
|
|
96
|
+
* identical logic.
|
|
97
|
+
*
|
|
98
|
+
* @param ingress Defaults to `"http"`, so every existing HTTP call site
|
|
99
|
+
* is unaffected. The WS upgrade handler passes `"ws-upgrade"` only
|
|
100
|
+
* when the upgrade itself already declares bootstrap intent — see
|
|
101
|
+
* {@link OriginValidationIngress}.
|
|
102
|
+
*/
|
|
103
|
+
export function validateOriginHost(rawHost, rawOrigin, policy, ingress = "http") {
|
|
104
|
+
if (policy.allowedHosts !== null) {
|
|
105
|
+
if (rawHost === undefined || rawHost.length === 0) {
|
|
106
|
+
return { header: "host", value: "" };
|
|
107
|
+
}
|
|
108
|
+
if (!policy.allowedHosts.includes(hostnameOf(rawHost))) {
|
|
109
|
+
return { header: "host", value: rawHost };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Absent Origin passes: every non-browser client omits it.
|
|
113
|
+
if (rawOrigin === undefined || rawOrigin.length === 0)
|
|
114
|
+
return null;
|
|
115
|
+
// A document with an OPAQUE origin (a srcdoc-mounted document,
|
|
116
|
+
// an about:-scheme page, or an equivalent sandboxed embedding) has no
|
|
117
|
+
// per-origin identity to serialize — the Origin header spec requires
|
|
118
|
+
// browsers to send the literal value "null" in that case, not an
|
|
119
|
+
// absence of the header. Passing `ingress: "ws-upgrade"` admits that
|
|
120
|
+
// value — but the caller (the WS upgrade handler in server.ts) only
|
|
121
|
+
// passes it when the upgrade URL already declares bootstrap intent
|
|
122
|
+
// (`?wsToken=` present); a bare opaque-origin socket with no
|
|
123
|
+
// bootstrap intent is validated as `"http"` and gets the ordinary
|
|
124
|
+
// rejection below.
|
|
125
|
+
//
|
|
126
|
+
// Admission here is NOT authentication. A bootstrap-intent socket
|
|
127
|
+
// still carries only an unverified placeholder identity at this
|
|
128
|
+
// point — the subscribe handler
|
|
129
|
+
// (`ggui-session-channel/subscribe.ts`) rejects it outright unless
|
|
130
|
+
// the `subscribe` message itself presents a credential that verifies.
|
|
131
|
+
// Origin-allowlisting a socket that can still do nothing until it
|
|
132
|
+
// clears that later gate adds no protection; rejecting "null"
|
|
133
|
+
// outright would instead break a spec-legitimate embedding topology
|
|
134
|
+
// that has no other way to send an Origin at all. The HTTP ingress
|
|
135
|
+
// has no equivalent post-admission gate, so it keeps rejecting
|
|
136
|
+
// "null" unconditionally.
|
|
137
|
+
if (ingress === "ws-upgrade" && rawOrigin.trim().toLowerCase() === "null")
|
|
138
|
+
return null;
|
|
139
|
+
const origin = rawOrigin.trim().toLowerCase().replace(/\/$/, "");
|
|
140
|
+
// Loopback pages are allowed unconditionally so the zero-config
|
|
141
|
+
// quickstart (sample SPA on :6890 → serve on :6781) needs no flags.
|
|
142
|
+
// Safe because loopback origins can only be opened by software already
|
|
143
|
+
// running on this machine.
|
|
144
|
+
if (isLoopbackOrigin(origin))
|
|
145
|
+
return null;
|
|
146
|
+
if (originMatchesRequestHost(origin, rawHost))
|
|
147
|
+
return null;
|
|
148
|
+
if (policy.allowedOrigins.includes(origin))
|
|
149
|
+
return null;
|
|
150
|
+
return { header: "origin", value: rawOrigin };
|
|
151
|
+
}
|
|
152
|
+
/** Message text for a rejection — mirrors the SDK's own wording. */
|
|
153
|
+
export function rejectionMessage(rejection) {
|
|
154
|
+
const label = rejection.header === "host" ? "Host" : "Origin";
|
|
155
|
+
return `Invalid ${label} header: ${rejection.value}`;
|
|
156
|
+
}
|
|
157
|
+
export function createOriginHostValidationMiddleware(opts) {
|
|
158
|
+
const { policy, enforceOriginPathPrefixes, logger } = opts;
|
|
159
|
+
// Lowercased once at middleware-creation time so a caller passing a
|
|
160
|
+
// mixed-case service path (e.g. perAppRouting.pathPrefix) is still
|
|
161
|
+
// matched correctly below — see the req.path.toLowerCase() comment.
|
|
162
|
+
const lowerEnforcePrefixes = enforceOriginPathPrefixes.map((p) => p.toLowerCase());
|
|
163
|
+
// Dedupe log noise: a polling page would otherwise flood the log with
|
|
164
|
+
// one identical warning per request. Capped so attacker-minted unique
|
|
165
|
+
// header values cannot grow the set without bound; after the cap,
|
|
166
|
+
// rejections still 403 — they just stop logging.
|
|
167
|
+
const warned = new Set();
|
|
168
|
+
return (req, res, next) => {
|
|
169
|
+
// Express routes case-insensitively by default (no `case sensitive
|
|
170
|
+
// routing` setting here), so `POST /MCP` reaches the same handler as
|
|
171
|
+
// `POST /mcp`. Comparing req.path as-is would let that case variant
|
|
172
|
+
// skip Origin enforcement entirely — the Origin MUST must not be
|
|
173
|
+
// case-bypassable — so both sides are lowercased before comparison.
|
|
174
|
+
const path = req.path.toLowerCase();
|
|
175
|
+
const originEnforced = lowerEnforcePrefixes.some((p) => path === p || path.startsWith(`${p}/`));
|
|
176
|
+
const rejection = validateOriginHost(req.headers.host, originEnforced && typeof req.headers.origin === "string" ? req.headers.origin : undefined, policy);
|
|
177
|
+
if (rejection === null) {
|
|
178
|
+
next();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const key = `${rejection.header}:${rejection.value}`;
|
|
182
|
+
if (!warned.has(key) && warned.size < 100) {
|
|
183
|
+
warned.add(key);
|
|
184
|
+
logger.warn("origin_host_rejected", {
|
|
185
|
+
header: rejection.header,
|
|
186
|
+
value: rejection.value,
|
|
187
|
+
path: req.path,
|
|
188
|
+
hint: rejection.header === "origin"
|
|
189
|
+
? "Add the page origin with `ggui serve --browser-origin <origin>` (or GGUI_BROWSER_ORIGINS)."
|
|
190
|
+
: "Request arrived with an unexpected Host header — DNS-rebinding defense. Set --public-base-url if this is a legitimate proxy/tunnel host.",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
res.status(403).json({
|
|
194
|
+
jsonrpc: "2.0",
|
|
195
|
+
error: { code: -32000, message: rejectionMessage(rejection) },
|
|
196
|
+
id: null,
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { HandlerContext } from "@ggui-ai/mcp-server-handlers";
|
|
2
|
+
/** The two row fields the read gate consults. */
|
|
3
|
+
export interface RenderReadRowView {
|
|
4
|
+
readonly appId: string;
|
|
5
|
+
/**
|
|
6
|
+
* The row's SUBJECT — the end user a render belongs to, written at
|
|
7
|
+
* commit and nowhere else.
|
|
8
|
+
*
|
|
9
|
+
* `userId` rather than the richer `endUserIdentity` block, and that
|
|
10
|
+
* is the whole of #446. The gate used to read `endUserIdentity`,
|
|
11
|
+
* which no writer has populated since the repo split deleted the one
|
|
12
|
+
* that did (`f99b81c28`) — so the subject rung has been reading a
|
|
13
|
+
* field that is always absent, and passing every caller through as
|
|
14
|
+
* "row has no subject". Reading `userId` binds the rung for the
|
|
15
|
+
* first time.
|
|
16
|
+
*
|
|
17
|
+
* Absent stays a legitimate state: builder and anonymous
|
|
18
|
+
* single-tenant flows mint rows with no subject, and those still
|
|
19
|
+
* pass rung 4.
|
|
20
|
+
*/
|
|
21
|
+
readonly userId?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Per-session render-resource read gate (rehydration access control).
|
|
25
|
+
*
|
|
26
|
+
* Rungs, in order (spec §3):
|
|
27
|
+
* 1. Fail closed without a request context.
|
|
28
|
+
* 2. App boundary — the row's appId must equal the caller's.
|
|
29
|
+
* 3. Subject binding — a caller carrying an end-user identity (kind
|
|
30
|
+
* 'user', any auth source) reading a subject-bound row must BE
|
|
31
|
+
* that subject. The row's subject is its `userId`, written at
|
|
32
|
+
* commit; see {@link RenderReadRowView.userId} for why this rung
|
|
33
|
+
* only starts binding now.
|
|
34
|
+
* 4. Everything else same-app passes: app credentials (tenant trust —
|
|
35
|
+
* the app is obligated to enforce its own user-ownership before
|
|
36
|
+
* fetching on a user's behalf), builder/anonymous single-tenant
|
|
37
|
+
* flows, and rows with no subject.
|
|
38
|
+
*
|
|
39
|
+
* Deny is surfaced by the CALLER byte-identically to a missing row —
|
|
40
|
+
* reads must not oracle which sessionIds exist. This function only
|
|
41
|
+
* returns the boolean; making the two indistinguishable is the caller's
|
|
42
|
+
* obligation, and it is a real one, because "refused" and "never
|
|
43
|
+
* existed" travel completely different code paths to get there. The
|
|
44
|
+
* resource handler discharges it by collapsing a refusal to "absent"
|
|
45
|
+
* and letting every downstream branch run as it would for a locator
|
|
46
|
+
* that never existed, so both arrive at the same typed failure with the
|
|
47
|
+
* same bytes.
|
|
48
|
+
*/
|
|
49
|
+
export declare function renderReadAllowed(row: RenderReadRowView, ctx: HandlerContext | undefined): boolean;
|
|
50
|
+
//# sourceMappingURL=render-read-gate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render-read-gate.d.ts","sourceRoot":"","sources":["../src/render-read-gate.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAEnE,iDAAiD;AACjD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,iBAAiB,EACtB,GAAG,EAAE,cAAc,GAAG,SAAS,GAC9B,OAAO,CAOT"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session render-resource read gate (rehydration access control).
|
|
3
|
+
*
|
|
4
|
+
* Rungs, in order (spec §3):
|
|
5
|
+
* 1. Fail closed without a request context.
|
|
6
|
+
* 2. App boundary — the row's appId must equal the caller's.
|
|
7
|
+
* 3. Subject binding — a caller carrying an end-user identity (kind
|
|
8
|
+
* 'user', any auth source) reading a subject-bound row must BE
|
|
9
|
+
* that subject. The row's subject is its `userId`, written at
|
|
10
|
+
* commit; see {@link RenderReadRowView.userId} for why this rung
|
|
11
|
+
* only starts binding now.
|
|
12
|
+
* 4. Everything else same-app passes: app credentials (tenant trust —
|
|
13
|
+
* the app is obligated to enforce its own user-ownership before
|
|
14
|
+
* fetching on a user's behalf), builder/anonymous single-tenant
|
|
15
|
+
* flows, and rows with no subject.
|
|
16
|
+
*
|
|
17
|
+
* Deny is surfaced by the CALLER byte-identically to a missing row —
|
|
18
|
+
* reads must not oracle which sessionIds exist. This function only
|
|
19
|
+
* returns the boolean; making the two indistinguishable is the caller's
|
|
20
|
+
* obligation, and it is a real one, because "refused" and "never
|
|
21
|
+
* existed" travel completely different code paths to get there. The
|
|
22
|
+
* resource handler discharges it by collapsing a refusal to "absent"
|
|
23
|
+
* and letting every downstream branch run as it would for a locator
|
|
24
|
+
* that never existed, so both arrive at the same typed failure with the
|
|
25
|
+
* same bytes.
|
|
26
|
+
*/
|
|
27
|
+
export function renderReadAllowed(row, ctx) {
|
|
28
|
+
if (ctx === undefined)
|
|
29
|
+
return false;
|
|
30
|
+
if (ctx.appId !== row.appId)
|
|
31
|
+
return false;
|
|
32
|
+
if (row.userId !== undefined && ctx.userId !== undefined) {
|
|
33
|
+
return ctx.userId === row.userId;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
@@ -32,6 +32,21 @@ interface MountOptions {
|
|
|
32
32
|
readonly runtimeBundleFile: string;
|
|
33
33
|
/** Structured logger for the missing-bundle boot warning. */
|
|
34
34
|
readonly logger: Logger;
|
|
35
|
+
/**
|
|
36
|
+
* Content-hashed twin of the mount — the LONG-CACHE production
|
|
37
|
+
* route. When present, `GET <path>` serves `source` with
|
|
38
|
+
* `Cache-Control: public, max-age=31536000, immutable`: the path
|
|
39
|
+
* embeds a hash of these exact bytes, so the response can never go
|
|
40
|
+
* stale under its own name, and a rebuild rolls clients over by
|
|
41
|
+
* changing the STAMPED URL rather than the cached content. Serving
|
|
42
|
+
* the captured bytes (not the file) is load-bearing for that
|
|
43
|
+
* guarantee — an in-place rebuild must not change what the old
|
|
44
|
+
* hash-name returns.
|
|
45
|
+
*/
|
|
46
|
+
readonly hashed?: {
|
|
47
|
+
readonly path: string;
|
|
48
|
+
readonly source: Buffer;
|
|
49
|
+
};
|
|
35
50
|
}
|
|
36
51
|
/**
|
|
37
52
|
* Mount `GET <runtimePath>` onto the express app. Returns nothing —
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime-bundle-route.d.ts","sourceRoot":"","sources":["../src/runtime-bundle-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,UAAU,YAAY;IACpB,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,oDAAoD;IACpD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,sDAAsD;IACtD,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"runtime-bundle-route.d.ts","sourceRoot":"","sources":["../src/runtime-bundle-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,UAAU,YAAY;IACpB,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,oDAAoD;IACpD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,sDAAsD;IACtD,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE;QAChB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAiEhE"}
|
|
@@ -29,13 +29,29 @@ import { existsSync } from "node:fs";
|
|
|
29
29
|
*/
|
|
30
30
|
export function mountRuntimeBundleRoute(opts) {
|
|
31
31
|
const { app, runtimePath, runtimeBundleFile, logger } = opts;
|
|
32
|
+
// Content-hashed long-cache route (#472) — registered ahead of the
|
|
33
|
+
// plain path so the two never shadow each other regardless of how a
|
|
34
|
+
// custom `runtimePath` glob might overlap.
|
|
35
|
+
if (opts.hashed !== undefined) {
|
|
36
|
+
const { path: hashedPath, source } = opts.hashed;
|
|
37
|
+
app.get(hashedPath, (_req, res) => {
|
|
38
|
+
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
|
39
|
+
// Immutable: the path names these exact bytes (see MountOptions
|
|
40
|
+
// .hashed). One download per client, zero revalidations; deploys
|
|
41
|
+
// roll over via a new stamped URL.
|
|
42
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
43
|
+
// Same CORS rationale as the plain route below.
|
|
44
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
45
|
+
res.send(source);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
32
48
|
if (existsSync(runtimeBundleFile)) {
|
|
33
49
|
app.get(runtimePath, (_req, res) => {
|
|
34
50
|
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
|
35
|
-
// Short cache — operators iterating on the renderer want
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
51
|
+
// Short cache — operators iterating on the renderer want fresh
|
|
52
|
+
// copies after rebuild. Production long-term caching lives on
|
|
53
|
+
// the hashed twin above; this name's content changes in place,
|
|
54
|
+
// so it must stay revalidated.
|
|
39
55
|
res.setHeader("Cache-Control", "no-cache");
|
|
40
56
|
// CORS: the bundle MUST be loadable from `<script type="module"
|
|
41
57
|
// src=...>` inside a sandboxed `srcdoc` iframe (the
|