mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(http): exempt only a Bearer request without a session cookie from the cross-site check
The cross-site write check let through any request that carried an Authorization header, while the caller is resolved from the session cookie before any header. A browser attaches Basic or Negotiate credentials on its own once a proxy or host asked for them, so a cookie-authenticated write from a sibling subdomain could pass. Only Authorization: Bearer is exempt now, and only when no session cookie rides along; a request carrying both is checked as the cookie request it resolves to. The share-link passphrase POST under /api/c/ is exempt by path: its credential is the token in the path and the passphrase in the body, and from the shared view's no-referrer page a browser without Sec-Fetch-Site sends Origin: null, which was refused.
This commit is contained in:
1 parent
5a875d2840
commit
0b830e6b02
2 files changed
+83
-7
No files matched your search
@@ -114,6 +114,65 @@ describe("apiHandler and a state change from another site", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a browser-attached Basic or Negotiate header riding a session cookie", async () => {
|
||||
// The browser adds these on its own once a proxy or the host asked for
|
||||
// them, and the caller is resolved from the cookie first: the header is
|
||||
// not what the request is authenticated by.
|
||||
for (const authorization of ["Basic dXNlcjpwYXNz", "Negotiate YIIGhg"]) {
|
||||
const response = await post({
|
||||
"content-type": "application/json",
|
||||
authorization,
|
||||
"sec-fetch-site": "same-site",
|
||||
origin: "https://evil.example.com",
|
||||
cookie: "healthlog_session=s",
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks a Bearer request that also carries a session cookie as the cookie request it resolves to", async () => {
|
||||
for (const cookie of [
|
||||
"healthlog_session=s",
|
||||
"theme=dark; __Host-healthlog_session=s",
|
||||
]) {
|
||||
const response = await post({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer hlk_x",
|
||||
"sec-fetch-site": "cross-site",
|
||||
cookie,
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
// From the app's own page the same pair passes.
|
||||
const own = await post({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer hlk_x",
|
||||
"sec-fetch-site": "same-origin",
|
||||
cookie: "healthlog_session=s",
|
||||
});
|
||||
expect(own.status).toBe(200);
|
||||
});
|
||||
|
||||
it("lets the share-link passphrase POST through from a no-referrer page", async () => {
|
||||
// The shared view is served with `Referrer-Policy: no-referrer`; a
|
||||
// browser without Sec-Fetch-Site then sends `Origin: null`. The token in
|
||||
// the path and the passphrase in the body are the credential.
|
||||
const response = await route(
|
||||
new NextRequest(`${ORIGIN}/api/c/hls_token/unlock`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "health.example.com",
|
||||
"content-type": "application/json",
|
||||
origin: "null",
|
||||
},
|
||||
body: '{"value":1}',
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("leaves reads alone", async () => {
|
||||
const response = await route(
|
||||
new NextRequest(`${ORIGIN}/api/measurements`, {
|
||||
|
||||
@@ -25,17 +25,28 @@
|
||||
* - A request with neither header is not from a browser page (the phone
|
||||
* app, a script, a webhook sender) and passes; it carries no cookie a
|
||||
* page could have lent it.
|
||||
* - A request carrying `Authorization` passes: a page cannot attach that
|
||||
* header to another origin without a CORS preflight, which this server
|
||||
* never answers.
|
||||
* - A request carrying `Authorization: Bearer` and no session cookie
|
||||
* passes: a page cannot attach that header to another origin without a
|
||||
* CORS preflight, which this server never answers, and without a session
|
||||
* cookie the token is what the request is authenticated by. Any other
|
||||
* scheme does not count: a browser attaches `Basic` or `Negotiate`
|
||||
* credentials on its own when a proxy or the host asked for them, and
|
||||
* the caller is resolved from the session cookie before any header
|
||||
* (`src/lib/auth/guards/caller.ts`), so a request with both is a cookie
|
||||
* request and is checked as one.
|
||||
*
|
||||
* Exempt by path: the surfaces that authenticate with a secret in a header
|
||||
* or the URL rather than the session cookie, and that a third party's client
|
||||
* may legitimately call from a browser (a push client's action button). The
|
||||
* MCP OAuth endpoints, which must accept cross-origin requests by their
|
||||
* specification, do not run through `apiHandler` at all.
|
||||
* may legitimately call from a browser (a push client's action button), and
|
||||
* the share-link passphrase check under `/api/c/`, whose credential is the
|
||||
* token in the path plus the passphrase in the body. A shared-record page is
|
||||
* served with `Referrer-Policy: no-referrer`, and a browser without
|
||||
* `Sec-Fetch-Site` then sends `Origin: null` on its POST, which no host
|
||||
* check can admit. The MCP OAuth endpoints, which must accept cross-origin
|
||||
* requests by their specification, do not run through `apiHandler` at all.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { cookieHeaderCarriesSession } from "@/lib/auth/session-cookie";
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
@@ -51,6 +62,7 @@ export const CROSS_SITE_EXEMPT_PREFIXES = [
|
||||
"/api/internal/deploy-webhook",
|
||||
"/api/notifications/action/",
|
||||
"/api/monitoring/csp-report",
|
||||
"/api/c/",
|
||||
] as const;
|
||||
|
||||
function hostOf(value: string | null | undefined): string | null {
|
||||
@@ -92,7 +104,12 @@ export function crossSiteWriteReason(
|
||||
if (CROSS_SITE_EXEMPT_PREFIXES.some((p) => pathname.startsWith(p))) {
|
||||
return null;
|
||||
}
|
||||
if (request.headers.has("authorization")) return null;
|
||||
if (
|
||||
request.headers.get("authorization")?.startsWith("Bearer ") &&
|
||||
!cookieHeaderCarriesSession(request.headers.get("cookie"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const site = request.headers.get("sec-fetch-site")?.trim().toLowerCase();
|
||||
if (site) {
|
||||
|
||||
Reference in new issue
Block a user