@remix-run/csrf-middleware 0.0.0 → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Shopify Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Placeholder Package
1
+ # csrf-middleware
2
2
 
3
- This package is a placeholder published at `0.0.0` to reserve the npm name and configure CI publish permissions.
3
+ CSRF protection middleware for Remix. It provides synchronizer-token validation backed by session storage, plus origin checks for unsafe requests.
4
+
5
+ ## Features
6
+
7
+ - **Session-Backed Tokens** - Creates and persists CSRF tokens in the request session
8
+ - **Flexible Token Extraction** - Reads tokens from headers, form fields, query params, or a custom resolver
9
+ - **Origin Validation** - Validates `Origin`/`Referer` for unsafe methods with customizable policies
10
+ - **Configurable Enforcement** - Control safe methods, token keys, and failure responses
11
+
12
+ ## Installation
13
+
14
+ ```sh
15
+ npm i remix
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ This middleware requires [`session-middleware`](https://github.com/remix-run/remix/tree/main/packages/session-middleware) to run before it.
21
+
22
+ ```ts
23
+ import { createCookie } from 'remix/cookie'
24
+ import { createRouter } from 'remix/fetch-router'
25
+ import { createCookieSessionStorage } from 'remix/session/cookie-storage'
26
+ import { session } from 'remix/session-middleware'
27
+ import { csrf, getCsrfToken } from 'remix/csrf-middleware'
28
+
29
+ let sessionCookie = createCookie('__session', { secrets: ['secret1'] })
30
+ let sessionStorage = createCookieSessionStorage()
31
+
32
+ let router = createRouter({
33
+ middleware: [session(sessionCookie, sessionStorage), csrf()],
34
+ })
35
+
36
+ router.get('/form', (context) => {
37
+ let token = getCsrfToken(context)
38
+
39
+ return new Response(`
40
+ <form method="post" action="/submit">
41
+ <input type="hidden" name="_csrf" value="${token}" />
42
+ <button type="submit">Submit</button>
43
+ </form>
44
+ `)
45
+ })
46
+ ```
47
+
48
+ ## Token Sources
49
+
50
+ By default, `csrf()` checks token values in this order:
51
+
52
+ 1. Request headers: `x-csrf-token`, `x-xsrf-token`, `csrf-token`
53
+ 2. Form field: `_csrf` (requires `formData()` middleware to parse request bodies)
54
+ 3. Query param: `_csrf`
55
+
56
+ You can override extraction using `value(context)`.
57
+
58
+ Headers and form fields are the preferred transports. Query param fallback exists for compatibility, but it is the weakest option because tokens are more likely to be exposed in logs, history, and copied URLs.
59
+
60
+ ## Origin Validation
61
+
62
+ For unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`), the middleware validates request origin.
63
+
64
+ - Default: same-origin validation when `Origin` or `Referer` is present
65
+ - Custom: provide `origin` as string, regex, array, or function
66
+ - Missing origin behavior: controlled by `allowMissingOrigin` (default `true`)
67
+
68
+ ## Caveats
69
+
70
+ - The synchronizer token is the primary defense in `csrf()`. `Origin` and `Referer` checks are an additional signal, not the only protection.
71
+ - By default, unsafe requests with a valid token still pass when `Origin` and `Referer` are both missing. Set `allowMissingOrigin: false` if your deployment wants to require provenance headers on unsafe requests.
72
+ - Query param tokens are supported for compatibility, but they should not be the default recommendation. Prefer headers or hidden form fields when you control the client.
73
+ - If you want to reject more unsafe requests before token validation, especially when browser provenance headers are available, layer [`cop-middleware`](https://github.com/remix-run/remix/tree/main/packages/cop-middleware) in front of `csrf()`.
74
+
75
+ ## Why This Exists
76
+
77
+ Modern browsers now provide stronger cross-origin signals like `Sec-Fetch-Site`, and explicit
78
+ `SameSite=Lax` cookies already block many CSRF attacks. We have considered the lighter,
79
+ tokenless model used by Go's `CrossOriginProtection`, and we think it is a good fit when a
80
+ deployment can make all of the guarantees that model depends on.
81
+
82
+ Remix cannot assume those guarantees for every app. `csrf()` still exists as the conservative
83
+ option for apps that want synchronizer tokens in addition to origin checks, especially for
84
+ session-backed HTML form workflows and mixed deployment environments.
85
+
86
+ If your deployment can guarantee the prerequisites for the tokenless model, this middleware is
87
+ optional. In that case, [`cop-middleware`](https://github.com/remix-run/remix/tree/main/packages/cop-middleware)
88
+ may be a better fit.
89
+
90
+ ## Related Packages
91
+
92
+ - [`cop-middleware`](https://github.com/remix-run/remix/tree/main/packages/cop-middleware) - Middleware for tokenless cross-origin protection using browser provenance headers
93
+ - [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Router for the web Fetch API
94
+ - [`session-middleware`](https://github.com/remix-run/remix/tree/main/packages/session-middleware) - Session middleware required by `csrf()`
95
+ - [`form-data-middleware`](https://github.com/remix-run/remix/tree/main/packages/form-data-middleware) - Needed for form body token extraction
96
+
97
+ ## License
98
+
99
+ See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
@@ -0,0 +1,2 @@
1
+ export { csrf, getCsrfToken, type CsrfFailureReason, type CsrfOptions, type CsrfOrigin, type CsrfOriginResolver, type CsrfOriginResolverResult, type CsrfTokenResolver, type CsrfTokenResolverResult, } from './lib/csrf.ts';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,IAAI,EACJ,YAAY,EACZ,KAAK,iBAAiB,EACtB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,wBAAwB,EAC7B,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,GAC7B,MAAM,eAAe,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { csrf, getCsrfToken, } from "./lib/csrf.js";
@@ -0,0 +1,104 @@
1
+ import type { Middleware, RequestContext, RequestMethod } from '@remix-run/fetch-router';
2
+ type OriginMatcher = string | RegExp | ReadonlyArray<string | RegExp>;
3
+ /**
4
+ * Return shape for a dynamic CSRF origin resolver.
5
+ */
6
+ export type CsrfOriginResolverResult = boolean | null | undefined;
7
+ /**
8
+ * Resolves whether an unsafe cross-origin request should be allowed.
9
+ */
10
+ export interface CsrfOriginResolver {
11
+ /**
12
+ * Resolves whether an unsafe request origin should be trusted.
13
+ */
14
+ (origin: string, context: RequestContext): CsrfOriginResolverResult | Promise<CsrfOriginResolverResult>;
15
+ }
16
+ /**
17
+ * Accepted forms for configuring allowed CSRF origins.
18
+ */
19
+ export type CsrfOrigin = OriginMatcher | CsrfOriginResolver;
20
+ /**
21
+ * Return shape for a dynamic CSRF token resolver.
22
+ */
23
+ export type CsrfTokenResolverResult = string | null | undefined;
24
+ /**
25
+ * Resolves the submitted CSRF token for a request.
26
+ */
27
+ export interface CsrfTokenResolver {
28
+ /**
29
+ * Resolves the submitted CSRF token for the current request.
30
+ */
31
+ (context: RequestContext): CsrfTokenResolverResult | Promise<CsrfTokenResolverResult>;
32
+ }
33
+ /**
34
+ * The reason a CSRF request was rejected.
35
+ */
36
+ export type CsrfFailureReason = 'invalid-origin' | 'missing-token' | 'invalid-token';
37
+ /**
38
+ * Options for the CSRF middleware.
39
+ */
40
+ export interface CsrfOptions {
41
+ /**
42
+ * Session key used to store the server-generated CSRF token.
43
+ *
44
+ * @default '_csrf'
45
+ */
46
+ tokenKey?: string;
47
+ /**
48
+ * Form field name to read CSRF tokens from.
49
+ *
50
+ * @default '_csrf'
51
+ */
52
+ fieldName?: string;
53
+ /**
54
+ * Header names checked (in order) for CSRF tokens.
55
+ *
56
+ * @default ['x-csrf-token', 'x-xsrf-token', 'csrf-token']
57
+ */
58
+ headerNames?: readonly string[];
59
+ /**
60
+ * Methods that do not require CSRF validation.
61
+ *
62
+ * @default ['GET', 'HEAD', 'OPTIONS']
63
+ */
64
+ safeMethods?: readonly RequestMethod[];
65
+ /**
66
+ * Allowed cross-origin origins for unsafe requests.
67
+ *
68
+ * When omitted, requests are validated as same-origin.
69
+ */
70
+ origin?: CsrfOrigin;
71
+ /**
72
+ * Allow requests without Origin/Referer headers.
73
+ *
74
+ * @default true
75
+ */
76
+ allowMissingOrigin?: boolean;
77
+ /**
78
+ * Custom function for extracting the submitted token.
79
+ */
80
+ value?: CsrfTokenResolver;
81
+ /**
82
+ * Optional custom error response for rejected requests.
83
+ */
84
+ onError?: (reason: CsrfFailureReason, context: RequestContext) => Response | Promise<Response>;
85
+ }
86
+ /**
87
+ * Session-backed CSRF protection middleware.
88
+ *
89
+ * This middleware requires the session middleware to run before it.
90
+ *
91
+ * @param options CSRF options
92
+ * @returns CSRF middleware
93
+ */
94
+ export declare function csrf(options?: CsrfOptions): Middleware;
95
+ /**
96
+ * Gets the CSRF token from the session. Creates one if missing.
97
+ *
98
+ * @param context Request context with a started session
99
+ * @param tokenKey Session key that stores the token
100
+ * @returns The active CSRF token
101
+ */
102
+ export declare function getCsrfToken(context: RequestContext, tokenKey?: string): string;
103
+ export {};
104
+ //# sourceMappingURL=csrf.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf.d.ts","sourceRoot":"","sources":["../../src/lib/csrf.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAA;AAMxF,KAAK,aAAa,GAAG,MAAM,GAAG,MAAM,GAAG,aAAa,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;AAErE;;GAEG;AACH,MAAM,MAAM,wBAAwB,GAAG,OAAO,GAAG,IAAI,GAAG,SAAS,CAAA;AAEjE;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;OAEG;IACH,CACE,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,cAAc,GACtB,wBAAwB,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAA;CAChE;AAED;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG,aAAa,GAAG,kBAAkB,CAAA;AAE3D;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;AAE/D;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;OAEG;IACH,CAAC,OAAO,EAAE,cAAc,GAAG,uBAAuB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAA;CACtF;AAED;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG,gBAAgB,GAAG,eAAe,GAAG,eAAe,CAAA;AAEpF;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB;;;;OAIG;IACH,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAE/B;;;;OAIG;IACH,WAAW,CAAC,EAAE,SAAS,aAAa,EAAE,CAAA;IAEtC;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,CAAA;IAEnB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAA;IAE5B;;OAEG;IACH,KAAK,CAAC,EAAE,iBAAiB,CAAA;IAEzB;;OAEG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,iBAAiB,EAAE,OAAO,EAAE,cAAc,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC/F;AAED;;;;;;;GAOG;AACH,wBAAgB,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,UAAU,CAwC1D;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,cAAc,EAAE,QAAQ,SAAU,GAAG,MAAM,CAehF"}
@@ -0,0 +1,167 @@
1
+ import { Session } from '@remix-run/session';
2
+ const defaultSafeMethods = ['GET', 'HEAD', 'OPTIONS'];
3
+ const defaultTokenHeaderNames = ['x-csrf-token', 'x-xsrf-token', 'csrf-token'];
4
+ /**
5
+ * Session-backed CSRF protection middleware.
6
+ *
7
+ * This middleware requires the session middleware to run before it.
8
+ *
9
+ * @param options CSRF options
10
+ * @returns CSRF middleware
11
+ */
12
+ export function csrf(options = {}) {
13
+ let safeMethods = options.safeMethods ?? defaultSafeMethods;
14
+ let tokenKey = options.tokenKey ?? '_csrf';
15
+ let fieldName = options.fieldName ?? '_csrf';
16
+ let headerNames = options.headerNames ?? defaultTokenHeaderNames;
17
+ let allowMissingOrigin = options.allowMissingOrigin ?? true;
18
+ return async (context, next) => {
19
+ if (!context.has(Session)) {
20
+ throw new Error('csrf middleware requires session() middleware to run before it');
21
+ }
22
+ let expectedToken = getCsrfToken(context, tokenKey);
23
+ if (safeMethods.includes(context.method)) {
24
+ return next();
25
+ }
26
+ let validOrigin = await validateRequestOrigin(context, options.origin, allowMissingOrigin, context.url.origin);
27
+ if (!validOrigin) {
28
+ return getErrorResponse(options, 'invalid-origin', context);
29
+ }
30
+ let submittedToken = await resolveSubmittedToken(context, options.value, fieldName, headerNames);
31
+ if (submittedToken == null || submittedToken === '') {
32
+ return getErrorResponse(options, 'missing-token', context);
33
+ }
34
+ if (!constantTimeEqual(submittedToken, expectedToken)) {
35
+ return getErrorResponse(options, 'invalid-token', context);
36
+ }
37
+ return next();
38
+ };
39
+ }
40
+ /**
41
+ * Gets the CSRF token from the session. Creates one if missing.
42
+ *
43
+ * @param context Request context with a started session
44
+ * @param tokenKey Session key that stores the token
45
+ * @returns The active CSRF token
46
+ */
47
+ export function getCsrfToken(context, tokenKey = '_csrf') {
48
+ if (!context.has(Session)) {
49
+ throw new Error('Session is not started. Use session() middleware before csrf().');
50
+ }
51
+ let session = context.get(Session);
52
+ let token = session.get(tokenKey);
53
+ if (typeof token === 'string' && token !== '') {
54
+ return token;
55
+ }
56
+ let createdToken = createCsrfToken();
57
+ session.set(tokenKey, createdToken);
58
+ return createdToken;
59
+ }
60
+ function createCsrfToken() {
61
+ let bytes = new Uint8Array(32);
62
+ crypto.getRandomValues(bytes);
63
+ let token = '';
64
+ for (let byte of bytes) {
65
+ token += byte.toString(16).padStart(2, '0');
66
+ }
67
+ return token;
68
+ }
69
+ function getErrorResponse(options, reason, context) {
70
+ if (options.onError) {
71
+ return options.onError(reason, context);
72
+ }
73
+ if (reason === 'invalid-origin') {
74
+ return new Response('Forbidden: invalid CSRF origin', { status: 403 });
75
+ }
76
+ if (reason === 'missing-token') {
77
+ return new Response('Forbidden: missing CSRF token', { status: 403 });
78
+ }
79
+ return new Response('Forbidden: invalid CSRF token', { status: 403 });
80
+ }
81
+ async function resolveSubmittedToken(context, valueResolver, fieldName, headerNames) {
82
+ if (valueResolver) {
83
+ let value = await valueResolver(context);
84
+ if (value == null) {
85
+ return null;
86
+ }
87
+ let trimmedValue = value.trim();
88
+ return trimmedValue === '' ? null : trimmedValue;
89
+ }
90
+ for (let headerName of headerNames) {
91
+ let headerValue = context.headers.get(headerName);
92
+ if (headerValue == null) {
93
+ continue;
94
+ }
95
+ let trimmedHeaderValue = headerValue.trim();
96
+ if (trimmedHeaderValue !== '') {
97
+ return trimmedHeaderValue;
98
+ }
99
+ }
100
+ let formValue = context.has(FormData) ? context.get(FormData).get(fieldName) : undefined;
101
+ if (typeof formValue === 'string') {
102
+ let trimmedFormValue = formValue.trim();
103
+ if (trimmedFormValue !== '') {
104
+ return trimmedFormValue;
105
+ }
106
+ }
107
+ let queryValue = context.url.searchParams.get(fieldName);
108
+ if (queryValue == null) {
109
+ return null;
110
+ }
111
+ let trimmedQueryValue = queryValue.trim();
112
+ return trimmedQueryValue === '' ? null : trimmedQueryValue;
113
+ }
114
+ async function validateRequestOrigin(context, configuredOrigin, allowMissingOrigin, defaultOrigin) {
115
+ let requestOrigin = getRequestOrigin(context);
116
+ if (requestOrigin == null) {
117
+ return allowMissingOrigin;
118
+ }
119
+ if (configuredOrigin == null) {
120
+ return requestOrigin === defaultOrigin;
121
+ }
122
+ if (typeof configuredOrigin === 'function') {
123
+ let result = await configuredOrigin(requestOrigin, context);
124
+ return result === true;
125
+ }
126
+ if (typeof configuredOrigin === 'string') {
127
+ return configuredOrigin === requestOrigin;
128
+ }
129
+ if (configuredOrigin instanceof RegExp) {
130
+ return configuredOrigin.test(requestOrigin);
131
+ }
132
+ for (let allowedOrigin of configuredOrigin) {
133
+ if (typeof allowedOrigin === 'string' && allowedOrigin === requestOrigin) {
134
+ return true;
135
+ }
136
+ if (allowedOrigin instanceof RegExp && allowedOrigin.test(requestOrigin)) {
137
+ return true;
138
+ }
139
+ }
140
+ return false;
141
+ }
142
+ function getRequestOrigin(context) {
143
+ let origin = context.headers.get('Origin');
144
+ if (origin != null && origin.trim() !== '') {
145
+ return origin;
146
+ }
147
+ let referer = context.headers.get('Referer');
148
+ if (referer == null || referer.trim() === '') {
149
+ return null;
150
+ }
151
+ try {
152
+ return new URL(referer).origin;
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ function constantTimeEqual(left, right) {
159
+ let mismatch = left.length === right.length ? 0 : 1;
160
+ let maxLength = Math.max(left.length, right.length);
161
+ for (let index = 0; index < maxLength; index++) {
162
+ let leftCode = left.charCodeAt(index) || 0;
163
+ let rightCode = right.charCodeAt(index) || 0;
164
+ mismatch |= leftCode ^ rightCode;
165
+ }
166
+ return mismatch === 0;
167
+ }
package/package.json CHANGED
@@ -1,14 +1,57 @@
1
1
  {
2
2
  "name": "@remix-run/csrf-middleware",
3
- "version": "0.0.0",
4
- "description": "Placeholder package for Remix CI/OIDC setup",
3
+ "version": "0.1.1",
4
+ "description": "Middleware for CSRF protection in Fetch API servers",
5
+ "author": "Michael Jackson <mjijackson@gmail.com>",
5
6
  "license": "MIT",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/remix-run/remix.git",
9
10
  "directory": "packages/csrf-middleware"
10
11
  },
11
- "publishConfig": {
12
- "access": "public"
12
+ "homepage": "https://github.com/remix-run/remix/tree/main/packages/csrf-middleware#readme",
13
+ "files": [
14
+ "LICENSE",
15
+ "README.md",
16
+ "dist",
17
+ "src",
18
+ "!src/**/*.test.ts"
19
+ ],
20
+ "type": "module",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^24.6.0",
30
+ "@typescript/native-preview": "7.0.0-dev.20251125.1",
31
+ "@remix-run/cookie": "0.5.1",
32
+ "@remix-run/assert": "0.1.0",
33
+ "@remix-run/form-data-middleware": "0.2.1",
34
+ "@remix-run/fetch-router": "0.18.1",
35
+ "@remix-run/session-middleware": "0.2.1",
36
+ "@remix-run/session": "0.4.1",
37
+ "@remix-run/test": "0.1.0"
38
+ },
39
+ "dependencies": {
40
+ "@remix-run/fetch-router": "^0.18.1",
41
+ "@remix-run/session": "^0.4.1"
42
+ },
43
+ "keywords": [
44
+ "fetch",
45
+ "router",
46
+ "middleware",
47
+ "csrf",
48
+ "security",
49
+ "origin"
50
+ ],
51
+ "scripts": {
52
+ "build": "tsgo -p tsconfig.build.json",
53
+ "clean": "git clean -fdX",
54
+ "test": "remix-test",
55
+ "typecheck": "tsgo --noEmit"
13
56
  }
14
- }
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export {
2
+ csrf,
3
+ getCsrfToken,
4
+ type CsrfFailureReason,
5
+ type CsrfOptions,
6
+ type CsrfOrigin,
7
+ type CsrfOriginResolver,
8
+ type CsrfOriginResolverResult,
9
+ type CsrfTokenResolver,
10
+ type CsrfTokenResolverResult,
11
+ } from './lib/csrf.ts'
@@ -0,0 +1,330 @@
1
+ import type { Middleware, RequestContext, RequestMethod } from '@remix-run/fetch-router'
2
+ import { Session } from '@remix-run/session'
3
+
4
+ const defaultSafeMethods: RequestMethod[] = ['GET', 'HEAD', 'OPTIONS']
5
+ const defaultTokenHeaderNames = ['x-csrf-token', 'x-xsrf-token', 'csrf-token']
6
+
7
+ type OriginMatcher = string | RegExp | ReadonlyArray<string | RegExp>
8
+
9
+ /**
10
+ * Return shape for a dynamic CSRF origin resolver.
11
+ */
12
+ export type CsrfOriginResolverResult = boolean | null | undefined
13
+
14
+ /**
15
+ * Resolves whether an unsafe cross-origin request should be allowed.
16
+ */
17
+ export interface CsrfOriginResolver {
18
+ /**
19
+ * Resolves whether an unsafe request origin should be trusted.
20
+ */
21
+ (
22
+ origin: string,
23
+ context: RequestContext,
24
+ ): CsrfOriginResolverResult | Promise<CsrfOriginResolverResult>
25
+ }
26
+
27
+ /**
28
+ * Accepted forms for configuring allowed CSRF origins.
29
+ */
30
+ export type CsrfOrigin = OriginMatcher | CsrfOriginResolver
31
+
32
+ /**
33
+ * Return shape for a dynamic CSRF token resolver.
34
+ */
35
+ export type CsrfTokenResolverResult = string | null | undefined
36
+
37
+ /**
38
+ * Resolves the submitted CSRF token for a request.
39
+ */
40
+ export interface CsrfTokenResolver {
41
+ /**
42
+ * Resolves the submitted CSRF token for the current request.
43
+ */
44
+ (context: RequestContext): CsrfTokenResolverResult | Promise<CsrfTokenResolverResult>
45
+ }
46
+
47
+ /**
48
+ * The reason a CSRF request was rejected.
49
+ */
50
+ export type CsrfFailureReason = 'invalid-origin' | 'missing-token' | 'invalid-token'
51
+
52
+ /**
53
+ * Options for the CSRF middleware.
54
+ */
55
+ export interface CsrfOptions {
56
+ /**
57
+ * Session key used to store the server-generated CSRF token.
58
+ *
59
+ * @default '_csrf'
60
+ */
61
+ tokenKey?: string
62
+
63
+ /**
64
+ * Form field name to read CSRF tokens from.
65
+ *
66
+ * @default '_csrf'
67
+ */
68
+ fieldName?: string
69
+
70
+ /**
71
+ * Header names checked (in order) for CSRF tokens.
72
+ *
73
+ * @default ['x-csrf-token', 'x-xsrf-token', 'csrf-token']
74
+ */
75
+ headerNames?: readonly string[]
76
+
77
+ /**
78
+ * Methods that do not require CSRF validation.
79
+ *
80
+ * @default ['GET', 'HEAD', 'OPTIONS']
81
+ */
82
+ safeMethods?: readonly RequestMethod[]
83
+
84
+ /**
85
+ * Allowed cross-origin origins for unsafe requests.
86
+ *
87
+ * When omitted, requests are validated as same-origin.
88
+ */
89
+ origin?: CsrfOrigin
90
+
91
+ /**
92
+ * Allow requests without Origin/Referer headers.
93
+ *
94
+ * @default true
95
+ */
96
+ allowMissingOrigin?: boolean
97
+
98
+ /**
99
+ * Custom function for extracting the submitted token.
100
+ */
101
+ value?: CsrfTokenResolver
102
+
103
+ /**
104
+ * Optional custom error response for rejected requests.
105
+ */
106
+ onError?: (reason: CsrfFailureReason, context: RequestContext) => Response | Promise<Response>
107
+ }
108
+
109
+ /**
110
+ * Session-backed CSRF protection middleware.
111
+ *
112
+ * This middleware requires the session middleware to run before it.
113
+ *
114
+ * @param options CSRF options
115
+ * @returns CSRF middleware
116
+ */
117
+ export function csrf(options: CsrfOptions = {}): Middleware {
118
+ let safeMethods = options.safeMethods ?? defaultSafeMethods
119
+ let tokenKey = options.tokenKey ?? '_csrf'
120
+ let fieldName = options.fieldName ?? '_csrf'
121
+ let headerNames = options.headerNames ?? defaultTokenHeaderNames
122
+ let allowMissingOrigin = options.allowMissingOrigin ?? true
123
+
124
+ return async (context, next) => {
125
+ if (!context.has(Session)) {
126
+ throw new Error('csrf middleware requires session() middleware to run before it')
127
+ }
128
+
129
+ let expectedToken = getCsrfToken(context, tokenKey)
130
+
131
+ if (safeMethods.includes(context.method)) {
132
+ return next()
133
+ }
134
+
135
+ let validOrigin = await validateRequestOrigin(
136
+ context,
137
+ options.origin,
138
+ allowMissingOrigin,
139
+ context.url.origin,
140
+ )
141
+ if (!validOrigin) {
142
+ return getErrorResponse(options, 'invalid-origin', context)
143
+ }
144
+
145
+ let submittedToken = await resolveSubmittedToken(context, options.value, fieldName, headerNames)
146
+
147
+ if (submittedToken == null || submittedToken === '') {
148
+ return getErrorResponse(options, 'missing-token', context)
149
+ }
150
+
151
+ if (!constantTimeEqual(submittedToken, expectedToken)) {
152
+ return getErrorResponse(options, 'invalid-token', context)
153
+ }
154
+
155
+ return next()
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Gets the CSRF token from the session. Creates one if missing.
161
+ *
162
+ * @param context Request context with a started session
163
+ * @param tokenKey Session key that stores the token
164
+ * @returns The active CSRF token
165
+ */
166
+ export function getCsrfToken(context: RequestContext, tokenKey = '_csrf'): string {
167
+ if (!context.has(Session)) {
168
+ throw new Error('Session is not started. Use session() middleware before csrf().')
169
+ }
170
+
171
+ let session = context.get(Session)
172
+ let token = session.get(tokenKey)
173
+ if (typeof token === 'string' && token !== '') {
174
+ return token
175
+ }
176
+
177
+ let createdToken = createCsrfToken()
178
+ session.set(tokenKey, createdToken)
179
+
180
+ return createdToken
181
+ }
182
+
183
+ function createCsrfToken(): string {
184
+ let bytes = new Uint8Array(32)
185
+ crypto.getRandomValues(bytes)
186
+
187
+ let token = ''
188
+ for (let byte of bytes) {
189
+ token += byte.toString(16).padStart(2, '0')
190
+ }
191
+
192
+ return token
193
+ }
194
+
195
+ function getErrorResponse(
196
+ options: CsrfOptions,
197
+ reason: CsrfFailureReason,
198
+ context: RequestContext,
199
+ ): Response | Promise<Response> {
200
+ if (options.onError) {
201
+ return options.onError(reason, context)
202
+ }
203
+
204
+ if (reason === 'invalid-origin') {
205
+ return new Response('Forbidden: invalid CSRF origin', { status: 403 })
206
+ }
207
+
208
+ if (reason === 'missing-token') {
209
+ return new Response('Forbidden: missing CSRF token', { status: 403 })
210
+ }
211
+
212
+ return new Response('Forbidden: invalid CSRF token', { status: 403 })
213
+ }
214
+
215
+ async function resolveSubmittedToken(
216
+ context: RequestContext,
217
+ valueResolver: CsrfTokenResolver | undefined,
218
+ fieldName: string,
219
+ headerNames: readonly string[],
220
+ ): Promise<string | null> {
221
+ if (valueResolver) {
222
+ let value = await valueResolver(context)
223
+ if (value == null) {
224
+ return null
225
+ }
226
+
227
+ let trimmedValue = value.trim()
228
+ return trimmedValue === '' ? null : trimmedValue
229
+ }
230
+
231
+ for (let headerName of headerNames) {
232
+ let headerValue = context.headers.get(headerName)
233
+ if (headerValue == null) {
234
+ continue
235
+ }
236
+
237
+ let trimmedHeaderValue = headerValue.trim()
238
+ if (trimmedHeaderValue !== '') {
239
+ return trimmedHeaderValue
240
+ }
241
+ }
242
+
243
+ let formValue = context.has(FormData) ? context.get(FormData).get(fieldName) : undefined
244
+ if (typeof formValue === 'string') {
245
+ let trimmedFormValue = formValue.trim()
246
+ if (trimmedFormValue !== '') {
247
+ return trimmedFormValue
248
+ }
249
+ }
250
+
251
+ let queryValue = context.url.searchParams.get(fieldName)
252
+ if (queryValue == null) {
253
+ return null
254
+ }
255
+
256
+ let trimmedQueryValue = queryValue.trim()
257
+ return trimmedQueryValue === '' ? null : trimmedQueryValue
258
+ }
259
+
260
+ async function validateRequestOrigin(
261
+ context: RequestContext,
262
+ configuredOrigin: CsrfOrigin | undefined,
263
+ allowMissingOrigin: boolean,
264
+ defaultOrigin: string,
265
+ ): Promise<boolean> {
266
+ let requestOrigin = getRequestOrigin(context)
267
+ if (requestOrigin == null) {
268
+ return allowMissingOrigin
269
+ }
270
+
271
+ if (configuredOrigin == null) {
272
+ return requestOrigin === defaultOrigin
273
+ }
274
+
275
+ if (typeof configuredOrigin === 'function') {
276
+ let result = await configuredOrigin(requestOrigin, context)
277
+ return result === true
278
+ }
279
+
280
+ if (typeof configuredOrigin === 'string') {
281
+ return configuredOrigin === requestOrigin
282
+ }
283
+
284
+ if (configuredOrigin instanceof RegExp) {
285
+ return configuredOrigin.test(requestOrigin)
286
+ }
287
+
288
+ for (let allowedOrigin of configuredOrigin) {
289
+ if (typeof allowedOrigin === 'string' && allowedOrigin === requestOrigin) {
290
+ return true
291
+ }
292
+
293
+ if (allowedOrigin instanceof RegExp && allowedOrigin.test(requestOrigin)) {
294
+ return true
295
+ }
296
+ }
297
+
298
+ return false
299
+ }
300
+
301
+ function getRequestOrigin(context: RequestContext): string | null {
302
+ let origin = context.headers.get('Origin')
303
+ if (origin != null && origin.trim() !== '') {
304
+ return origin
305
+ }
306
+
307
+ let referer = context.headers.get('Referer')
308
+ if (referer == null || referer.trim() === '') {
309
+ return null
310
+ }
311
+
312
+ try {
313
+ return new URL(referer).origin
314
+ } catch {
315
+ return null
316
+ }
317
+ }
318
+
319
+ function constantTimeEqual(left: string, right: string): boolean {
320
+ let mismatch = left.length === right.length ? 0 : 1
321
+ let maxLength = Math.max(left.length, right.length)
322
+
323
+ for (let index = 0; index < maxLength; index++) {
324
+ let leftCode = left.charCodeAt(index) || 0
325
+ let rightCode = right.charCodeAt(index) || 0
326
+ mismatch |= leftCode ^ rightCode
327
+ }
328
+
329
+ return mismatch === 0
330
+ }