@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
package/dist/proxy.d.mts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Client-supplied forwarding and method-override headers an origin must never trust. */
|
|
2
|
+
export declare const CLIENT_SUPPLIED_PROXY_HEADERS_TO_STRIP: readonly ['cf-connecting-ip', 'forwarded', 'true-client-ip', 'via', 'x-forwarded-for', 'x-forwarded-port', 'x-forwarded-prefix', 'x-forwarded-scheme', 'x-forwarded-server', 'x-forwarded-ssl', 'x-forwarded-uri', 'x-http-method', 'x-http-method-override', 'x-method-override', 'x-original-url', 'x-real-ip', 'x-rewrite-url'];
|
|
3
|
+
/** Next.js internal headers that external clients must never send (middleware and cache CVEs). */
|
|
4
|
+
export declare const NEXTJS_INTERNAL_HEADERS_TO_STRIP: readonly ['x-middleware-subrequest', 'x-middleware-subrequest-id', 'x-nextjs-data', 'x-now-route-matches', 'next-resume', 'next-action-nonce'];
|
|
5
|
+
export type OriginRequestOptions = {
|
|
6
|
+
/** Absolute origin URL; only its protocol and host replace the request's. */
|
|
7
|
+
origin: string;
|
|
8
|
+
/** Cookie names removed before forwarding. */
|
|
9
|
+
stripCookieNames?: ReadonlySet<string>;
|
|
10
|
+
stripAllCookies?: boolean;
|
|
11
|
+
/** Path and query merged over the request URL (override query params win). */
|
|
12
|
+
pathOverride?: string | null;
|
|
13
|
+
requestId?: string;
|
|
14
|
+
requestIdHeader?: string;
|
|
15
|
+
/** Shared secret proving the request came through the Worker. Any client copy is removed. */
|
|
16
|
+
sharedSecret?: {
|
|
17
|
+
header: string;
|
|
18
|
+
value: string | undefined;
|
|
19
|
+
};
|
|
20
|
+
/** Keep a non-Basic `authorization` header (for example Bearer). Dropped by default. */
|
|
21
|
+
preserveNonBasicAuthorization?: boolean;
|
|
22
|
+
/** Keep a Basic `authorization` header. Dropped by default. */
|
|
23
|
+
preserveBasicAuthorization?: boolean;
|
|
24
|
+
/** Force `accept-encoding: identity`, required when the response enters a shared cache. */
|
|
25
|
+
forceIdentityEncoding?: boolean;
|
|
26
|
+
/** Hostnames that also get identity encoding (local development). */
|
|
27
|
+
identityEncodingHostnames?: readonly string[];
|
|
28
|
+
/** Extra headers deleted from the client request, for example identity or control headers. */
|
|
29
|
+
stripHeaders?: Iterable<string>;
|
|
30
|
+
/** Headers set last, after every strip. */
|
|
31
|
+
setHeaders?: Readonly<Record<string, string>>;
|
|
32
|
+
};
|
|
33
|
+
/** Builds the sanitized request forwarded to an origin. Redirects stay manual. */
|
|
34
|
+
export declare function buildOriginRequest(request: Request, options: OriginRequestOptions): Request;
|
package/dist/proxy.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { stripCookies } from './cookies.mjs';
|
|
2
|
+
import { deleteHeaders } from './headers.mjs';
|
|
3
|
+
const METHOD_WITHOUT_BODY = new Set(['GET', 'HEAD']);
|
|
4
|
+
/** Client-supplied forwarding and method-override headers an origin must never trust. */
|
|
5
|
+
export const CLIENT_SUPPLIED_PROXY_HEADERS_TO_STRIP = [
|
|
6
|
+
'cf-connecting-ip',
|
|
7
|
+
'forwarded',
|
|
8
|
+
'true-client-ip',
|
|
9
|
+
'via',
|
|
10
|
+
'x-forwarded-for',
|
|
11
|
+
'x-forwarded-port',
|
|
12
|
+
'x-forwarded-prefix',
|
|
13
|
+
'x-forwarded-scheme',
|
|
14
|
+
'x-forwarded-server',
|
|
15
|
+
'x-forwarded-ssl',
|
|
16
|
+
'x-forwarded-uri',
|
|
17
|
+
'x-http-method',
|
|
18
|
+
'x-http-method-override',
|
|
19
|
+
'x-method-override',
|
|
20
|
+
'x-original-url',
|
|
21
|
+
'x-real-ip',
|
|
22
|
+
'x-rewrite-url',
|
|
23
|
+
];
|
|
24
|
+
/** Next.js internal headers that external clients must never send (middleware and cache CVEs). */
|
|
25
|
+
export const NEXTJS_INTERNAL_HEADERS_TO_STRIP = [
|
|
26
|
+
'x-middleware-subrequest',
|
|
27
|
+
'x-middleware-subrequest-id',
|
|
28
|
+
'x-nextjs-data',
|
|
29
|
+
'x-now-route-matches',
|
|
30
|
+
'next-resume',
|
|
31
|
+
'next-action-nonce',
|
|
32
|
+
];
|
|
33
|
+
/** Builds the sanitized request forwarded to an origin. Redirects stay manual. */
|
|
34
|
+
export function buildOriginRequest(request, options) {
|
|
35
|
+
const original = new URL(request.url);
|
|
36
|
+
const originUrl = new URL(options.origin);
|
|
37
|
+
const url = new URL(request.url);
|
|
38
|
+
url.protocol = originUrl.protocol;
|
|
39
|
+
url.host = originUrl.host;
|
|
40
|
+
if (options.pathOverride) {
|
|
41
|
+
const override = new URL(options.pathOverride, url);
|
|
42
|
+
const merged = new URLSearchParams(url.search);
|
|
43
|
+
override.searchParams.forEach((value, key) => merged.set(key, value));
|
|
44
|
+
url.pathname = override.pathname;
|
|
45
|
+
url.search = merged.toString();
|
|
46
|
+
}
|
|
47
|
+
const headers = new Headers(request.headers);
|
|
48
|
+
const cookie = options.stripAllCookies
|
|
49
|
+
? null
|
|
50
|
+
: stripCookies(headers.get('cookie'), options.stripCookieNames ?? []);
|
|
51
|
+
if (cookie)
|
|
52
|
+
headers.set('cookie', cookie);
|
|
53
|
+
else
|
|
54
|
+
headers.delete('cookie');
|
|
55
|
+
deleteHeaders(headers, CLIENT_SUPPLIED_PROXY_HEADERS_TO_STRIP);
|
|
56
|
+
deleteHeaders(headers, options.stripHeaders ?? []);
|
|
57
|
+
const requestIdHeader = options.requestIdHeader ?? 'x-request-id';
|
|
58
|
+
headers.delete(requestIdHeader);
|
|
59
|
+
if (options.sharedSecret)
|
|
60
|
+
headers.delete(options.sharedSecret.header);
|
|
61
|
+
const isBasic = /^basic\s/i.test(headers.get('authorization') ?? '');
|
|
62
|
+
if (isBasic ? !options.preserveBasicAuthorization : !options.preserveNonBasicAuthorization) {
|
|
63
|
+
headers.delete('authorization');
|
|
64
|
+
}
|
|
65
|
+
headers.set('x-forwarded-host', original.host);
|
|
66
|
+
headers.set('x-forwarded-proto', original.protocol.replace(':', ''));
|
|
67
|
+
const clientIp = request.headers.get('cf-connecting-ip');
|
|
68
|
+
if (clientIp)
|
|
69
|
+
headers.set('x-forwarded-for', clientIp);
|
|
70
|
+
const identityHosts = options.identityEncodingHostnames ?? ['localhost', '127.0.0.1'];
|
|
71
|
+
if (options.forceIdentityEncoding || identityHosts.includes(originUrl.hostname)) {
|
|
72
|
+
headers.set('accept-encoding', 'identity');
|
|
73
|
+
}
|
|
74
|
+
if (options.sharedSecret?.value)
|
|
75
|
+
headers.set(options.sharedSecret.header, options.sharedSecret.value);
|
|
76
|
+
if (options.requestId)
|
|
77
|
+
headers.set(requestIdHeader, options.requestId);
|
|
78
|
+
for (const [name, value] of Object.entries(options.setHeaders ?? {}))
|
|
79
|
+
headers.set(name, value);
|
|
80
|
+
const init = {
|
|
81
|
+
method: request.method,
|
|
82
|
+
headers,
|
|
83
|
+
// 'follow' would resolve redirects inside the Worker and hide them from the client.
|
|
84
|
+
redirect: 'manual',
|
|
85
|
+
};
|
|
86
|
+
if (!METHOD_WITHOUT_BODY.has(request.method)) {
|
|
87
|
+
init.body = request.body;
|
|
88
|
+
// Required by undici for streaming bodies; the Workers runtime ignores it.
|
|
89
|
+
init.duplex = 'half';
|
|
90
|
+
}
|
|
91
|
+
return new Request(url, init);
|
|
92
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const DEFAULT_PERMISSIONS_POLICY: string;
|
|
2
|
+
export declare const BASE_SECURITY_HEADERS: Readonly<Record<string, string>>;
|
|
3
|
+
/** HSTS value. `preload` is effectively irreversible, so callers opt in explicitly. */
|
|
4
|
+
export declare function buildHsts(includePreload?: boolean): string;
|
|
5
|
+
export type SecurityHeadersOptions = {
|
|
6
|
+
/** Adds `strict-transport-security` when true. */
|
|
7
|
+
production?: boolean;
|
|
8
|
+
hstsPreload?: boolean;
|
|
9
|
+
/** Final `content-security-policy` value; omitted for API-style responses. */
|
|
10
|
+
csp?: string;
|
|
11
|
+
openerPolicy?: 'same-origin-allow-popups' | 'same-origin' | 'unsafe-none';
|
|
12
|
+
/**
|
|
13
|
+
* Honor an origin `referrer-policy: no-referrer` (only that value, only more restrictive).
|
|
14
|
+
* The caller decides which routes carry URL credentials.
|
|
15
|
+
*/
|
|
16
|
+
preserveOriginNoReferrer?: boolean;
|
|
17
|
+
/** Replaces the base header set. Defaults to {@link BASE_SECURITY_HEADERS}. */
|
|
18
|
+
baseHeaders?: Readonly<Record<string, string>>;
|
|
19
|
+
/** Applied last, so callers can override any computed header. */
|
|
20
|
+
overrides?: Readonly<Record<string, string>>;
|
|
21
|
+
};
|
|
22
|
+
/** Returns the edge-authoritative security header set for one response. */
|
|
23
|
+
export declare function buildSecurityHeaders(response: Response, options?: SecurityHeadersOptions): Record<string, string>;
|
|
24
|
+
/** Overwrites response headers with {@link buildSecurityHeaders}, preserving `set-cookie`. */
|
|
25
|
+
export declare function addSecurityHeaders(response: Response, options?: SecurityHeadersOptions): Response;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { withHeaders } from './headers.mjs';
|
|
2
|
+
export const DEFAULT_PERMISSIONS_POLICY = [
|
|
3
|
+
'accelerometer',
|
|
4
|
+
'attribution-reporting',
|
|
5
|
+
'bluetooth',
|
|
6
|
+
'browsing-topics',
|
|
7
|
+
'camera',
|
|
8
|
+
'display-capture',
|
|
9
|
+
'gamepad',
|
|
10
|
+
'geolocation',
|
|
11
|
+
'gyroscope',
|
|
12
|
+
'hid',
|
|
13
|
+
'idle-detection',
|
|
14
|
+
'interest-cohort',
|
|
15
|
+
'join-ad-interest-group',
|
|
16
|
+
'local-fonts',
|
|
17
|
+
'magnetometer',
|
|
18
|
+
'microphone',
|
|
19
|
+
'midi',
|
|
20
|
+
'payment',
|
|
21
|
+
'run-ad-auction',
|
|
22
|
+
'screen-wake-lock',
|
|
23
|
+
'serial',
|
|
24
|
+
'speaker-selection',
|
|
25
|
+
'sync-xhr',
|
|
26
|
+
'usb',
|
|
27
|
+
'web-share',
|
|
28
|
+
'xr-spatial-tracking',
|
|
29
|
+
]
|
|
30
|
+
.map((feature) => `${feature}=()`)
|
|
31
|
+
.join(', ');
|
|
32
|
+
export const BASE_SECURITY_HEADERS = {
|
|
33
|
+
'x-content-type-options': 'nosniff',
|
|
34
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
35
|
+
'x-xss-protection': '0',
|
|
36
|
+
'referrer-policy': 'strict-origin-when-cross-origin',
|
|
37
|
+
'permissions-policy': DEFAULT_PERMISSIONS_POLICY,
|
|
38
|
+
'origin-agent-cluster': '?1',
|
|
39
|
+
'cross-origin-opener-policy': 'same-origin-allow-popups',
|
|
40
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
41
|
+
};
|
|
42
|
+
/** HSTS value. `preload` is effectively irreversible, so callers opt in explicitly. */
|
|
43
|
+
export function buildHsts(includePreload = false) {
|
|
44
|
+
return `max-age=63072000; includeSubDomains${includePreload ? '; preload' : ''}`;
|
|
45
|
+
}
|
|
46
|
+
/** Returns the edge-authoritative security header set for one response. */
|
|
47
|
+
export function buildSecurityHeaders(response, options = {}) {
|
|
48
|
+
const headers = { ...(options.baseHeaders ?? BASE_SECURITY_HEADERS) };
|
|
49
|
+
if (options.production)
|
|
50
|
+
headers['strict-transport-security'] = buildHsts(options.hstsPreload);
|
|
51
|
+
if (options.csp)
|
|
52
|
+
headers['content-security-policy'] = options.csp;
|
|
53
|
+
if (options.openerPolicy)
|
|
54
|
+
headers['cross-origin-opener-policy'] = options.openerPolicy;
|
|
55
|
+
// Headers.get() comma-joins repeats, so match any token; that can only tighten the policy.
|
|
56
|
+
if (options.preserveOriginNoReferrer &&
|
|
57
|
+
response.headers
|
|
58
|
+
.get('referrer-policy')
|
|
59
|
+
?.split(',')
|
|
60
|
+
.some((value) => value.trim().toLowerCase() === 'no-referrer')) {
|
|
61
|
+
headers['referrer-policy'] = 'no-referrer';
|
|
62
|
+
}
|
|
63
|
+
return { ...headers, ...options.overrides };
|
|
64
|
+
}
|
|
65
|
+
/** Overwrites response headers with {@link buildSecurityHeaders}, preserving `set-cookie`. */
|
|
66
|
+
export function addSecurityHeaders(response, options = {}) {
|
|
67
|
+
return withHeaders(response, buildSecurityHeaders(response, options));
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type SentryDsnConfig = {
|
|
2
|
+
/** Canonical DSN URL. */
|
|
3
|
+
dsn: string;
|
|
4
|
+
envelopeUrl: string;
|
|
5
|
+
origin: string;
|
|
6
|
+
projectId: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Parses an `https://<public-key>@<host>/<project>` DSN into canonical parts. Rejects passwords,
|
|
10
|
+
* query, hash, non-numeric projects, and non-canonical paths so an envelope DSN can be compared to
|
|
11
|
+
* a configured one without accepting lookalike spellings. Returns undefined for invalid values.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseSentryDsn(raw: string | undefined): SentryDsnConfig | undefined;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses an `https://<public-key>@<host>/<project>` DSN into canonical parts. Rejects passwords,
|
|
3
|
+
* query, hash, non-numeric projects, and non-canonical paths so an envelope DSN can be compared to
|
|
4
|
+
* a configured one without accepting lookalike spellings. Returns undefined for invalid values.
|
|
5
|
+
*/
|
|
6
|
+
export function parseSentryDsn(raw) {
|
|
7
|
+
const value = raw?.trim();
|
|
8
|
+
if (!value)
|
|
9
|
+
return undefined;
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(value);
|
|
12
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
13
|
+
const projectId = segments.at(-1);
|
|
14
|
+
if (url.protocol !== 'https:' ||
|
|
15
|
+
!url.username ||
|
|
16
|
+
!/^[A-Za-z0-9_]+$/u.test(url.username) ||
|
|
17
|
+
url.password ||
|
|
18
|
+
url.search ||
|
|
19
|
+
url.hash ||
|
|
20
|
+
!projectId ||
|
|
21
|
+
!/^\d+$/u.test(projectId)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const prefix = segments.slice(0, -1).join('/');
|
|
25
|
+
const base = prefix ? `${prefix}/` : '';
|
|
26
|
+
if (url.pathname !== `/${base}${projectId}`)
|
|
27
|
+
return undefined;
|
|
28
|
+
return {
|
|
29
|
+
dsn: url.toString(),
|
|
30
|
+
envelopeUrl: new URL(`/${base}api/${projectId}/envelope/`, url.origin).toString(),
|
|
31
|
+
origin: url.origin,
|
|
32
|
+
projectId,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type SentryTunnelRejectReason = 'content_length_too_large' | 'body_too_large' | 'body_read_error' | 'invalid_envelope_header' | 'missing_dsn' | 'invalid_dsn' | 'configuration_unavailable' | 'dsn_not_allowed';
|
|
2
|
+
export type SentryTunnelDiagnosticDetails = {
|
|
3
|
+
envelopeItemCount?: number;
|
|
4
|
+
envelopeItemTypes?: string[];
|
|
5
|
+
errorName?: string;
|
|
6
|
+
errorCauseName?: string;
|
|
7
|
+
errorCode?: string;
|
|
8
|
+
limitBytes?: number;
|
|
9
|
+
projectId?: string;
|
|
10
|
+
};
|
|
11
|
+
export type SentryTunnelLog = {
|
|
12
|
+
warn: (line: string) => void;
|
|
13
|
+
error: (line: string) => void;
|
|
14
|
+
};
|
|
15
|
+
export declare function logSentryTunnelRejection(log: SentryTunnelLog, request: Request, status: number, reason: SentryTunnelRejectReason, details?: SentryTunnelDiagnosticDetails): void;
|
|
16
|
+
export declare function logSentryTunnelForwardFailure(log: SentryTunnelLog, request: Request, projectId: string, error: unknown, details?: SentryTunnelDiagnosticDetails): void;
|
|
17
|
+
export declare function logSentryTunnelUpstreamResponse(log: SentryTunnelLog, request: Request, projectId: string, status: number, details?: SentryTunnelDiagnosticDetails): void;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const ERROR_CODE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/u;
|
|
2
|
+
function requestDiagnostics(request) {
|
|
3
|
+
const rawLength = request.headers.get('content-length');
|
|
4
|
+
const length = rawLength === null ? undefined : Number(rawLength);
|
|
5
|
+
const ray = request.cf?.ray ?? request.headers.get('cf-ray');
|
|
6
|
+
return {
|
|
7
|
+
method: request.method,
|
|
8
|
+
requestPath: new URL(request.url).pathname,
|
|
9
|
+
...(ray ? { cfRay: ray } : {}),
|
|
10
|
+
...(typeof length === 'number' && Number.isFinite(length)
|
|
11
|
+
? { contentLengthBytes: length }
|
|
12
|
+
: {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function logSentryTunnelRejection(log, request, status, reason, details = {}) {
|
|
16
|
+
log.warn(JSON.stringify({
|
|
17
|
+
message: 'sentry_tunnel_rejected',
|
|
18
|
+
reason,
|
|
19
|
+
status,
|
|
20
|
+
...requestDiagnostics(request),
|
|
21
|
+
...details,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
export function logSentryTunnelForwardFailure(log, request, projectId, error, details = {}) {
|
|
25
|
+
log.error(JSON.stringify({
|
|
26
|
+
message: 'sentry_tunnel_forward_failed',
|
|
27
|
+
reason: 'sentry_fetch_error',
|
|
28
|
+
status: 502,
|
|
29
|
+
...requestDiagnostics(request),
|
|
30
|
+
projectId,
|
|
31
|
+
...details,
|
|
32
|
+
...errorDiagnostics(error),
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
export function logSentryTunnelUpstreamResponse(log, request, projectId, status, details = {}) {
|
|
36
|
+
log.warn(JSON.stringify({
|
|
37
|
+
message: 'sentry_tunnel_upstream_response',
|
|
38
|
+
reason: 'sentry_upstream_non_2xx',
|
|
39
|
+
status,
|
|
40
|
+
...requestDiagnostics(request),
|
|
41
|
+
projectId,
|
|
42
|
+
...details,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
/** Only error names and bounded codes are logged; messages may carry request data. */
|
|
46
|
+
function errorDiagnostics(error) {
|
|
47
|
+
const details = { errorName: errorName(error) ?? typeof error };
|
|
48
|
+
const code = safeErrorCode(error);
|
|
49
|
+
if (code !== undefined)
|
|
50
|
+
details.errorCode = code;
|
|
51
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
52
|
+
if (cause !== undefined && cause !== null) {
|
|
53
|
+
const causeName = errorName(cause);
|
|
54
|
+
if (causeName !== undefined)
|
|
55
|
+
details.errorCauseName = causeName;
|
|
56
|
+
const causeCode = safeErrorCode(cause);
|
|
57
|
+
if (details.errorCode === undefined && causeCode !== undefined)
|
|
58
|
+
details.errorCode = causeCode;
|
|
59
|
+
}
|
|
60
|
+
return details;
|
|
61
|
+
}
|
|
62
|
+
function errorName(error) {
|
|
63
|
+
return error instanceof Error && typeof error.name === 'string' ? error.name : undefined;
|
|
64
|
+
}
|
|
65
|
+
function safeErrorCode(error) {
|
|
66
|
+
if (typeof error !== 'object' || error === null)
|
|
67
|
+
return undefined;
|
|
68
|
+
const code = error.code;
|
|
69
|
+
if (typeof code !== 'string' && typeof code !== 'number')
|
|
70
|
+
return undefined;
|
|
71
|
+
const normalized = String(code);
|
|
72
|
+
return ERROR_CODE_PATTERN.test(normalized) ? normalized : undefined;
|
|
73
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SentryTunnelLog } from './sentry-tunnel-diagnostics.mts';
|
|
2
|
+
export type SentryTunnelOptions = {
|
|
3
|
+
/** Raw DSNs the tunnel may forward to. Invalid and missing entries are ignored. */
|
|
4
|
+
trustedDsns: readonly (string | undefined)[];
|
|
5
|
+
/** Envelope size cap. Defaults to 1,000,000 bytes. */
|
|
6
|
+
maxBytes?: number;
|
|
7
|
+
/** Distinct item types recorded in failure diagnostics. Defaults to 8. */
|
|
8
|
+
maxItemTypes?: number;
|
|
9
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
10
|
+
log?: SentryTunnelLog;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Builds a handler that proxies browser Sentry envelopes to trusted DSNs, bypassing ad-blockers.
|
|
14
|
+
* The DSN in each envelope header must match a trusted DSN exactly. Upstream responses are not
|
|
15
|
+
* forwarded; only the status is, so Sentry rate-limit headers never reach browsers.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createSentryTunnel(options: SentryTunnelOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { getSentryEnvelopeDiagnostics } from '@vouchington/utils/sentry-envelope';
|
|
2
|
+
import { edgeErrorResponse, withFailureNoStoreHeaders } from './error-response.mjs';
|
|
3
|
+
import { parseSentryDsn } from './sentry-dsn.mjs';
|
|
4
|
+
import { logSentryTunnelForwardFailure, logSentryTunnelRejection, logSentryTunnelUpstreamResponse, } from './sentry-tunnel-diagnostics.mjs';
|
|
5
|
+
async function readEnvelopeWithLimit(request, maxBytes) {
|
|
6
|
+
if (!request.body)
|
|
7
|
+
return { kind: 'ok', envelope: '' };
|
|
8
|
+
try {
|
|
9
|
+
const reader = request.body.getReader();
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let bytesRead = 0;
|
|
13
|
+
while (true) {
|
|
14
|
+
const { done, value } = await reader.read();
|
|
15
|
+
if (done) {
|
|
16
|
+
chunks.push(decoder.decode());
|
|
17
|
+
return { kind: 'ok', envelope: chunks.join('') };
|
|
18
|
+
}
|
|
19
|
+
bytesRead += value.byteLength;
|
|
20
|
+
if (bytesRead > maxBytes) {
|
|
21
|
+
reader.cancel().catch(() => { });
|
|
22
|
+
return { kind: 'too-large' };
|
|
23
|
+
}
|
|
24
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return { kind: 'read-error' };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Builds a handler that proxies browser Sentry envelopes to trusted DSNs, bypassing ad-blockers.
|
|
33
|
+
* The DSN in each envelope header must match a trusted DSN exactly. Upstream responses are not
|
|
34
|
+
* forwarded; only the status is, so Sentry rate-limit headers never reach browsers.
|
|
35
|
+
*/
|
|
36
|
+
export function createSentryTunnel(options) {
|
|
37
|
+
const maxBytes = options.maxBytes ?? 1_000_000;
|
|
38
|
+
const maxItemTypes = options.maxItemTypes ?? 8;
|
|
39
|
+
const log = options.log ?? console;
|
|
40
|
+
const send = options.fetch ?? fetch;
|
|
41
|
+
const trusted = options.trustedDsns
|
|
42
|
+
.map(parseSentryDsn)
|
|
43
|
+
.filter((dsn) => dsn !== undefined);
|
|
44
|
+
const diagnostics = (envelope) => getSentryEnvelopeDiagnostics(envelope, { maxBytes, maxItemTypes });
|
|
45
|
+
return async function handleSentryTunnel(request) {
|
|
46
|
+
const reject = (status, message, code, reason, details) => {
|
|
47
|
+
logSentryTunnelRejection(log, request, status, reason, details);
|
|
48
|
+
return edgeErrorResponse(status, message, code);
|
|
49
|
+
};
|
|
50
|
+
if (trusted.length === 0) {
|
|
51
|
+
return reject(503, 'Service Unavailable', 'SERVICE_UNAVAILABLE', 'configuration_unavailable');
|
|
52
|
+
}
|
|
53
|
+
const contentLength = request.headers.get('content-length');
|
|
54
|
+
if (contentLength !== null && Number(contentLength) > maxBytes) {
|
|
55
|
+
return reject(413, 'Payload Too Large', 'INVALID_INPUT', 'content_length_too_large', {
|
|
56
|
+
limitBytes: maxBytes,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const read = await readEnvelopeWithLimit(request, maxBytes);
|
|
60
|
+
if (read.kind === 'too-large') {
|
|
61
|
+
return reject(413, 'Payload Too Large', 'INVALID_INPUT', 'body_too_large', {
|
|
62
|
+
limitBytes: maxBytes,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (read.kind === 'read-error') {
|
|
66
|
+
return reject(400, 'Bad Request', 'INVALID_INPUT', 'body_read_error');
|
|
67
|
+
}
|
|
68
|
+
const { envelope } = read;
|
|
69
|
+
let header;
|
|
70
|
+
try {
|
|
71
|
+
header = JSON.parse(envelope.split('\n')[0]);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return reject(400, 'Invalid envelope header', 'INVALID_INPUT', 'invalid_envelope_header');
|
|
75
|
+
}
|
|
76
|
+
const dsn = typeof header === 'object' && header !== null
|
|
77
|
+
? header.dsn
|
|
78
|
+
: undefined;
|
|
79
|
+
if (typeof dsn !== 'string')
|
|
80
|
+
return reject(400, 'Missing DSN', 'INVALID_INPUT', 'missing_dsn');
|
|
81
|
+
const envelopeDsn = parseSentryDsn(dsn);
|
|
82
|
+
if (!envelopeDsn)
|
|
83
|
+
return reject(400, 'Invalid DSN', 'INVALID_INPUT', 'invalid_dsn');
|
|
84
|
+
const target = trusted.find((candidate) => candidate.dsn === envelopeDsn.dsn);
|
|
85
|
+
if (!target)
|
|
86
|
+
return reject(403, 'Forbidden', 'FORBIDDEN', 'dsn_not_allowed');
|
|
87
|
+
let upstream;
|
|
88
|
+
try {
|
|
89
|
+
upstream = await send(target.envelopeUrl, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
body: envelope,
|
|
92
|
+
headers: { 'content-type': 'application/x-sentry-envelope' },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
logSentryTunnelForwardFailure(log, request, target.projectId, error, diagnostics(envelope));
|
|
97
|
+
return edgeErrorResponse(502, 'Bad Gateway', 'BAD_GATEWAY');
|
|
98
|
+
}
|
|
99
|
+
if (upstream.status < 200 || upstream.status >= 300) {
|
|
100
|
+
logSentryTunnelUpstreamResponse(log, request, target.projectId, upstream.status, diagnostics(envelope));
|
|
101
|
+
}
|
|
102
|
+
return withFailureNoStoreHeaders(new Response(null, { status: upstream.status }));
|
|
103
|
+
};
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vouchington/edge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cloudflare Worker builders: security headers, CSP, basic auth, origin proxying, cache policy, and a Sentry tunnel.",
|
|
5
|
+
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/edge#readme",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jonathan Ong",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vouchington/vouchington-platform.git",
|
|
11
|
+
"directory": "packages/edge"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.mjs",
|
|
20
|
+
"types": "./dist/index.d.mts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"import": "./dist/index.mjs",
|
|
25
|
+
"default": "./dist/index.mjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc --project tsconfig.build.json",
|
|
33
|
+
"prepack": "pnpm run build"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@vouchington/utils": "^0.6.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@cloudflare/workers-types": "^5.20261016.1"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@cloudflare/workers-types": "^5.20261016.1"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@cloudflare/workers-types": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=24.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|