Repository navigation
Expand file tree
/
Copy pathorigin-check.ts
More file actions
165 lines (150 loc) · 8.24 KB
/
Copy pathorigin-check.ts
File metadata and controls
165 lines (150 loc) · 8.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import { logger } from "@/lib/logger";
import { readTrustProxyHeaders } from "@/lib/security/config";
/**
* Origin check: a second CSRF layer for state-changing requests.
*
* The comparison is HOST-ONLY and ignores the scheme. That is deliberate and it is what removes
* the single largest false-positive class: a TLS-terminating proxy that forwards plain HTTP
* without setting x-forwarded-proto makes the browser send Origin: https://db.example.com while
* the app computes http://db.example.com, and comparing schemes there locks the operator out of
* their own login form. The security given up is an http:// page on the same host posting to the
* https:// app, which requires an active network attacker who has already broken transport - a
* smaller threat than the deployment class this protects.
*
* GET, HEAD and OPTIONS are exempt by method. That is what lets GET /api/auth/oidc/callback work:
* it is a legitimate cross-origin top-level navigation from the identity provider, and browsers
* may omit Origin on it entirely. Method-based exemption covers it without a path special case.
*
* A request that carries NEITHER Origin NOR Referer is accepted, but only when its Content-Type is
* application/json. That is not an off switch smuggled in under another name; it is the one shape
* a cross-site browser cannot produce against a victim's session, for two independent reasons:
*
* - An HTML <form>, the classic CSRF vector, can only submit as
* application/x-www-form-urlencoded, multipart/form-data or text/plain - `enctype` has no fourth
* value. It is structurally incapable of sending Content-Type: application/json, so this
* carve-out is invisible to a <form>-based attack no matter what the form's target does.
* - A cross-site fetch()/XHR CAN set that content type, but application/json is not one of the
* three CORS-safelisted values, so doing so turns the request "non-simple" and forces a
* preflight (OPTIONS) before the browser will send the real request with credentials. This
* deployment answers no request anywhere with an Access-Control-Allow-* header, so the preflight
* is never affirmatively answered and the browser never sends the follow-up carrying the
* victim's cookie. (If that ever changes, this reasoning must be re-checked against whatever
* route added it.)
*
* What remains is a caller that sets its own Content-Type deliberately outside a browser
* altogether - curl, a server-to-server integration, this project's own documented API examples
* (scripts/engine-smoke.sh, docs/RANCHER.md, docs/API_DOCS.md all POST JSON with neither Origin
* nor Referer). That is not a CSRF vector: CSRF is specifically an unwitting BROWSER carrying
* credentials it did not choose to send. A script that sets its own headers chose to send them.
*
* A header that IS present but malformed (e.g. "Origin: /") does NOT qualify for this carve-out -
* only true absence of both headers does. See the `observedOrigin === ""` guard below.
*/
const STATE_CHANGING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
export interface OriginCheckResult {
allowed: boolean;
/** The Origin, or the Referer when there was no Origin. Empty when the request carried neither. */
observedOrigin: string;
/** The host this deployment computed for itself. Empty when no Host header arrived. */
expectedHost: string;
}
/** "https://host:port/path" and a bare "host:port" both reduce to "host:port". */
function hostOf(value: string): string | null {
const withoutScheme = value.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
const host = withoutScheme.split("/")[0].trim().toLowerCase();
return host.length > 0 ? host : null;
}
/**
* True only for a bare "application/json" media type, ignoring parameters (charset, ...) and case,
* per RFC 9110's media-type grammar: `type "/" subtype *( OWS ";" OWS parameter )`.
*/
function isJsonContentType(value: string | null): boolean {
if (!value) return false;
const mediaType = value.split(";")[0]?.trim().toLowerCase();
return mediaType === "application/json";
}
function selfHost(headers: Headers): string {
// Gated behind the same trust decision client-address.ts makes for the identical header, for the
// identical reason: x-forwarded-host is attacker-supplied unless a reverse proxy is known to set
// it and overwrite any client-supplied copy. An operator who sets TRUST_PROXY_HEADERS=false to
// declare "no proxy in front of me" must have that decision honoured here too, not just there.
if (readTrustProxyHeaders()) {
const forwarded = headers.get("x-forwarded-host")?.split(",")[0]?.trim().toLowerCase();
if (forwarded) return forwarded;
}
return headers.get("host")?.trim().toLowerCase() ?? "";
}
let warnedAboutWildcardOrigin = false;
/** Test seam: clears the warn-once latch so each case observes a fresh process. */
export function resetOriginCheckWarnings(): void {
warnedAboutWildcardOrigin = false;
}
/**
* Extra hosts the operator vouched for, as origins or bare hosts. A literal "*" is ignored rather
* than trusted: this is a host allowlist compared exactly, never a CORS-style wildcard, and an
* operator who copies the CORS convention here would otherwise get silent, unexplained trust of
* nothing (no real Origin header is ever literally "*") instead of the explicit list they meant to
* write. Warn once so the mistake is visible instead of a self-hoster wondering why it "did
* nothing".
*/
function configuredHosts(): string[] {
const raw = process.env.ALLOWED_ORIGINS;
if (!raw) return [];
const hosts: string[] = [];
for (const entry of raw.split(",")) {
const host = hostOf(entry);
if (!host) continue;
if (host === "*") {
if (!warnedAboutWildcardOrigin) {
warnedAboutWildcardOrigin = true;
logger.warn(
'ALLOWED_ORIGINS contains "*", which is ignored: this is a host allowlist compared exactly, ' +
"not a CORS wildcard. List the exact host(s) that must be trusted instead.",
{ route: "security" },
);
}
continue;
}
hosts.push(host);
}
return hosts;
}
/**
* The host names of ALLOWED_ORIGINS, without ports and with IPv6 bracketed: the shape the MCP
* SDK's Origin and Host allowlists compare (#246). Built on configuredHosts() so this file stays
* the only parser of the variable and its "*" warning. An entry that does not parse as a host can
* match no Origin or Host the SDK parses, so it names nothing here.
*/
export function configuredOriginHostnames(): string[] {
const hostnames: string[] = [];
for (const host of configuredHosts()) {
const candidate = `http://${host}`;
if (URL.canParse(candidate)) hostnames.push(new URL(candidate).hostname);
}
return hostnames;
}
export function checkOrigin(request: { method: string; headers: Headers }): OriginCheckResult {
const expectedHost = selfHost(request.headers);
if (!STATE_CHANGING.has(request.method.toUpperCase())) {
return { allowed: true, observedOrigin: "", expectedHost };
}
// Referer is the fallback, which is why Referrer-Policy is same-origin rather than no-referrer:
// no-referrer would suppress exactly this signal.
const observedOrigin = request.headers.get("origin") ?? request.headers.get("referer") ?? "";
const observedHost = observedOrigin ? hostOf(observedOrigin) : null;
if (!observedHost) {
// True absence (neither header sent at all) is eligible for the JSON carve-out documented
// above. A header that arrived but did not resolve to a host (observedOrigin non-empty, e.g.
// "Origin: /") is NOT: it is evidence of a header a browser did send, just a malformed one, and
// there is no legitimate reason to treat that more kindly than an outright cross-site mismatch.
if (observedOrigin === "" && isJsonContentType(request.headers.get("content-type"))) {
return { allowed: true, observedOrigin, expectedHost };
}
// A non-browser caller that is not sending JSON must send Origin: <public origin> instead.
// There is no other off switch for this: an off switch for a CSRF control is the knob that
// gets set in a template and never unset.
return { allowed: false, observedOrigin, expectedHost };
}
const allowed = observedHost === expectedHost || configuredHosts().includes(observedHost);
return { allowed, observedOrigin, expectedHost };
}