Skip to content

Commit 9fab60a

Browse files
authored
feat(server): replace simple CSRF protection with GET method CSRF protection (#1846)
Adds `GetMethodCsrfProtectionHandlerPlugin`, a zero-config safeguard that makes enabling `GET` on handlers safe with `SameSite=Lax` cookie authentication, and removes `SimpleCsrfProtectionHandlerPlugin` entirely. The new plugin rejects `GET` requests arriving as cross-site or browser-initiated top-level navigations, the only context where another site can make a browser attach explicitly marked `SameSite=Lax` cookies to a safe-method request. Cookie-less cross-site requests keep working, so public APIs need no origin allowlist. ## Behavior - `GET` requests get a `403` before routing when `Sec-Fetch-Site` reports `cross-site` or `none` on a top-level navigation (`Sec-Fetch-Mode: navigate` targeting `Sec-Fetch-Dest: document`); a request stripped of either header is rejected rather than passed. - Only `GET` is guarded: navigations can use no method besides `GET` and the unsafe `POST` per the HTML spec, so `HEAD`, `QUERY`, and every other method pass untouched. - Same-origin and same-site requests always pass: the site is the `SameSite` trust boundary, so sibling-subdomain fetches and dev setups need no configuration. - Cross-site `fetch`, `<img>`, `<iframe>`, and other non-top-level contexts pass, since browsers never attach `Lax` cookies to them. - Non-browser clients and plain-HTTP deployments pass unchanged (no Fetch Metadata), and batch requests are judged before splitting, so forged sub-request headers cannot overturn the verdict. ## Breaking changes - `SimpleCsrfProtectionHandlerPlugin` no longer exists; imports fail at compile time. The replacement for cookie-based apps is `GetMethodCsrfProtectionHandlerPlugin` with cookies explicitly marked `SameSite=Lax` or `Strict`; apps that relied on rejecting all cross-site traffic need their own safeguard, such as a synchronizer token. - The v1 migration guide now maps the old CSRF plugin pair to the new plugin. ## Docs - New page documents which request contexts send `SameSite=Lax` cookies (links, address bar, email links, `<img>`, `<iframe>`, `fetch`, forms) and the resulting verdicts, plus cookie requirements: only Chrome defaults unmarked cookies to `Lax`, so the attribute must be set explicitly. - The RPC handler page's "Enabling the GET Method" section now recommends the new plugin. ## Testing - Tests cover every navigation vector, cookie-less context, ignored methods, stripped and repeated headers, and batch forgery, with 100% statement and branch coverage on the plugin. - Full suite green: root vitest, `pnpm type:check`, and `pnpm docs:validate` (JSDoc backlinks + strict link check) all pass.
1 parent 18576d4 commit 9fab60a

10 files changed

Lines changed: 393 additions & 619 deletions

apps/content/docs/migrations/from-v1.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -517,12 +517,12 @@ v1 shipped `StrictGetMethodPlugin` enabled by default. v2 removed that plugin (a
517517
<CodeGroup>
518518

519519
```ts v2
520-
import { SimpleCsrfProtectionHandlerPlugin } from '@orpc/server/plugins'
520+
import { GetMethodCsrfProtectionHandlerPlugin } from '@orpc/server/plugins'
521521
import { RPC_DEFAULT_ALLOW_METHODS } from '@orpc/server/standard'
522522

523523
const handler = new RPCHandler(router, {
524524
allowMethods: ['GET', ...RPC_DEFAULT_ALLOW_METHODS],
525-
plugins: [new SimpleCsrfProtectionHandlerPlugin()],
525+
plugins: [new GetMethodCsrfProtectionHandlerPlugin()],
526526
})
527527
```
528528

@@ -538,7 +538,7 @@ const handler = new RPCHandler(router)
538538
The simplest migration is to stop sending GET instead of allowing it: remove the `method` option from your [RPC Link](/docs/rpc/link) so every call uses POST (the default), and keep the handler's default `allowMethods`. Only allow GET when you really need it, for example for HTTP caching.
539539
:::
540540

541-
The v2 [Simple CSRF Protection Plugin](/docs/plugins/simple-csrf-protection) checks the browser's `Sec-Fetch-*` headers, so it no longer needs a matching link plugin. Remove `SimpleCsrfProtectionLinkPlugin` from your client; it no longer exists. If your client runs on a different site than your API, list its origin in the plugin's `origin` option.
541+
The v2 [GET Method CSRF Protection Plugin](/docs/plugins/get-method-csrf-protection) replaces the v1 CSRF plugin pair. It checks the browser's `Sec-Fetch-*` headers, so it needs no matching link plugin and no configuration. Remove `SimpleCsrfProtectionPlugin` from your handler and `SimpleCsrfProtectionLinkPlugin` from your client; they no longer exist.
542542

543543
### Interceptor options renamed
544544

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
title: "GET Method CSRF Protection Plugin"
3+
description: "Use GetMethodCsrfProtectionHandlerPlugin to make the safe GET method as secure as POST by rejecting navigations that may carry SameSite=Lax cookies from another site."
4+
sidebar:
5+
label: "GET Method CSRF Protection"
6+
---
7+
8+
## How It Works
9+
10+
Cross-site, browsers withhold explicitly marked [`SameSite=Lax`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#samesitesamesite-value) cookies from unsafe methods such as `POST`, but still attach them to [safe methods](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP) like `GET` on top-level navigations, per [RFC 6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/). The plugin closes that gap by rejecting exactly those navigations with a `403` before routing:
11+
12+
| Request from another site | Sends `SameSite=Lax` cookies | |
13+
| --- | --- | --- |
14+
| link click, redirect, `window.open`, GET form | yes | rejected |
15+
| address bar, bookmark, link from an email or native app | yes | rejected |
16+
| `fetch`, `XMLHttpRequest` | no | allowed, CORS governs the response |
17+
| `<img>`, `<script>`, media, prefetch | no | allowed |
18+
| `<iframe>`, `<embed>`, `<object>`, which are not top-level | no | allowed |
19+
| any method other than `GET` | no | ignored |
20+
21+
The verdict comes from [Fetch Metadata](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers#fetch_metadata_request_headers): a `GET` request is rejected when [`Sec-Fetch-Site`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site) reports `cross-site` or `none` on a top-level navigation (`Sec-Fetch-Mode: navigate` targeting `Sec-Fetch-Dest: document`). A guarded request stripped of either header is rejected. Only `GET` needs guarding, since [navigations use no method](https://html.spec.whatwg.org/multipage/browsing-the-web.html) besides `GET` and the unsafe `POST`. The result resembles upgrading your cookies to `SameSite=Strict` for safe methods, and also covers links opened from outside the browser, where browsers attach even `Strict` cookies.
22+
23+
Cookie-less cross-site requests pass, which makes the plugin the natural safeguard for [enabling the `GET` method](/docs/rpc/handler#enabling-the-get-method). Requests from your own site always pass, including sibling subdomains, since the `SameSite` cookie model makes the site the trust boundary. Host untrusted content on a separate site, not a subdomain.
24+
25+
## Setup
26+
27+
```ts twoslash
28+
import { RPCHandler } from '@orpc/server/fetch'
29+
import { router } from './shared/planet'
30+
// ---cut---
31+
import { GetMethodCsrfProtectionHandlerPlugin } from '@orpc/server/plugins'
32+
import { RPC_DEFAULT_ALLOW_METHODS } from '@orpc/server/standard'
33+
34+
const handler = new RPCHandler(router, {
35+
allowMethods: ['GET', ...RPC_DEFAULT_ALLOW_METHODS],
36+
plugins: [
37+
new GetMethodCsrfProtectionHandlerPlugin(),
38+
],
39+
})
40+
```
41+
42+
:::info
43+
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one.
44+
:::
45+
46+
## Cookie Requirements
47+
48+
Mark authentication cookies `SameSite=Lax` or `SameSite=Strict` explicitly. Do not rely on browser defaults: only Chrome treats unmarked cookies as `Lax`, while Firefox and Safari treat them like `SameSite=None`, and Chrome still sends fresh unmarked cookies on cross-site `POST` for [two minutes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#lax).
49+
50+
With `SameSite=None` or unmarked cookies, cross-site requests the plugin allows, such as `fetch` and `<img>`, can still carry them. In that case, fix the cookie attribute or add a [synchronizer token](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern).
51+
52+
## Limitations
53+
54+
- Fetch Metadata is [Baseline widely available](https://developer.mozilla.org/en-US/docs/Glossary/Baseline/Compatibility), supported by every major browser since Safari 16.4 in March 2023. Older browsers and header-stripping proxies pass through unchecked.
55+
- Browsers [send Fetch Metadata only to trustworthy URLs](https://w3c.github.io/webappsec-fetch-metadata/): HTTPS and `localhost`. Over plain HTTP the headers are absent while cookies are not, so every request passes, and `localhost` qualifying hides this in development.
56+
- Users cannot open guarded procedures by typing the URL, following a bookmark, or clicking a link, since those navigations look identical to a forged one. Test with `curl` or a same-origin page instead.
57+
- Prefer `SameSite=Strict` or a [synchronizer token](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern) for high-value requests.
58+
59+
## Learn More
60+
61+
Learn more about the attack this plugin prevents on [MDN](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF) and in the [OWASP CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html). For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/get-method-csrf-protection.ts).

apps/content/docs/plugins/simple-csrf-protection.mdx

Lines changed: 0 additions & 97 deletions
This file was deleted.

apps/content/docs/rpc/handler.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,12 @@ const handler = new RPCHandler(router, {
8181
### Enabling the GET Method
8282

8383
:::danger[Dangerous with cookie-based authentication]
84-
Enabling `GET` is dangerous when your application stores tokens in cookies with `SameSite=Lax` (the browser default) or `SameSite=None`. These cookies **are still sent on cross-site top-level navigations**, so an attacker only needs a signed-in user to click a link like `https://example.com/rpc/planet/delete?data=...` and the procedure runs with the victim's cookies. No JavaScript, no CORS bypass.
84+
Enabling `GET` is dangerous when your application stores tokens in cookies not marked `SameSite=Strict`. These cookies **are still sent on cross-site top-level navigations**, so an attacker only needs a signed-in user to click a link like `https://example.com/rpc/planet/delete?data=...` and the procedure runs with the victim's cookies. No JavaScript, no CORS bypass.
8585

8686
To enable `GET` safely, do one of the following:
8787

8888
- Set authentication cookies to `SameSite=Strict`, which browsers never send cross-site
89-
- Use an independent protection, such as the [Simple CSRF Protection Plugin](/docs/plugins/simple-csrf-protection)
89+
- Use an independent protection, such as the [GET Method CSRF Protection Plugin](/docs/plugins/get-method-csrf-protection)
9090
- Allow `GET` only for safe procedures that never modify data, as shown below
9191

9292
Learn more about this attack on [MDN](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF) and in the [OWASP CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html).
@@ -95,18 +95,18 @@ Learn more about this attack on [MDN](https://developer.mozilla.org/en-US/docs/W
9595
Add `GET` to `allowMethods` to enable it for every procedure:
9696

9797
```ts
98-
import { SimpleCsrfProtectionHandlerPlugin } from '@orpc/server/plugins'
98+
import { GetMethodCsrfProtectionHandlerPlugin } from '@orpc/server/plugins'
9999
import { RPC_DEFAULT_ALLOW_METHODS } from '@orpc/server/standard'
100100

101101
const handler = new RPCHandler(router, {
102102
allowMethods: ['GET', ...RPC_DEFAULT_ALLOW_METHODS],
103103
plugins: [
104-
new SimpleCsrfProtectionHandlerPlugin(), // reject requests initiated by another site
104+
new GetMethodCsrfProtectionHandlerPlugin(), // reject GET navigations that may carry SameSite=Lax cookies
105105
],
106106
})
107107
```
108108

109-
The plugin rejects any request the browser reports as initiated elsewhere. List the origins of clients on other sites in its `origin` option.
109+
The plugin rejects the navigations another site can use to send `SameSite=Lax` cookies, such as link clicks and redirects, while cookie-less requests such as `fetch` keep working. See the [GET Method CSRF Protection Plugin](/docs/plugins/get-method-csrf-protection) page for its cookie requirements.
110110

111111
Or pass a function to decide per request. For example, only allow `GET` for procedures that declare it via [OpenAPI metadata](/docs/openapi/routing):
112112

0 commit comments

Comments
 (0)