@aiquants/request-isolation
v0.1.0
Published
Host-agnostic cross-origin check for server routes: one decision over Fetch Metadata and Origin, taken before authentication, that refuses requests started by a page of another origin (sibling subdomains included) unless the route serves that top-level do
Readme
@aiquants/request-isolation
Host-agnostic cross-origin check for server routes. One function, crossOriginRefusal, decides from Fetch Metadata (Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest) and Origin whether a request was started by a page of another origin, so a route can refuse it before authentication, rate charges, authorization or any other work.
- Refuses every request from another origin, a sibling subdomain of the same site included, whatever its method.
- Serves a top-level document navigation from another origin (a link to the route) only when the route says it does.
- Decides a request without Fetch Metadata by its method and, for a method that may change state, by its
Origin.
Zero runtime dependencies. Works anywhere WHATWG Request and URL.canParse exist (Node.js 18.17 or later, Deno, Bun, edge workers).
Contents
Why a request-side check
| What one might rely on | Why it does not stop a cross-origin request |
| --- | --- |
| SameSite=Lax session cookies | A sibling subdomain of the same site is the same site, so its requests carry the cookie |
| CORS | A form submission (application/x-www-form-urlencoded, multipart/form-data, text/plain) and a no-cors request need no preflight; the request runs and only the response is hidden |
| Response headers (Cross-Origin-Resource-Policy, X-Frame-Options, ...) | They decide whether a page may read or render the response, not whether the request runs |
| A framework's built-in CSRF check | Frameworks usually check document requests and their own data requests; resource routes (APIs, streams, downloads) pass through |
The check is therefore made on the request, in one place, first. It is written as an allow-list: a frame, an embedded object, a navigation without a destination and any destination a future specification adds are refused by construction, because only the cases below are served.
The rule
| Request | Answer |
| --- | --- |
| Sec-Fetch-Site: same-origin or none (a load the user started: address bar, bookmark) | Served, whatever the method |
| Sec-Fetch-Site with any other value (same-site, cross-site, a value the specification does not define), top-level document navigation (GET, Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document) that policy.navigable(request) answers with true | Served |
| Sec-Fetch-Site with any other value, anything else | Refused: "cross-origin-fetch" |
| No Sec-Fetch-Site, a safe method (GET, HEAD, OPTIONS) | Served |
| No Sec-Fetch-Site, a method that may change state, no Origin or an Origin naming the request's own host (with its port) | Served |
| No Sec-Fetch-Site, a method that may change state, any other Origin (another host or port, null, a value that is not a URL) | Refused: "foreign-origin" |
Without Fetch Metadata the rule is the one of Go 1.25's net/http CrossOriginProtection. With Fetch Metadata it is stricter: a request from another origin is refused even when its method is safe (a sibling page cannot open a stream, probe a download or load an image from the route), except the document navigations the route serves.
Install
pnpm add @aiquants/request-isolationIn a pnpm workspace, depend on it with "@aiquants/request-isolation": "workspace:^".
Usage
A route that no page of another origin may open (an API, an action, a stream):
import { crossOriginRefusal, NO_CROSS_ORIGIN_NAVIGATION } from "@aiquants/request-isolation"
export async function action({ request }: { request: Request }): Promise<Response> {
if (crossOriginRefusal(request, NO_CROSS_ORIGIN_NAVIGATION) !== undefined) {
return new Response(JSON.stringify({ error: "Cross-site request rejected" }), {
status: 403,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
})
}
// authenticate, charge, authorize, then do the work
}A route whose links may be opened from another origin (an original file shown in a new tab, say), and only for the requests it chooses:
import { type CrossOriginPolicy, crossOriginRefusal } from "@aiquants/request-isolation"
const ORIGINAL_FILE_LINKS: CrossOriginPolicy = {
navigable: (request) => new URL(request.url).searchParams.get("disposition") === "inline",
}
export async function loader({ request }: { request: Request }): Promise<Response> {
const refusal = crossOriginRefusal(request, ORIGINAL_FILE_LINKS)
if (refusal !== undefined) {
console.warn(`cross_origin_refused reason=${refusal} method=${request.method}`)
return new Response("Forbidden", { status: 403, headers: { "Cache-Control": "no-store" } })
}
// ...
}The refusal value never contains anything the request sent, so it can be logged as it is. Log header values the request sent only after bounding and quoting them.
API
crossOriginRefusal(request, policy)
function crossOriginRefusal(request: Request, policy: CrossOriginPolicy): CrossOriginRefusal | undefinedReturns why the request must be refused, or undefined when the route may serve it (see The rule). policy.navigable is called only for a GET document navigation from another origin, with the same Request, and the navigation is served only when it returns exactly true; any other answer refuses it.
Throws TypeError when policy is not an object whose navigable is a function. The policy is checked on every call, before the request is read, so a missing policy fails on the first request rather than on the first cross-origin navigation.
CrossOriginPolicy
type CrossOriginPolicy = { readonly navigable: (request: Request) => boolean }The route's answer to the one question the check asks it: does it serve this top-level document navigation from another origin?
NO_CROSS_ORIGIN_NAVIGATION
A frozen CrossOriginPolicy whose navigable always returns false: the policy of a route that no page of another origin may open.
CrossOriginRefusal
type CrossOriginRefusal = "cross-origin-fetch" | "foreign-origin"| Value | Meaning |
| --- | --- |
| "cross-origin-fetch" | Fetch Metadata names another origin and the request is not a document navigation the route serves |
| "foreign-origin" | No Fetch Metadata, a method that may change state, and an Origin that does not name the request's host |
Security notes
- Run it first. Call the check before authentication, session refresh, rate limiting, authorization and any service call, and answer a refusal without
Set-Cookie, so a refused request leaves no trace on the session or the quotas. - The request URL's host is the reference.
Originis compared with the host ofrequest.url, which the server adapter builds from theHostheader. Behind a reverse proxy, make sure the URL the route sees carries the host the browser addressed. - Requests without Fetch Metadata. Browsers send Fetch Metadata only to potentially trustworthy URLs (
https:, orhttp:to a loopback host). A request without it comes from a client that is not a browser, an older browser, or a page served over plain HTTP. Browsers always sendOriginwith a request whose method may change state (the value may benull), so its absence means no page started the request. - Method names are compared as sent. WHATWG
Requestupper-cases only the standard methods (get,post, ...); a non-standard spelling such aspatchstays as written and is treated as a method that may change state. - Sign-out is a different rule. A sign-out link followed by the user from a sibling subdomain should usually still sign out, and a speculative request (prefetch, prerender) should not. Keep a separate rule for sign-out; this check is for a route's data requests.
Out of scope
- Response headers, CORS, authentication, session handling and rate limiting.
- Framework data-request handling (for example refusing a framework's own data-request form of a streaming route); that is the route's policy.
- Logging and the 403 response; both stay with the route, which knows its response shape.
License
MIT
