@remix-run/cop-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 +21 -0
- package/README.md +116 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/cop.d.ts +39 -0
- package/dist/lib/cop.d.ts.map +1 -0
- package/dist/lib/cop.js +221 -0
- package/package.json +43 -5
- package/src/index.ts +1 -0
- package/src/lib/cop.ts +348 -0
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,117 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cop-middleware
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cross-origin protection middleware for Remix. It mirrors Go's `CrossOriginProtection` by rejecting unsafe cross-origin browser requests without synchronizer tokens.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Browser Provenance Checks** - Uses `Sec-Fetch-Site` when present and falls back to `Origin`
|
|
8
|
+
- **Trusted Origins** - Allow specific cross-origin callers by exact origin
|
|
9
|
+
- **Explicit Escape Hatches** - Support insecure bypass patterns for endpoints like webhooks
|
|
10
|
+
- **No Session State** - Does not require synchronizer tokens or server-side CSRF storage
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm i remix
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { createRouter } from 'remix/fetch-router'
|
|
22
|
+
import { cop } from 'remix/cop-middleware'
|
|
23
|
+
|
|
24
|
+
let router = createRouter({
|
|
25
|
+
middleware: [cop()],
|
|
26
|
+
})
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Behavior
|
|
30
|
+
|
|
31
|
+
For unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`), `cop()` follows the same broad model as Go's `CrossOriginProtection`:
|
|
32
|
+
|
|
33
|
+
- Allow `Sec-Fetch-Site: same-origin`
|
|
34
|
+
- Allow `Sec-Fetch-Site: none`
|
|
35
|
+
- Reject other `Sec-Fetch-Site` values unless the request matches a trusted origin or insecure bypass
|
|
36
|
+
- If `Sec-Fetch-Site` is missing, compare `Origin` to the request host
|
|
37
|
+
- If both `Sec-Fetch-Site` and `Origin` are missing, allow the request
|
|
38
|
+
|
|
39
|
+
This middleware is intentionally tokenless. If you cannot guarantee the deployment assumptions behind that model, prefer [`csrf-middleware`](https://github.com/remix-run/remix/tree/main/packages/csrf-middleware).
|
|
40
|
+
|
|
41
|
+
## Caveats
|
|
42
|
+
|
|
43
|
+
- `cop()` is a browser-origin guard, not a universal CSRF solution. It is designed for deployments that can rely on modern browser provenance signals and same-origin request handling.
|
|
44
|
+
- If both `Sec-Fetch-Site` and `Origin` are missing on an unsafe request, `cop()` allows the request to continue. This is intentional so older clients and non-browser callers do not fail closed by default.
|
|
45
|
+
- If `Sec-Fetch-Site` is missing, `cop()` only rejects when `Origin` is present and does not match the request host.
|
|
46
|
+
- If you need stronger guarantees for session-backed form workflows, mixed deployment environments, or requests that should not fall through when browser provenance headers are missing, use [`csrf-middleware`](https://github.com/remix-run/remix/tree/main/packages/csrf-middleware) or layer both middlewares together.
|
|
47
|
+
|
|
48
|
+
## Using with csrf-middleware
|
|
49
|
+
|
|
50
|
+
You can also layer `cop()` in front of `csrf()` when you want both browser provenance checks and session-backed synchronizer tokens.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { createCookie } from 'remix/cookie'
|
|
54
|
+
import { createRouter } from 'remix/fetch-router'
|
|
55
|
+
import { createCookieSessionStorage } from 'remix/session/cookie-storage'
|
|
56
|
+
import { session } from 'remix/session-middleware'
|
|
57
|
+
import { cop } from 'remix/cop-middleware'
|
|
58
|
+
import { csrf } from 'remix/csrf-middleware'
|
|
59
|
+
|
|
60
|
+
let sessionCookie = createCookie('__session', { secrets: ['secret1'] })
|
|
61
|
+
let sessionStorage = createCookieSessionStorage()
|
|
62
|
+
|
|
63
|
+
let router = createRouter({
|
|
64
|
+
middleware: [cop(), session(sessionCookie, sessionStorage), csrf()],
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
In this setup, `cop()` runs first and rejects unsafe cross-origin browser requests early using `Sec-Fetch-Site` and `Origin`. Requests that pass `cop()` continue into `csrf()`, which still enforces synchronizer-token validation and origin checks for the remaining traffic.
|
|
69
|
+
|
|
70
|
+
## Trusted Origins
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { createRouter } from 'remix/fetch-router'
|
|
74
|
+
import { cop } from 'remix/cop-middleware'
|
|
75
|
+
|
|
76
|
+
let router = createRouter({
|
|
77
|
+
middleware: [
|
|
78
|
+
cop({
|
|
79
|
+
trustedOrigins: ['https://admin.example.com'],
|
|
80
|
+
}),
|
|
81
|
+
],
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Trusted origins must be exact origin values in the form `scheme://host[:port]`.
|
|
86
|
+
|
|
87
|
+
## Insecure Bypass Patterns
|
|
88
|
+
|
|
89
|
+
Bypass patterns intentionally weaken protection for specific endpoints. They support:
|
|
90
|
+
|
|
91
|
+
- Optional method prefixes, for example `POST /webhooks/{provider}`
|
|
92
|
+
- Exact paths, for example `/healthz`
|
|
93
|
+
- Trailing-slash subtree patterns, for example `/webhooks/`
|
|
94
|
+
- Single-segment wildcards with `{name}`
|
|
95
|
+
- Tail wildcards with `{name...}`
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { createRouter } from 'remix/fetch-router'
|
|
99
|
+
import { cop } from 'remix/cop-middleware'
|
|
100
|
+
|
|
101
|
+
let router = createRouter({
|
|
102
|
+
middleware: [
|
|
103
|
+
cop({
|
|
104
|
+
insecureBypassPatterns: ['POST /webhooks/{provider}', '/healthz'],
|
|
105
|
+
}),
|
|
106
|
+
],
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Related Packages
|
|
111
|
+
|
|
112
|
+
- [`csrf-middleware`](https://github.com/remix-run/remix/tree/main/packages/csrf-middleware) - Session-backed CSRF protection with synchronizer tokens
|
|
113
|
+
- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Router for the web Fetch API
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,KAAK,cAAc,EAAE,KAAK,gBAAgB,EAAE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { cop } from "./lib/cop.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Middleware, RequestContext } from '@remix-run/fetch-router';
|
|
2
|
+
/**
|
|
3
|
+
* Reason reported when cross-origin protection rejects a request.
|
|
4
|
+
*/
|
|
5
|
+
export type CopFailureReason = 'cross-origin-request' | 'cross-origin-request-from-old-browser';
|
|
6
|
+
/**
|
|
7
|
+
* Custom response handler for rejected cross-origin requests.
|
|
8
|
+
*/
|
|
9
|
+
export interface CopDenyHandler {
|
|
10
|
+
/**
|
|
11
|
+
* Builds the response returned when a request is denied.
|
|
12
|
+
*/
|
|
13
|
+
(reason: CopFailureReason, context: RequestContext): Response | Promise<Response>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Configuration for the cross-origin protection middleware.
|
|
17
|
+
*/
|
|
18
|
+
export interface CopOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Exact origins that should bypass cross-origin rejection.
|
|
21
|
+
*/
|
|
22
|
+
trustedOrigins?: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Path patterns that should bypass protection for matching requests.
|
|
25
|
+
*/
|
|
26
|
+
insecureBypassPatterns?: readonly string[];
|
|
27
|
+
/**
|
|
28
|
+
* Optional custom response handler for rejected requests.
|
|
29
|
+
*/
|
|
30
|
+
onDeny?: CopDenyHandler;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Creates middleware that rejects unsafe cross-origin requests.
|
|
34
|
+
*
|
|
35
|
+
* @param options Cross-origin protection options.
|
|
36
|
+
* @returns Middleware that validates request origin headers.
|
|
37
|
+
*/
|
|
38
|
+
export declare function cop(options?: CopOptions): Middleware;
|
|
39
|
+
//# sourceMappingURL=cop.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cop.d.ts","sourceRoot":"","sources":["../../src/lib/cop.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAiB,MAAM,yBAAyB,CAAA;AAaxF;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG,sBAAsB,GAAG,uCAAuC,CAAA;AAE/F;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B;;OAEG;IACH,CAAC,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,cAAc,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAClF;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB;;OAEG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAElC;;OAEG;IACH,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAE1C;;OAEG;IACH,MAAM,CAAC,EAAE,cAAc,CAAA;CACxB;AAqFD;;;;;GAKG;AACH,wBAAgB,GAAG,CAAC,OAAO,GAAE,UAAe,GAAG,UAAU,CAWxD"}
|
package/dist/lib/cop.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { RequestMethods } from '@remix-run/fetch-router';
|
|
2
|
+
const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
|
|
3
|
+
class CrossOriginProtection {
|
|
4
|
+
#trustedOrigins = new Set();
|
|
5
|
+
#insecureBypassPatterns = [];
|
|
6
|
+
#onDeny;
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
for (let trustedOrigin of options.trustedOrigins ?? []) {
|
|
9
|
+
this.addTrustedOrigin(trustedOrigin);
|
|
10
|
+
}
|
|
11
|
+
for (let insecureBypassPattern of options.insecureBypassPatterns ?? []) {
|
|
12
|
+
this.addInsecureBypassPattern(insecureBypassPattern);
|
|
13
|
+
}
|
|
14
|
+
this.setDenyHandler(options.onDeny);
|
|
15
|
+
}
|
|
16
|
+
addTrustedOrigin(origin) {
|
|
17
|
+
this.#trustedOrigins.add(validateTrustedOrigin(origin));
|
|
18
|
+
}
|
|
19
|
+
addInsecureBypassPattern(pattern) {
|
|
20
|
+
this.#insecureBypassPatterns.push(parseBypassPattern(pattern));
|
|
21
|
+
}
|
|
22
|
+
setDenyHandler(onDeny) {
|
|
23
|
+
this.#onDeny = onDeny;
|
|
24
|
+
}
|
|
25
|
+
check(context) {
|
|
26
|
+
if (safeMethods.includes(context.method)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
let secFetchSite = getHeaderValue(context.headers, 'Sec-Fetch-Site')?.toLowerCase() ?? '';
|
|
30
|
+
switch (secFetchSite) {
|
|
31
|
+
case '':
|
|
32
|
+
break;
|
|
33
|
+
case 'same-origin':
|
|
34
|
+
case 'none':
|
|
35
|
+
return null;
|
|
36
|
+
default:
|
|
37
|
+
return this.#isRequestExempt(context) ? null : 'cross-origin-request';
|
|
38
|
+
}
|
|
39
|
+
let requestOrigin = getHeaderValue(context.headers, 'Origin');
|
|
40
|
+
if (requestOrigin == null) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
let parsedOrigin = parseOrigin(requestOrigin);
|
|
44
|
+
if (parsedOrigin != null && parsedOrigin.host === context.url.host) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return this.#isRequestExempt(context) ? null : 'cross-origin-request-from-old-browser';
|
|
48
|
+
}
|
|
49
|
+
deny(reason, context) {
|
|
50
|
+
if (this.#onDeny) {
|
|
51
|
+
return this.#onDeny(reason, context);
|
|
52
|
+
}
|
|
53
|
+
return new Response(getDefaultErrorMessage(reason), { status: 403 });
|
|
54
|
+
}
|
|
55
|
+
#isRequestExempt(context) {
|
|
56
|
+
for (let pattern of this.#insecureBypassPatterns) {
|
|
57
|
+
if (matchesBypassPattern(pattern, context)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let requestOrigin = getHeaderValue(context.headers, 'Origin');
|
|
62
|
+
if (requestOrigin == null) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
let normalizedOrigin = normalizeOrigin(requestOrigin);
|
|
66
|
+
return normalizedOrigin != null && this.#trustedOrigins.has(normalizedOrigin);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Creates middleware that rejects unsafe cross-origin requests.
|
|
71
|
+
*
|
|
72
|
+
* @param options Cross-origin protection options.
|
|
73
|
+
* @returns Middleware that validates request origin headers.
|
|
74
|
+
*/
|
|
75
|
+
export function cop(options = {}) {
|
|
76
|
+
let protection = new CrossOriginProtection(options);
|
|
77
|
+
return async (context, next) => {
|
|
78
|
+
let reason = protection.check(context);
|
|
79
|
+
if (reason == null) {
|
|
80
|
+
return next();
|
|
81
|
+
}
|
|
82
|
+
return protection.deny(reason, context);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function getDefaultErrorMessage(reason) {
|
|
86
|
+
if (reason === 'cross-origin-request') {
|
|
87
|
+
return 'Forbidden: cross-origin request detected from Sec-Fetch-Site header';
|
|
88
|
+
}
|
|
89
|
+
return 'Forbidden: cross-origin request detected, and/or browser is out of date: Sec-Fetch-Site is missing, and Origin does not match Host';
|
|
90
|
+
}
|
|
91
|
+
function getHeaderValue(headers, name) {
|
|
92
|
+
let value = headers.get(name);
|
|
93
|
+
if (value == null) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
let trimmedValue = value.trim();
|
|
97
|
+
return trimmedValue === '' ? null : trimmedValue;
|
|
98
|
+
}
|
|
99
|
+
function validateTrustedOrigin(origin) {
|
|
100
|
+
let trimmedOrigin = origin.trim();
|
|
101
|
+
if (trimmedOrigin === '') {
|
|
102
|
+
throw new Error('trusted origin must not be empty');
|
|
103
|
+
}
|
|
104
|
+
if (trimmedOrigin.endsWith('/')) {
|
|
105
|
+
throw new Error(`invalid origin ${JSON.stringify(origin)}: trailing slash is not allowed`);
|
|
106
|
+
}
|
|
107
|
+
let parsedOrigin = parseOrigin(trimmedOrigin);
|
|
108
|
+
if (parsedOrigin == null) {
|
|
109
|
+
throw new Error(`invalid origin ${JSON.stringify(origin)}`);
|
|
110
|
+
}
|
|
111
|
+
if (parsedOrigin.pathname !== '/' || parsedOrigin.search !== '' || parsedOrigin.hash !== '') {
|
|
112
|
+
throw new Error(`invalid origin ${JSON.stringify(origin)}: path, query, and fragment are not allowed`);
|
|
113
|
+
}
|
|
114
|
+
return serializeOrigin(parsedOrigin);
|
|
115
|
+
}
|
|
116
|
+
function normalizeOrigin(origin) {
|
|
117
|
+
let parsedOrigin = parseOrigin(origin);
|
|
118
|
+
return parsedOrigin == null ? null : serializeOrigin(parsedOrigin);
|
|
119
|
+
}
|
|
120
|
+
function parseOrigin(origin) {
|
|
121
|
+
try {
|
|
122
|
+
let parsedOrigin = new URL(origin);
|
|
123
|
+
if (parsedOrigin.host === '') {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (parsedOrigin.username !== '' || parsedOrigin.password !== '') {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return parsedOrigin;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function serializeOrigin(origin) {
|
|
136
|
+
return `${origin.protocol}//${origin.host}`;
|
|
137
|
+
}
|
|
138
|
+
function parseBypassPattern(pattern) {
|
|
139
|
+
let trimmedPattern = pattern.trim();
|
|
140
|
+
if (trimmedPattern === '') {
|
|
141
|
+
throw new Error('bypass pattern must not be empty');
|
|
142
|
+
}
|
|
143
|
+
let method = null;
|
|
144
|
+
let pathname = trimmedPattern;
|
|
145
|
+
let methodPattern = /^([A-Z]+)\s+(.+)$/.exec(trimmedPattern);
|
|
146
|
+
if (methodPattern != null && methodPattern[2].startsWith('/')) {
|
|
147
|
+
let maybeMethod = methodPattern[1];
|
|
148
|
+
if (!RequestMethods.includes(maybeMethod)) {
|
|
149
|
+
throw new Error(`invalid request method in bypass pattern ${JSON.stringify(pattern)}`);
|
|
150
|
+
}
|
|
151
|
+
method = maybeMethod;
|
|
152
|
+
pathname = methodPattern[2];
|
|
153
|
+
}
|
|
154
|
+
if (!pathname.startsWith('/')) {
|
|
155
|
+
throw new Error(`invalid bypass pattern ${JSON.stringify(pattern)}: path must start with "/"`);
|
|
156
|
+
}
|
|
157
|
+
if (pathname.includes('?') || pathname.includes('#')) {
|
|
158
|
+
throw new Error(`invalid bypass pattern ${JSON.stringify(pattern)}: query strings and fragments are not supported`);
|
|
159
|
+
}
|
|
160
|
+
let matchesSubtree = pathname.endsWith('/');
|
|
161
|
+
let normalizedPathname = pathname.length > 1 && matchesSubtree ? pathname.slice(0, pathname.length - 1) : pathname;
|
|
162
|
+
let rawSegments = normalizedPathname === '/' ? [] : normalizedPathname.slice(1).split('/');
|
|
163
|
+
let segments = rawSegments.map((segment, index) => parseBypassSegment(pattern, segment, index === rawSegments.length - 1));
|
|
164
|
+
return { method, pathname, segments, matchesSubtree };
|
|
165
|
+
}
|
|
166
|
+
function parseBypassSegment(pattern, segment, isLastSegment) {
|
|
167
|
+
if (segment === '') {
|
|
168
|
+
throw new Error(`invalid bypass pattern ${JSON.stringify(pattern)}: empty path segments are not allowed`);
|
|
169
|
+
}
|
|
170
|
+
if (!segment.startsWith('{') || !segment.endsWith('}')) {
|
|
171
|
+
return { type: 'static', value: segment };
|
|
172
|
+
}
|
|
173
|
+
let wildcardName = segment.slice(1, segment.length - 1);
|
|
174
|
+
if (wildcardName === '') {
|
|
175
|
+
throw new Error(`invalid bypass pattern ${JSON.stringify(pattern)}: empty wildcards are not allowed`);
|
|
176
|
+
}
|
|
177
|
+
if (wildcardName === '$') {
|
|
178
|
+
throw new Error(`invalid bypass pattern ${JSON.stringify(pattern)}: "{$}" is not supported in cop-middleware`);
|
|
179
|
+
}
|
|
180
|
+
if (wildcardName.endsWith('...')) {
|
|
181
|
+
if (!isLastSegment) {
|
|
182
|
+
throw new Error(`invalid bypass pattern ${JSON.stringify(pattern)}: tail wildcards must be last`);
|
|
183
|
+
}
|
|
184
|
+
if (wildcardName.length === 3) {
|
|
185
|
+
throw new Error(`invalid bypass pattern ${JSON.stringify(pattern)}: tail wildcards require a name`);
|
|
186
|
+
}
|
|
187
|
+
return { type: 'rest' };
|
|
188
|
+
}
|
|
189
|
+
return { type: 'wildcard' };
|
|
190
|
+
}
|
|
191
|
+
function matchesBypassPattern(pattern, context) {
|
|
192
|
+
if (pattern.method != null && pattern.method !== context.method) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
let pathname = context.url.pathname;
|
|
196
|
+
let hasTrailingSlash = pathname.length > 1 && pathname.endsWith('/');
|
|
197
|
+
let normalizedPathname = pathname.length > 1 && hasTrailingSlash ? pathname.slice(0, pathname.length - 1) : pathname;
|
|
198
|
+
let pathSegments = normalizedPathname === '/' ? [] : normalizedPathname.slice(1).split('/');
|
|
199
|
+
let segmentIndex = 0;
|
|
200
|
+
while (segmentIndex < pattern.segments.length) {
|
|
201
|
+
let pathSegment = pathSegments[segmentIndex];
|
|
202
|
+
let segment = pattern.segments[segmentIndex];
|
|
203
|
+
if (segment.type === 'rest') {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
if (pathSegment == null) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (segment.type === 'static' && segment.value !== pathSegment) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
segmentIndex++;
|
|
213
|
+
}
|
|
214
|
+
if (pattern.matchesSubtree) {
|
|
215
|
+
if (pathSegments.length === pattern.segments.length) {
|
|
216
|
+
return pattern.pathname === '/' || hasTrailingSlash;
|
|
217
|
+
}
|
|
218
|
+
return pathSegments.length > pattern.segments.length;
|
|
219
|
+
}
|
|
220
|
+
return pathSegments.length === pattern.segments.length && !hasTrailingSlash;
|
|
221
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remix-run/cop-middleware",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Middleware for tokenless cross-origin 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/cop-middleware"
|
|
10
11
|
},
|
|
11
|
-
"
|
|
12
|
-
|
|
12
|
+
"homepage": "https://github.com/remix-run/remix/tree/main/packages/cop-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/fetch-router": "0.18.1",
|
|
32
|
+
"@remix-run/assert": "0.1.0",
|
|
33
|
+
"@remix-run/test": "0.1.0"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@remix-run/fetch-router": "^0.18.1"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"fetch",
|
|
40
|
+
"router",
|
|
41
|
+
"middleware",
|
|
42
|
+
"csrf",
|
|
43
|
+
"cross-origin",
|
|
44
|
+
"sec-fetch-site"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsgo -p tsconfig.build.json",
|
|
48
|
+
"clean": "git clean -fdX",
|
|
49
|
+
"test": "remix-test",
|
|
50
|
+
"typecheck": "tsgo --noEmit"
|
|
13
51
|
}
|
|
14
|
-
}
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { cop, type CopDenyHandler, type CopFailureReason, type CopOptions } from './lib/cop.ts'
|
package/src/lib/cop.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { RequestMethods } from '@remix-run/fetch-router'
|
|
2
|
+
import type { Middleware, RequestContext, RequestMethod } from '@remix-run/fetch-router'
|
|
3
|
+
|
|
4
|
+
const safeMethods: RequestMethod[] = ['GET', 'HEAD', 'OPTIONS']
|
|
5
|
+
|
|
6
|
+
type BypassSegment = { type: 'static'; value: string } | { type: 'wildcard' } | { type: 'rest' }
|
|
7
|
+
|
|
8
|
+
interface BypassPattern {
|
|
9
|
+
method: RequestMethod | null
|
|
10
|
+
pathname: string
|
|
11
|
+
segments: BypassSegment[]
|
|
12
|
+
matchesSubtree: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reason reported when cross-origin protection rejects a request.
|
|
17
|
+
*/
|
|
18
|
+
export type CopFailureReason = 'cross-origin-request' | 'cross-origin-request-from-old-browser'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Custom response handler for rejected cross-origin requests.
|
|
22
|
+
*/
|
|
23
|
+
export interface CopDenyHandler {
|
|
24
|
+
/**
|
|
25
|
+
* Builds the response returned when a request is denied.
|
|
26
|
+
*/
|
|
27
|
+
(reason: CopFailureReason, context: RequestContext): Response | Promise<Response>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Configuration for the cross-origin protection middleware.
|
|
32
|
+
*/
|
|
33
|
+
export interface CopOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Exact origins that should bypass cross-origin rejection.
|
|
36
|
+
*/
|
|
37
|
+
trustedOrigins?: readonly string[]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Path patterns that should bypass protection for matching requests.
|
|
41
|
+
*/
|
|
42
|
+
insecureBypassPatterns?: readonly string[]
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Optional custom response handler for rejected requests.
|
|
46
|
+
*/
|
|
47
|
+
onDeny?: CopDenyHandler
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class CrossOriginProtection {
|
|
51
|
+
#trustedOrigins = new Set<string>()
|
|
52
|
+
#insecureBypassPatterns: BypassPattern[] = []
|
|
53
|
+
#onDeny?: CopDenyHandler
|
|
54
|
+
|
|
55
|
+
constructor(options: CopOptions = {}) {
|
|
56
|
+
for (let trustedOrigin of options.trustedOrigins ?? []) {
|
|
57
|
+
this.addTrustedOrigin(trustedOrigin)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (let insecureBypassPattern of options.insecureBypassPatterns ?? []) {
|
|
61
|
+
this.addInsecureBypassPattern(insecureBypassPattern)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.setDenyHandler(options.onDeny)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
addTrustedOrigin(origin: string): void {
|
|
68
|
+
this.#trustedOrigins.add(validateTrustedOrigin(origin))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
addInsecureBypassPattern(pattern: string): void {
|
|
72
|
+
this.#insecureBypassPatterns.push(parseBypassPattern(pattern))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setDenyHandler(onDeny?: CopDenyHandler): void {
|
|
76
|
+
this.#onDeny = onDeny
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
check(context: RequestContext): CopFailureReason | null {
|
|
80
|
+
if (safeMethods.includes(context.method)) {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let secFetchSite = getHeaderValue(context.headers, 'Sec-Fetch-Site')?.toLowerCase() ?? ''
|
|
85
|
+
switch (secFetchSite) {
|
|
86
|
+
case '':
|
|
87
|
+
break
|
|
88
|
+
case 'same-origin':
|
|
89
|
+
case 'none':
|
|
90
|
+
return null
|
|
91
|
+
default:
|
|
92
|
+
return this.#isRequestExempt(context) ? null : 'cross-origin-request'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let requestOrigin = getHeaderValue(context.headers, 'Origin')
|
|
96
|
+
if (requestOrigin == null) {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let parsedOrigin = parseOrigin(requestOrigin)
|
|
101
|
+
if (parsedOrigin != null && parsedOrigin.host === context.url.host) {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return this.#isRequestExempt(context) ? null : 'cross-origin-request-from-old-browser'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
deny(reason: CopFailureReason, context: RequestContext): Response | Promise<Response> {
|
|
109
|
+
if (this.#onDeny) {
|
|
110
|
+
return this.#onDeny(reason, context)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return new Response(getDefaultErrorMessage(reason), { status: 403 })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#isRequestExempt(context: RequestContext): boolean {
|
|
117
|
+
for (let pattern of this.#insecureBypassPatterns) {
|
|
118
|
+
if (matchesBypassPattern(pattern, context)) {
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let requestOrigin = getHeaderValue(context.headers, 'Origin')
|
|
124
|
+
if (requestOrigin == null) {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let normalizedOrigin = normalizeOrigin(requestOrigin)
|
|
129
|
+
return normalizedOrigin != null && this.#trustedOrigins.has(normalizedOrigin)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Creates middleware that rejects unsafe cross-origin requests.
|
|
135
|
+
*
|
|
136
|
+
* @param options Cross-origin protection options.
|
|
137
|
+
* @returns Middleware that validates request origin headers.
|
|
138
|
+
*/
|
|
139
|
+
export function cop(options: CopOptions = {}): Middleware {
|
|
140
|
+
let protection = new CrossOriginProtection(options)
|
|
141
|
+
|
|
142
|
+
return async (context, next) => {
|
|
143
|
+
let reason = protection.check(context)
|
|
144
|
+
if (reason == null) {
|
|
145
|
+
return next()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return protection.deny(reason, context)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getDefaultErrorMessage(reason: CopFailureReason): string {
|
|
153
|
+
if (reason === 'cross-origin-request') {
|
|
154
|
+
return 'Forbidden: cross-origin request detected from Sec-Fetch-Site header'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return 'Forbidden: cross-origin request detected, and/or browser is out of date: Sec-Fetch-Site is missing, and Origin does not match Host'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getHeaderValue(headers: Headers, name: string): string | null {
|
|
161
|
+
let value = headers.get(name)
|
|
162
|
+
if (value == null) {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let trimmedValue = value.trim()
|
|
167
|
+
return trimmedValue === '' ? null : trimmedValue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validateTrustedOrigin(origin: string): string {
|
|
171
|
+
let trimmedOrigin = origin.trim()
|
|
172
|
+
if (trimmedOrigin === '') {
|
|
173
|
+
throw new Error('trusted origin must not be empty')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (trimmedOrigin.endsWith('/')) {
|
|
177
|
+
throw new Error(`invalid origin ${JSON.stringify(origin)}: trailing slash is not allowed`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let parsedOrigin = parseOrigin(trimmedOrigin)
|
|
181
|
+
if (parsedOrigin == null) {
|
|
182
|
+
throw new Error(`invalid origin ${JSON.stringify(origin)}`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (parsedOrigin.pathname !== '/' || parsedOrigin.search !== '' || parsedOrigin.hash !== '') {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`invalid origin ${JSON.stringify(origin)}: path, query, and fragment are not allowed`,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return serializeOrigin(parsedOrigin)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeOrigin(origin: string): string | null {
|
|
195
|
+
let parsedOrigin = parseOrigin(origin)
|
|
196
|
+
return parsedOrigin == null ? null : serializeOrigin(parsedOrigin)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseOrigin(origin: string): URL | null {
|
|
200
|
+
try {
|
|
201
|
+
let parsedOrigin = new URL(origin)
|
|
202
|
+
if (parsedOrigin.host === '') {
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (parsedOrigin.username !== '' || parsedOrigin.password !== '') {
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return parsedOrigin
|
|
211
|
+
} catch {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function serializeOrigin(origin: URL): string {
|
|
217
|
+
return `${origin.protocol}//${origin.host}`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseBypassPattern(pattern: string): BypassPattern {
|
|
221
|
+
let trimmedPattern = pattern.trim()
|
|
222
|
+
if (trimmedPattern === '') {
|
|
223
|
+
throw new Error('bypass pattern must not be empty')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let method: RequestMethod | null = null
|
|
227
|
+
let pathname = trimmedPattern
|
|
228
|
+
let methodPattern = /^([A-Z]+)\s+(.+)$/.exec(trimmedPattern)
|
|
229
|
+
|
|
230
|
+
if (methodPattern != null && methodPattern[2].startsWith('/')) {
|
|
231
|
+
let maybeMethod = methodPattern[1] as RequestMethod
|
|
232
|
+
if (!RequestMethods.includes(maybeMethod)) {
|
|
233
|
+
throw new Error(`invalid request method in bypass pattern ${JSON.stringify(pattern)}`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
method = maybeMethod
|
|
237
|
+
pathname = methodPattern[2]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!pathname.startsWith('/')) {
|
|
241
|
+
throw new Error(`invalid bypass pattern ${JSON.stringify(pattern)}: path must start with "/"`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (pathname.includes('?') || pathname.includes('#')) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`invalid bypass pattern ${JSON.stringify(pattern)}: query strings and fragments are not supported`,
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let matchesSubtree = pathname.endsWith('/')
|
|
251
|
+
let normalizedPathname =
|
|
252
|
+
pathname.length > 1 && matchesSubtree ? pathname.slice(0, pathname.length - 1) : pathname
|
|
253
|
+
let rawSegments = normalizedPathname === '/' ? [] : normalizedPathname.slice(1).split('/')
|
|
254
|
+
let segments = rawSegments.map((segment, index) =>
|
|
255
|
+
parseBypassSegment(pattern, segment, index === rawSegments.length - 1),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return { method, pathname, segments, matchesSubtree }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseBypassSegment(
|
|
262
|
+
pattern: string,
|
|
263
|
+
segment: string,
|
|
264
|
+
isLastSegment: boolean,
|
|
265
|
+
): BypassSegment {
|
|
266
|
+
if (segment === '') {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`invalid bypass pattern ${JSON.stringify(pattern)}: empty path segments are not allowed`,
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!segment.startsWith('{') || !segment.endsWith('}')) {
|
|
273
|
+
return { type: 'static', value: segment }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let wildcardName = segment.slice(1, segment.length - 1)
|
|
277
|
+
if (wildcardName === '') {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`invalid bypass pattern ${JSON.stringify(pattern)}: empty wildcards are not allowed`,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (wildcardName === '$') {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`invalid bypass pattern ${JSON.stringify(pattern)}: "{$}" is not supported in cop-middleware`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (wildcardName.endsWith('...')) {
|
|
290
|
+
if (!isLastSegment) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`invalid bypass pattern ${JSON.stringify(pattern)}: tail wildcards must be last`,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (wildcardName.length === 3) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`invalid bypass pattern ${JSON.stringify(pattern)}: tail wildcards require a name`,
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return { type: 'rest' }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { type: 'wildcard' }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function matchesBypassPattern(pattern: BypassPattern, context: RequestContext): boolean {
|
|
309
|
+
if (pattern.method != null && pattern.method !== context.method) {
|
|
310
|
+
return false
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let pathname = context.url.pathname
|
|
314
|
+
let hasTrailingSlash = pathname.length > 1 && pathname.endsWith('/')
|
|
315
|
+
let normalizedPathname =
|
|
316
|
+
pathname.length > 1 && hasTrailingSlash ? pathname.slice(0, pathname.length - 1) : pathname
|
|
317
|
+
let pathSegments = normalizedPathname === '/' ? [] : normalizedPathname.slice(1).split('/')
|
|
318
|
+
|
|
319
|
+
let segmentIndex = 0
|
|
320
|
+
while (segmentIndex < pattern.segments.length) {
|
|
321
|
+
let pathSegment = pathSegments[segmentIndex]
|
|
322
|
+
let segment = pattern.segments[segmentIndex]
|
|
323
|
+
|
|
324
|
+
if (segment.type === 'rest') {
|
|
325
|
+
return true
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (pathSegment == null) {
|
|
329
|
+
return false
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (segment.type === 'static' && segment.value !== pathSegment) {
|
|
333
|
+
return false
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
segmentIndex++
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (pattern.matchesSubtree) {
|
|
340
|
+
if (pathSegments.length === pattern.segments.length) {
|
|
341
|
+
return pattern.pathname === '/' || hasTrailingSlash
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return pathSegments.length > pattern.segments.length
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return pathSegments.length === pattern.segments.length && !hasTrailingSlash
|
|
348
|
+
}
|