@vouchington/edge 0.0.0-stage → 0.1.0
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 +9 -0
- package/README.md +44 -2
- package/dist/basic-auth-credentials.d.mts +23 -0
- package/dist/basic-auth-credentials.mjs +75 -0
- package/dist/basic-auth.d.mts +29 -0
- package/dist/basic-auth.mjs +60 -0
- package/dist/cache-policy.d.mts +41 -0
- package/dist/cache-policy.mjs +74 -0
- package/dist/cache-url.d.mts +9 -0
- package/dist/cache-url.mjs +34 -0
- package/dist/cache-vary.d.mts +8 -0
- package/dist/cache-vary.mjs +46 -0
- package/dist/cached-origin.d.mts +42 -0
- package/dist/cached-origin.mjs +74 -0
- package/dist/client-info.d.mts +33 -0
- package/dist/client-info.mjs +63 -0
- package/dist/cookies.d.mts +9 -0
- package/dist/cookies.mjs +26 -0
- package/dist/csp.d.mts +32 -0
- package/dist/csp.mjs +113 -0
- package/dist/env-validation.d.mts +42 -0
- package/dist/env-validation.mjs +58 -0
- package/dist/error-response.d.mts +9 -0
- package/dist/error-response.mjs +19 -0
- package/dist/headers.d.mts +8 -0
- package/dist/headers.mjs +34 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.mjs +18 -0
- package/dist/nonce-rewrite.d.mts +14 -0
- package/dist/nonce-rewrite.mjs +104 -0
- package/dist/origin-fetch.d.mts +21 -0
- package/dist/origin-fetch.mjs +36 -0
- package/dist/proxy.d.mts +34 -0
- package/dist/proxy.mjs +92 -0
- package/dist/security-headers.d.mts +25 -0
- package/dist/security-headers.mjs +68 -0
- package/dist/sentry-dsn.d.mts +13 -0
- package/dist/sentry-dsn.mjs +38 -0
- package/dist/sentry-tunnel-diagnostics.d.mts +17 -0
- package/dist/sentry-tunnel-diagnostics.mjs +73 -0
- package/dist/sentry-tunnel.d.mts +17 -0
- package/dist/sentry-tunnel.mjs +104 -0
- package/package.json +50 -4
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const FETCH_METADATA_HEADERS = ['sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest'];
|
|
2
|
+
const SAFE_NAVIGATION_METHODS = new Set(['GET', 'HEAD']);
|
|
3
|
+
// Browsers send this Origin on same-origin requests made under a no-referrer policy.
|
|
4
|
+
const NO_REFERRER_ORIGIN = 'null';
|
|
5
|
+
export function hasBrowserFetchMetadata(headers) {
|
|
6
|
+
return FETCH_METADATA_HEADERS.some((name) => headers.has(name));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Browser evidence that a request came from the web app on this origin, or from a top-level
|
|
10
|
+
* navigation. Forgeable by non-browser callers, so it only classifies telemetry.
|
|
11
|
+
*/
|
|
12
|
+
export function isWebBrowserRequest(headers, request) {
|
|
13
|
+
if (isTopLevelNavigation(headers, request.method))
|
|
14
|
+
return true;
|
|
15
|
+
if (headers.get('sec-fetch-site')?.toLowerCase() !== 'same-origin')
|
|
16
|
+
return false;
|
|
17
|
+
const origin = headers.get('origin');
|
|
18
|
+
const referer = headers.get('referer');
|
|
19
|
+
return ((origin === null ||
|
|
20
|
+
origin === NO_REFERRER_ORIGIN ||
|
|
21
|
+
parseOrigin(origin) === request.requestOrigin) &&
|
|
22
|
+
(referer === null || parseOrigin(referer) === request.requestOrigin));
|
|
23
|
+
}
|
|
24
|
+
function isTopLevelNavigation(headers, method) {
|
|
25
|
+
return (SAFE_NAVIGATION_METHODS.has(method.toUpperCase()) &&
|
|
26
|
+
headers.get('sec-fetch-mode')?.toLowerCase() === 'navigate' &&
|
|
27
|
+
headers.get('sec-fetch-dest')?.toLowerCase() === 'document');
|
|
28
|
+
}
|
|
29
|
+
function parseOrigin(value) {
|
|
30
|
+
try {
|
|
31
|
+
return new URL(value).origin;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Normalizes client-identification headers before forwarding. Synthetic traffic gets only the
|
|
39
|
+
* request-kind header; browser-evidenced traffic gets the injected web headers; anything else
|
|
40
|
+
* keeps non-web client headers and loses forged web claims.
|
|
41
|
+
*/
|
|
42
|
+
export function applyClientInfoHeaders(headers, policy, options) {
|
|
43
|
+
const clear = () => {
|
|
44
|
+
for (const name of policy.clientInfoHeaderNames)
|
|
45
|
+
headers.delete(name);
|
|
46
|
+
};
|
|
47
|
+
headers.delete(policy.requestKindHeader);
|
|
48
|
+
if (options.requestKind) {
|
|
49
|
+
clear();
|
|
50
|
+
headers.set(policy.requestKindHeader, options.requestKind);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!hasBrowserFetchMetadata(headers)) {
|
|
54
|
+
if (headers.get(policy.webClientHeader.name) === policy.webClientHeader.value)
|
|
55
|
+
clear();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
clear();
|
|
59
|
+
if (!isWebBrowserRequest(headers, options))
|
|
60
|
+
return;
|
|
61
|
+
for (const [name, value] of Object.entries(policy.webClientHeaders))
|
|
62
|
+
headers.set(name, value);
|
|
63
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { parseCookies } from '@vouchington/utils/cookies';
|
|
2
|
+
export { parseCookies };
|
|
3
|
+
/**
|
|
4
|
+
* Sets cookies in a request `cookie` header value. `validate` runs per replacement and should
|
|
5
|
+
* throw for values that are unsafe to forward.
|
|
6
|
+
*/
|
|
7
|
+
export declare function replaceCookies(cookieHeader: string | null | undefined, replacements: Readonly<Record<string, string | undefined>>, validate?: (name: string, value: string) => void): string;
|
|
8
|
+
/** Removes named cookies; returns null when no cookie remains. */
|
|
9
|
+
export declare function stripCookies(cookieHeader: string | null | undefined, cookieNamesToStrip: Iterable<string>): string | null;
|
package/dist/cookies.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parseCookies } from '@vouchington/utils/cookies';
|
|
2
|
+
export { parseCookies };
|
|
3
|
+
function serialize(cookies) {
|
|
4
|
+
return [...cookies.entries()].map(([name, value]) => `${name}=${value}`).join('; ');
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Sets cookies in a request `cookie` header value. `validate` runs per replacement and should
|
|
8
|
+
* throw for values that are unsafe to forward.
|
|
9
|
+
*/
|
|
10
|
+
export function replaceCookies(cookieHeader, replacements, validate) {
|
|
11
|
+
const cookies = parseCookies(cookieHeader);
|
|
12
|
+
for (const [name, value] of Object.entries(replacements)) {
|
|
13
|
+
if (value === undefined)
|
|
14
|
+
continue;
|
|
15
|
+
validate?.(name, value);
|
|
16
|
+
cookies.set(name, value);
|
|
17
|
+
}
|
|
18
|
+
return serialize(cookies);
|
|
19
|
+
}
|
|
20
|
+
/** Removes named cookies; returns null when no cookie remains. */
|
|
21
|
+
export function stripCookies(cookieHeader, cookieNamesToStrip) {
|
|
22
|
+
const cookies = parseCookies(cookieHeader);
|
|
23
|
+
for (const name of cookieNamesToStrip)
|
|
24
|
+
cookies.delete(name);
|
|
25
|
+
return cookies.size === 0 ? null : serialize(cookies);
|
|
26
|
+
}
|
package/dist/csp.d.mts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type CspDirectives = Record<string, readonly string[]>;
|
|
2
|
+
export type CspOptions = {
|
|
3
|
+
/** Per-request nonce; adds `'nonce-…'` to `script-src` when set. */
|
|
4
|
+
nonce?: string;
|
|
5
|
+
/** `false` enables the development relaxations (`'unsafe-eval'`, `http:` images). */
|
|
6
|
+
production?: boolean;
|
|
7
|
+
/** Static-asset origin (see {@link normalizeAssetOrigin}) added to script, style, img, font, connect. */
|
|
8
|
+
assetOrigin?: string;
|
|
9
|
+
/** Extra sources appended to a directive, e.g. analytics or captcha origins. */
|
|
10
|
+
sources?: Readonly<Record<string, readonly string[]>>;
|
|
11
|
+
/** Whole-directive replacements; `null` removes a directive. Applied before `sources`. */
|
|
12
|
+
directives?: Readonly<Record<string, readonly string[] | null>>;
|
|
13
|
+
};
|
|
14
|
+
export declare const DEFAULT_CSP_DIRECTIVES: Readonly<CspDirectives>;
|
|
15
|
+
/**
|
|
16
|
+
* Normalizes an asset origin from configuration. Accepts `https:` origins and
|
|
17
|
+
* `http://localhost`; strips path, query, and hash so a stray character cannot alter the policy.
|
|
18
|
+
* Returns an empty string for absent or invalid values.
|
|
19
|
+
*/
|
|
20
|
+
export declare function normalizeAssetOrigin(raw?: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Parses a JSON array of exact HTTPS origins (for example upload hosts). Throws on any malformed,
|
|
23
|
+
* duplicate, non-origin, or disallowed entry so a bad deployment value fails loudly.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseOriginAllowlist(raw: string | undefined, options?: {
|
|
26
|
+
isAllowedHostname?: (hostname: string) => boolean;
|
|
27
|
+
label?: string;
|
|
28
|
+
}): string[];
|
|
29
|
+
/** Serializes directives as a header value, rejecting sources that could inject a directive. */
|
|
30
|
+
export declare function serializeCsp(directives: Readonly<Record<string, readonly string[]>>): string;
|
|
31
|
+
/** Builds a nonce-based CSP from safe defaults plus injected origins. */
|
|
32
|
+
export declare function buildCsp(options?: CspOptions): string;
|
package/dist/csp.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export const DEFAULT_CSP_DIRECTIVES = {
|
|
2
|
+
'default-src': ["'self'"],
|
|
3
|
+
'script-src': ["'self'"],
|
|
4
|
+
'style-src': ["'self'", "'unsafe-inline'"],
|
|
5
|
+
'img-src': ["'self'", 'data:', 'blob:', 'https:'],
|
|
6
|
+
'font-src': ["'self'"],
|
|
7
|
+
'frame-src': ["'self'"],
|
|
8
|
+
'media-src': ["'self'", 'https:', 'blob:'],
|
|
9
|
+
'connect-src': ["'self'"],
|
|
10
|
+
'object-src': ["'none'"],
|
|
11
|
+
'base-uri': ["'self'"],
|
|
12
|
+
'form-action': ["'self'"],
|
|
13
|
+
'frame-ancestors': ["'self'"],
|
|
14
|
+
};
|
|
15
|
+
const ASSET_DIRECTIVES = ['script-src', 'style-src', 'img-src', 'font-src', 'connect-src'];
|
|
16
|
+
const UNSAFE_SOURCE = /[\s;,]/u;
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes an asset origin from configuration. Accepts `https:` origins and
|
|
19
|
+
* `http://localhost`; strips path, query, and hash so a stray character cannot alter the policy.
|
|
20
|
+
* Returns an empty string for absent or invalid values.
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeAssetOrigin(raw) {
|
|
23
|
+
const trimmed = raw?.trim() ?? '';
|
|
24
|
+
if (!trimmed)
|
|
25
|
+
return '';
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(trimmed);
|
|
28
|
+
if (url.protocol === 'https:')
|
|
29
|
+
return url.origin;
|
|
30
|
+
if (url.protocol === 'http:' && url.hostname === 'localhost')
|
|
31
|
+
return url.origin;
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parses a JSON array of exact HTTPS origins (for example upload hosts). Throws on any malformed,
|
|
40
|
+
* duplicate, non-origin, or disallowed entry so a bad deployment value fails loudly.
|
|
41
|
+
*/
|
|
42
|
+
export function parseOriginAllowlist(raw, options = {}) {
|
|
43
|
+
const fail = () => {
|
|
44
|
+
throw new Error(`${options.label ?? 'Origin allowlist'} must contain exact HTTPS origins`);
|
|
45
|
+
};
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(raw ?? '');
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return fail();
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
54
|
+
return fail();
|
|
55
|
+
const origins = parsed.map((value) => {
|
|
56
|
+
if (typeof value !== 'string')
|
|
57
|
+
return fail();
|
|
58
|
+
try {
|
|
59
|
+
const url = new URL(value);
|
|
60
|
+
const allowed = options.isAllowedHostname?.(url.hostname) ?? true;
|
|
61
|
+
if (url.protocol === 'https:' && url.origin === value && allowed)
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return fail();
|
|
66
|
+
}
|
|
67
|
+
return fail();
|
|
68
|
+
});
|
|
69
|
+
if (new Set(origins).size !== origins.length)
|
|
70
|
+
return fail();
|
|
71
|
+
return origins;
|
|
72
|
+
}
|
|
73
|
+
/** Serializes directives as a header value, rejecting sources that could inject a directive. */
|
|
74
|
+
export function serializeCsp(directives) {
|
|
75
|
+
return Object.entries(directives)
|
|
76
|
+
.map(([name, sources]) => {
|
|
77
|
+
for (const source of sources) {
|
|
78
|
+
if (!source || UNSAFE_SOURCE.test(source)) {
|
|
79
|
+
throw new Error(`Invalid CSP source in ${name}: ${JSON.stringify(source)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return [name, ...sources].join(' ');
|
|
83
|
+
})
|
|
84
|
+
.join('; ');
|
|
85
|
+
}
|
|
86
|
+
/** Builds a nonce-based CSP from safe defaults plus injected origins. */
|
|
87
|
+
export function buildCsp(options = {}) {
|
|
88
|
+
const directives = {};
|
|
89
|
+
for (const [name, sources] of Object.entries(DEFAULT_CSP_DIRECTIVES))
|
|
90
|
+
directives[name] = [...sources];
|
|
91
|
+
const add = (name, extra) => {
|
|
92
|
+
directives[name] = [...(directives[name] ?? []), ...extra];
|
|
93
|
+
};
|
|
94
|
+
if (options.nonce)
|
|
95
|
+
add('script-src', [`'nonce-${options.nonce}'`]);
|
|
96
|
+
if (options.production === false) {
|
|
97
|
+
add('script-src', ["'unsafe-eval'"]);
|
|
98
|
+
add('img-src', ['http:']);
|
|
99
|
+
}
|
|
100
|
+
const assetOrigin = normalizeAssetOrigin(options.assetOrigin);
|
|
101
|
+
if (assetOrigin)
|
|
102
|
+
for (const name of ASSET_DIRECTIVES)
|
|
103
|
+
add(name, [assetOrigin]);
|
|
104
|
+
for (const [name, replacement] of Object.entries(options.directives ?? {})) {
|
|
105
|
+
if (replacement === null)
|
|
106
|
+
delete directives[name];
|
|
107
|
+
else
|
|
108
|
+
directives[name] = [...replacement];
|
|
109
|
+
}
|
|
110
|
+
for (const [name, extra] of Object.entries(options.sources ?? {}))
|
|
111
|
+
add(name, extra);
|
|
112
|
+
return serializeCsp(directives);
|
|
113
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type EnvWarner = (key: string, message: string, level?: 'error' | 'warn') => void;
|
|
2
|
+
/**
|
|
3
|
+
* Creates a warn-once reporter. Create it at module scope so each isolate logs a given key once
|
|
4
|
+
* instead of on every request.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createEnvWarner(log?: {
|
|
7
|
+
error: (message: string) => void;
|
|
8
|
+
warn: (message: string) => void;
|
|
9
|
+
}): EnvWarner;
|
|
10
|
+
/** Anything truthy other than `false` is production, so ambiguous values fail closed. */
|
|
11
|
+
export declare function isProductionMode(env: {
|
|
12
|
+
PRODUCTION?: string;
|
|
13
|
+
}): boolean;
|
|
14
|
+
/** True when `PRODUCTION` is set to something other than exactly `true` or `false`. */
|
|
15
|
+
export declare function isProductionValueInvalid(env: {
|
|
16
|
+
PRODUCTION?: string;
|
|
17
|
+
}): boolean;
|
|
18
|
+
export declare function isSecretValid(value: string | undefined, minLength: number): value is string;
|
|
19
|
+
export declare function warnIfProductionValueInvalid(warn: EnvWarner, env: {
|
|
20
|
+
PRODUCTION?: string;
|
|
21
|
+
}): void;
|
|
22
|
+
export type SecretRequirement = {
|
|
23
|
+
/** Binding name used in messages and as the warn-once key. */
|
|
24
|
+
name: string;
|
|
25
|
+
value: string | undefined;
|
|
26
|
+
minLength: number;
|
|
27
|
+
/** Appended after the dash when the secret is missing or empty. */
|
|
28
|
+
missingConsequence: string;
|
|
29
|
+
/** Appended after the dash when the secret is shorter than `minLength`. */
|
|
30
|
+
shortConsequence?: string;
|
|
31
|
+
generateHint?: string;
|
|
32
|
+
};
|
|
33
|
+
/** Warns once when a required secret is absent, or present but too short. */
|
|
34
|
+
export declare function warnIfSecretMisconfigured(warn: EnvWarner, secret: SecretRequirement): void;
|
|
35
|
+
/** Warns once when an asset-origin binding is set but would be dropped by `normalizeAssetOrigin`. */
|
|
36
|
+
export declare function warnIfAssetOriginInvalid(warn: EnvWarner, name: string, value?: string): void;
|
|
37
|
+
/** Warns once when a DSN binding is present but unparseable, or absent while `required`. */
|
|
38
|
+
export declare function warnIfDsnInvalid(warn: EnvWarner, binding: {
|
|
39
|
+
name: string;
|
|
40
|
+
value: string | undefined;
|
|
41
|
+
required?: boolean;
|
|
42
|
+
}): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { normalizeAssetOrigin } from './csp.mjs';
|
|
2
|
+
import { parseSentryDsn } from './sentry-dsn.mjs';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a warn-once reporter. Create it at module scope so each isolate logs a given key once
|
|
5
|
+
* instead of on every request.
|
|
6
|
+
*/
|
|
7
|
+
export function createEnvWarner(log = console) {
|
|
8
|
+
const emitted = new Set();
|
|
9
|
+
return (key, message, level = 'error') => {
|
|
10
|
+
if (emitted.has(key))
|
|
11
|
+
return;
|
|
12
|
+
emitted.add(key);
|
|
13
|
+
log[level](message);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Anything truthy other than `false` is production, so ambiguous values fail closed. */
|
|
17
|
+
export function isProductionMode(env) {
|
|
18
|
+
const production = env.PRODUCTION?.toLowerCase();
|
|
19
|
+
return Boolean(production) && production !== 'false';
|
|
20
|
+
}
|
|
21
|
+
/** True when `PRODUCTION` is set to something other than exactly `true` or `false`. */
|
|
22
|
+
export function isProductionValueInvalid(env) {
|
|
23
|
+
const production = env.PRODUCTION?.toLowerCase();
|
|
24
|
+
return Boolean(production) && production !== 'true' && production !== 'false';
|
|
25
|
+
}
|
|
26
|
+
export function isSecretValid(value, minLength) {
|
|
27
|
+
return value !== undefined && value.length >= minLength;
|
|
28
|
+
}
|
|
29
|
+
export function warnIfProductionValueInvalid(warn, env) {
|
|
30
|
+
if (!isProductionValueInvalid(env))
|
|
31
|
+
return;
|
|
32
|
+
warn('production-invalid', `PRODUCTION is set to '${env.PRODUCTION}' but must be exactly 'true' or 'false'; treating it as production mode so security controls fail closed`);
|
|
33
|
+
}
|
|
34
|
+
/** Warns once when a required secret is absent, or present but too short. */
|
|
35
|
+
export function warnIfSecretMisconfigured(warn, secret) {
|
|
36
|
+
const hint = secret.generateHint ?? 'openssl rand -hex 32';
|
|
37
|
+
if (!secret.value) {
|
|
38
|
+
warn(`${secret.name}:missing`, `${secret.name} is not set or empty — ${secret.missingConsequence}; generate with: ${hint}`);
|
|
39
|
+
}
|
|
40
|
+
else if (secret.value.length < secret.minLength) {
|
|
41
|
+
const consequence = secret.shortConsequence ?? secret.missingConsequence;
|
|
42
|
+
warn(`${secret.name}:short`, `${secret.name} is too short (minimum ${secret.minLength} characters) — ${consequence}; generate with: ${hint}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Warns once when an asset-origin binding is set but would be dropped by `normalizeAssetOrigin`. */
|
|
46
|
+
export function warnIfAssetOriginInvalid(warn, name, value) {
|
|
47
|
+
if (value?.trim() && !normalizeAssetOrigin(value)) {
|
|
48
|
+
warn(`${name}:invalid`, `${name} failed validation and will be ignored in CSP — static assets may be blocked. Value must be a valid https:// origin or http://localhost:<port> for local development.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Warns once when a DSN binding is present but unparseable, or absent while `required`. */
|
|
52
|
+
export function warnIfDsnInvalid(warn, binding) {
|
|
53
|
+
if (parseSentryDsn(binding.value))
|
|
54
|
+
return;
|
|
55
|
+
if (binding.value === undefined && !binding.required)
|
|
56
|
+
return;
|
|
57
|
+
warn(`${binding.name}:dsn`, `${binding.name} is missing or not a valid Sentry DSN`, 'warn');
|
|
58
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const NO_STORE_HEADERS: {
|
|
2
|
+
readonly 'cache-control': 'no-store, max-age=0, must-revalidate';
|
|
3
|
+
readonly 'cdn-cache-control': 'no-store';
|
|
4
|
+
readonly 'cloudflare-cdn-cache-control': 'no-store';
|
|
5
|
+
};
|
|
6
|
+
/** JSON `{ message, code }` error that no cache layer will store. */
|
|
7
|
+
export declare function edgeErrorResponse(status: number, message: string, code: string, headers?: Record<string, string>): Response;
|
|
8
|
+
/** Returns 2xx/3xx unchanged; 4xx/5xx gain no-store headers with `set-cookie` preserved. */
|
|
9
|
+
export declare function withFailureNoStoreHeaders(response: Response): Response;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { withHeaders } from './headers.mjs';
|
|
2
|
+
export const NO_STORE_HEADERS = {
|
|
3
|
+
'cache-control': 'no-store, max-age=0, must-revalidate',
|
|
4
|
+
'cdn-cache-control': 'no-store',
|
|
5
|
+
'cloudflare-cdn-cache-control': 'no-store',
|
|
6
|
+
};
|
|
7
|
+
/** JSON `{ message, code }` error that no cache layer will store. */
|
|
8
|
+
export function edgeErrorResponse(status, message, code, headers) {
|
|
9
|
+
return new Response(JSON.stringify({ message, code }), {
|
|
10
|
+
status,
|
|
11
|
+
headers: { 'content-type': 'application/json', ...headers, ...NO_STORE_HEADERS },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/** Returns 2xx/3xx unchanged; 4xx/5xx gain no-store headers with `set-cookie` preserved. */
|
|
15
|
+
export function withFailureNoStoreHeaders(response) {
|
|
16
|
+
if (response.status < 400)
|
|
17
|
+
return response;
|
|
18
|
+
return withHeaders(response, NO_STORE_HEADERS);
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Copies headers entry by entry so repeated `set-cookie` values survive. */
|
|
2
|
+
export declare function copyHeaders(headers: Headers): Headers;
|
|
3
|
+
/** Returns a response with `entries` set, replacing same-named headers and keeping `set-cookie`. */
|
|
4
|
+
export declare function withHeaders(response: Response, entries: Record<string, string>): Response;
|
|
5
|
+
/** Returns a response with `entries` appended after the existing headers. */
|
|
6
|
+
export declare function appendHeaders(response: Response, entries: ReadonlyArray<readonly [string, string]>): Response;
|
|
7
|
+
/** Deletes every named header in place. */
|
|
8
|
+
export declare function deleteHeaders(headers: Headers, names: Iterable<string>): void;
|
package/dist/headers.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Copies headers entry by entry so repeated `set-cookie` values survive. */
|
|
2
|
+
export function copyHeaders(headers) {
|
|
3
|
+
const copied = new Headers();
|
|
4
|
+
headers.forEach((value, key) => {
|
|
5
|
+
copied.append(key, value);
|
|
6
|
+
});
|
|
7
|
+
return copied;
|
|
8
|
+
}
|
|
9
|
+
function rebuild(response, headers) {
|
|
10
|
+
return new Response(response.body, {
|
|
11
|
+
status: response.status,
|
|
12
|
+
statusText: response.statusText,
|
|
13
|
+
headers,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/** Returns a response with `entries` set, replacing same-named headers and keeping `set-cookie`. */
|
|
17
|
+
export function withHeaders(response, entries) {
|
|
18
|
+
const headers = copyHeaders(response.headers);
|
|
19
|
+
for (const [key, value] of Object.entries(entries))
|
|
20
|
+
headers.set(key, value);
|
|
21
|
+
return rebuild(response, headers);
|
|
22
|
+
}
|
|
23
|
+
/** Returns a response with `entries` appended after the existing headers. */
|
|
24
|
+
export function appendHeaders(response, entries) {
|
|
25
|
+
const headers = copyHeaders(response.headers);
|
|
26
|
+
for (const [key, value] of entries)
|
|
27
|
+
headers.append(key, value);
|
|
28
|
+
return rebuild(response, headers);
|
|
29
|
+
}
|
|
30
|
+
/** Deletes every named header in place. */
|
|
31
|
+
export function deleteHeaders(headers, names) {
|
|
32
|
+
for (const name of names)
|
|
33
|
+
headers.delete(name);
|
|
34
|
+
}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from './basic-auth-credentials.mts';
|
|
2
|
+
export * from './basic-auth.mts';
|
|
3
|
+
export * from './cache-policy.mts';
|
|
4
|
+
export * from './cache-url.mts';
|
|
5
|
+
export * from './cache-vary.mts';
|
|
6
|
+
export * from './cached-origin.mts';
|
|
7
|
+
export * from './client-info.mts';
|
|
8
|
+
export * from './cookies.mts';
|
|
9
|
+
export * from './csp.mts';
|
|
10
|
+
export * from './env-validation.mts';
|
|
11
|
+
export * from './error-response.mts';
|
|
12
|
+
export * from './headers.mts';
|
|
13
|
+
export * from './nonce-rewrite.mts';
|
|
14
|
+
export * from './origin-fetch.mts';
|
|
15
|
+
export * from './proxy.mts';
|
|
16
|
+
export * from './security-headers.mts';
|
|
17
|
+
export * from './sentry-dsn.mts';
|
|
18
|
+
export * from './sentry-tunnel.mts';
|
|
19
|
+
export type { SentryTunnelDiagnosticDetails, SentryTunnelLog, SentryTunnelRejectReason, } from './sentry-tunnel-diagnostics.mts';
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export * from './basic-auth-credentials.mjs';
|
|
2
|
+
export * from './basic-auth.mjs';
|
|
3
|
+
export * from './cache-policy.mjs';
|
|
4
|
+
export * from './cache-url.mjs';
|
|
5
|
+
export * from './cache-vary.mjs';
|
|
6
|
+
export * from './cached-origin.mjs';
|
|
7
|
+
export * from './client-info.mjs';
|
|
8
|
+
export * from './cookies.mjs';
|
|
9
|
+
export * from './csp.mjs';
|
|
10
|
+
export * from './env-validation.mjs';
|
|
11
|
+
export * from './error-response.mjs';
|
|
12
|
+
export * from './headers.mjs';
|
|
13
|
+
export * from './nonce-rewrite.mjs';
|
|
14
|
+
export * from './origin-fetch.mjs';
|
|
15
|
+
export * from './proxy.mjs';
|
|
16
|
+
export * from './security-headers.mjs';
|
|
17
|
+
export * from './sentry-dsn.mjs';
|
|
18
|
+
export * from './sentry-tunnel.mjs';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type NonceRewriteOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* `<meta name="…">` tags removed from the rewritten HTML, for example trace-context tags that
|
|
4
|
+
* belong to the cache-fill request rather than the client that receives the cached bytes.
|
|
5
|
+
*/
|
|
6
|
+
stripMetaNames?: readonly string[];
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Streams an HTML response, replacing a cache placeholder nonce with a per-request nonce. Bounded
|
|
10
|
+
* tails keep replacements correct across chunk boundaries without buffering the document.
|
|
11
|
+
* The `content-security-policy` header is the caller's to rebuild with the real nonce.
|
|
12
|
+
*/
|
|
13
|
+
export declare function rewritePlaceholderNonce(response: Response, placeholderNonce: string, realNonce: string, options?: NonceRewriteOptions): Response;
|
|
14
|
+
export declare function createNonceRewriteStream(placeholderNonce: string, realNonce: string, options?: NonceRewriteOptions): TransformStream<Uint8Array, Uint8Array>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const MAX_META_TAG_CHARS = 16 * 1024;
|
|
2
|
+
const OUTPUT_BATCH_CHARS = 16 * 1024;
|
|
3
|
+
function escapeRegExp(value) {
|
|
4
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Streams an HTML response, replacing a cache placeholder nonce with a per-request nonce. Bounded
|
|
8
|
+
* tails keep replacements correct across chunk boundaries without buffering the document.
|
|
9
|
+
* The `content-security-policy` header is the caller's to rebuild with the real nonce.
|
|
10
|
+
*/
|
|
11
|
+
export function rewritePlaceholderNonce(response, placeholderNonce, realNonce, options = {}) {
|
|
12
|
+
if (!response.body || !placeholderNonce || placeholderNonce === realNonce)
|
|
13
|
+
return response;
|
|
14
|
+
const headers = new Headers(response.headers);
|
|
15
|
+
// A byte transform cannot keep the wire length or encoding, so neither header stays valid.
|
|
16
|
+
headers.delete('content-encoding');
|
|
17
|
+
headers.delete('content-length');
|
|
18
|
+
return new Response(response.body.pipeThrough(createNonceRewriteStream(placeholderNonce, realNonce, options)), { status: response.status, statusText: response.statusText, headers });
|
|
19
|
+
}
|
|
20
|
+
export function createNonceRewriteStream(placeholderNonce, realNonce, options = {}) {
|
|
21
|
+
const decoder = new TextDecoder();
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
const names = options.stripMetaNames ?? [];
|
|
24
|
+
const metaTag = names.length === 0
|
|
25
|
+
? undefined
|
|
26
|
+
: new RegExp(`^<meta\\s+name="(?:${names.map(escapeRegExp).join('|')})"[^>]*>$`, 'iu');
|
|
27
|
+
let controller;
|
|
28
|
+
let nonceCandidate = '';
|
|
29
|
+
let metaCandidate = '';
|
|
30
|
+
let outputBuffer = '';
|
|
31
|
+
const flushOutput = () => {
|
|
32
|
+
if (!outputBuffer)
|
|
33
|
+
return;
|
|
34
|
+
controller.enqueue(encoder.encode(outputBuffer));
|
|
35
|
+
outputBuffer = '';
|
|
36
|
+
};
|
|
37
|
+
const appendOutput = (output) => {
|
|
38
|
+
outputBuffer += output;
|
|
39
|
+
if (outputBuffer.length >= OUTPUT_BATCH_CHARS)
|
|
40
|
+
flushOutput();
|
|
41
|
+
};
|
|
42
|
+
const appendMetaFiltered = (text) => {
|
|
43
|
+
for (const char of text) {
|
|
44
|
+
if (!metaTag) {
|
|
45
|
+
appendOutput(char);
|
|
46
|
+
}
|
|
47
|
+
else if (!metaCandidate) {
|
|
48
|
+
if (char === '<')
|
|
49
|
+
metaCandidate = char;
|
|
50
|
+
else
|
|
51
|
+
appendOutput(char);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
metaCandidate += char;
|
|
55
|
+
const lower = metaCandidate.toLowerCase();
|
|
56
|
+
if ('<meta'.startsWith(lower))
|
|
57
|
+
continue;
|
|
58
|
+
if (!lower.startsWith('<meta') || metaCandidate.length > MAX_META_TAG_CHARS) {
|
|
59
|
+
appendOutput(metaCandidate);
|
|
60
|
+
metaCandidate = '';
|
|
61
|
+
}
|
|
62
|
+
else if (char === '>') {
|
|
63
|
+
if (!metaTag.test(metaCandidate))
|
|
64
|
+
appendOutput(metaCandidate);
|
|
65
|
+
metaCandidate = '';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const longestPlaceholderPrefix = (value) => {
|
|
71
|
+
const max = Math.min(value.length, placeholderNonce.length - 1);
|
|
72
|
+
for (let length = max; length > 0; length -= 1) {
|
|
73
|
+
if (value.endsWith(placeholderNonce.slice(0, length)))
|
|
74
|
+
return length;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
};
|
|
78
|
+
const appendNonceRewritten = (text) => {
|
|
79
|
+
for (const char of text) {
|
|
80
|
+
const candidate = nonceCandidate + char;
|
|
81
|
+
if (candidate === placeholderNonce) {
|
|
82
|
+
appendMetaFiltered(realNonce);
|
|
83
|
+
nonceCandidate = '';
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const prefixLength = longestPlaceholderPrefix(candidate);
|
|
87
|
+
appendMetaFiltered(candidate.slice(0, candidate.length - prefixLength));
|
|
88
|
+
nonceCandidate = candidate.slice(candidate.length - prefixLength);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return new TransformStream({
|
|
92
|
+
transform(chunk, ctl) {
|
|
93
|
+
controller = ctl;
|
|
94
|
+
appendNonceRewritten(decoder.decode(chunk, { stream: true }));
|
|
95
|
+
},
|
|
96
|
+
flush(ctl) {
|
|
97
|
+
controller = ctl;
|
|
98
|
+
appendNonceRewritten(decoder.decode());
|
|
99
|
+
appendMetaFiltered(nonceCandidate);
|
|
100
|
+
appendOutput(metaCandidate);
|
|
101
|
+
flushOutput();
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const DROPPED_SOCKET_PATTERN: RegExp;
|
|
2
|
+
export type OriginFetchOptions = {
|
|
3
|
+
/** Defaults to global `fetch`; inject a service binding's fetch or a test double. */
|
|
4
|
+
fetch?: (request: Request) => Promise<Response>;
|
|
5
|
+
/** Receives the final error before the 502 is produced. */
|
|
6
|
+
onError?: (error: unknown) => void;
|
|
7
|
+
/** Overrides which errors are safe to retry once on a fresh connection. */
|
|
8
|
+
isRetryable?: (error: unknown) => boolean;
|
|
9
|
+
};
|
|
10
|
+
export type OriginFetchResult = {
|
|
11
|
+
duration: number;
|
|
12
|
+
response: Response;
|
|
13
|
+
} | {
|
|
14
|
+
error: Response;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Fetches an origin request. GET and HEAD are retried once after a dropped socket; mutating
|
|
18
|
+
* methods are single-attempt because a lost response may already have been applied. Failures
|
|
19
|
+
* become a no-store 502 under `error`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function fetchOriginResponse(originRequest: Request, options?: OriginFetchOptions): Promise<OriginFetchResult>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { edgeErrorResponse } from './error-response.mjs';
|
|
2
|
+
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD']);
|
|
3
|
+
export const DROPPED_SOCKET_PATTERN = /network connection lost|connection reset by peer|ECONNRESET|ECONNREFUSED|EPIPE|UND_ERR_SOCKET/i;
|
|
4
|
+
function isDroppedSocket(error) {
|
|
5
|
+
return error instanceof Error && DROPPED_SOCKET_PATTERN.test(error.message);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Fetches an origin request. GET and HEAD are retried once after a dropped socket; mutating
|
|
9
|
+
* methods are single-attempt because a lost response may already have been applied. Failures
|
|
10
|
+
* become a no-store 502 under `error`.
|
|
11
|
+
*/
|
|
12
|
+
export async function fetchOriginResponse(originRequest, options = {}) {
|
|
13
|
+
const send = options.fetch ?? fetch;
|
|
14
|
+
const isRetryable = options.isRetryable ?? isDroppedSocket;
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
const canRetry = IDEMPOTENT_METHODS.has(originRequest.method.toUpperCase());
|
|
17
|
+
const failure = (error) => {
|
|
18
|
+
options.onError?.(error);
|
|
19
|
+
return { error: edgeErrorResponse(502, 'Bad Gateway', 'BAD_GATEWAY') };
|
|
20
|
+
};
|
|
21
|
+
// Clone first so a retry still has an unread request.
|
|
22
|
+
const firstRequest = canRetry ? originRequest.clone() : originRequest;
|
|
23
|
+
try {
|
|
24
|
+
return { duration: Date.now() - start, response: await send(firstRequest) };
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!canRetry || !isRetryable(error))
|
|
28
|
+
return failure(error);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return { duration: Date.now() - start, response: await send(originRequest) };
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return failure(error);
|
|
35
|
+
}
|
|
36
|
+
}
|