-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathrequest.ts
More file actions
340 lines (301 loc) · 10.9 KB
/
request.ts
File metadata and controls
340 lines (301 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
import type { PolymorphicRequest } from '../types-hoist/polymorphics';
import type { RequestEventData } from '../types-hoist/request';
import type { WebFetchHeaders, WebFetchRequest } from '../types-hoist/webfetchapi';
/**
* Transforms a `Headers` object that implements the `Web Fetch API` (https://developer.mozilla.org/en-US/docs/Web/API/Headers) into a simple key-value dict.
* The header keys will be lower case: e.g. A "Content-Type" header will be stored as "content-type".
*/
export function winterCGHeadersToDict(winterCGHeaders: WebFetchHeaders): Record<string, string> {
const headers: Record<string, string> = {};
try {
winterCGHeaders.forEach((value, key) => {
if (typeof value === 'string') {
// We check that value is a string even though it might be redundant to make sure prototype pollution is not possible.
headers[key] = value;
}
});
} catch {
// just return the empty headers
}
return headers;
}
/**
* Convert common request headers to a simple dictionary.
*/
export function headersToDict(reqHeaders: Record<string, string | string[] | undefined>): Record<string, string> {
const headers: Record<string, string> = Object.create(null);
try {
Object.entries(reqHeaders).forEach(([key, value]) => {
if (typeof value === 'string') {
headers[key] = value;
}
});
} catch {
// just return the empty headers
}
return headers;
}
/**
* Converts a `Request` object that implements the `Web Fetch API` (https://developer.mozilla.org/en-US/docs/Web/API/Headers) into the format that the `RequestData` integration understands.
*/
export function winterCGRequestToRequestData(req: WebFetchRequest): RequestEventData {
const headers = winterCGHeadersToDict(req.headers);
return {
method: req.method,
url: req.url,
query_string: extractQueryParamsFromUrl(req.url),
headers,
// TODO: Can we extract body data from the request?
};
}
/**
* Convert a HTTP request object to RequestEventData to be passed as normalizedRequest.
* Instead of allowing `PolymorphicRequest` to be passed,
* we want to be more specific and generally require a http.IncomingMessage-like object.
*/
export function httpRequestToRequestData(request: {
method?: string;
url?: string;
headers?: {
[key: string]: string | string[] | undefined;
};
protocol?: string;
socket?: {
encrypted?: boolean;
remoteAddress?: string;
};
}): RequestEventData {
const headers = request.headers || {};
// Check for x-forwarded-host first, then fall back to host header
const forwardedHost = typeof headers['x-forwarded-host'] === 'string' ? headers['x-forwarded-host'] : undefined;
const host = forwardedHost || (typeof headers.host === 'string' ? headers.host : undefined);
// Check for x-forwarded-proto first, then fall back to existing protocol detection
const forwardedProto = typeof headers['x-forwarded-proto'] === 'string' ? headers['x-forwarded-proto'] : undefined;
const protocol = forwardedProto || request.protocol || (request.socket?.encrypted ? 'https' : 'http');
const url = request.url || '';
const absoluteUrl = getAbsoluteUrl({
url,
host,
protocol,
});
// This is non-standard, but may be sometimes set
// It may be overwritten later by our own body handling
const data = (request as PolymorphicRequest).body || undefined;
// This is non-standard, but may be set on e.g. Next.js or Express requests
const cookies = (request as PolymorphicRequest).cookies;
return {
url: absoluteUrl,
method: request.method,
query_string: extractQueryParamsFromUrl(url),
headers: headersToDict(headers),
cookies,
data,
};
}
function getAbsoluteUrl({
url,
protocol,
host,
}: {
url?: string;
protocol: string;
host?: string;
}): string | undefined {
if (url?.startsWith('http')) {
return url;
}
if (url && host) {
return `${protocol}://${host}${url}`;
}
return undefined;
}
const SENSITIVE_HEADER_SNIPPETS = [
'auth',
'token',
'secret',
'session', // for the user_session cookie
'password',
'passwd',
'pwd',
'key',
'jwt',
'bearer',
'sso',
'saml',
'csrf',
'xsrf',
'credentials',
// Always treat cookie headers as sensitive in case individual key-value cookie pairs cannot properly be extracted
'set-cookie',
'cookie',
];
/**
* Extra substrings matched only against individual Cookie / Set-Cookie **names** (not header names),
* so we can cover common session secrets that do not match {@link SENSITIVE_HEADER_SNIPPETS}
* (e.g. `connect.sid` does not contain `session`) without false positives on arbitrary HTTP headers.
*
* Cookie names are checked with the same `includes()` list as headers plus these entries; omit redundant
* cookie-only snippets that are already implied by a header match (e.g. `oauth` → `auth`, `id_token` → `token`,
* `next-auth` → `auth`).
*/
const SENSITIVE_COOKIE_NAME_SNIPPETS = [
// Express / Connect default session cookie
'.sid',
// Opaque session ids (PHPSESSID, ASPSESSIONID*, BIGipServer*, *sessid*, …)
'sessid',
// Laravel etc. "remember me" tokens
'remember',
// OIDC / OAuth auxiliary (`oauth*` covered by header snippet `auth`)
'oidc',
'pkce',
'nonce',
// RFC 6265bis high-security cookie name prefixes
'__secure-',
'__host-',
// Load balancer / CDN sticky-session cookies (opaque routing tokens)
'awsalb',
'awselb',
'akamai',
// BaaS / IdP session cookies (names often omit "session")
'__stripe',
'cognito',
'firebase',
'supabase',
'sb-',
// Step-up / MFA cookies
'mfa',
'2fa',
];
const PII_HEADER_SNIPPETS = ['x-forwarded-', '-user'];
/**
* Converts incoming HTTP request or response headers to OpenTelemetry span attributes following semantic conventions.
* Header names are converted to the format: http.<request|response>.header.<key>
* where <key> is the header name in lowercase with dashes converted to underscores.
*
* @param lifecycle - The lifecycle of the headers, either 'request' or 'response'
*
* @see https://opentelemetry.io/docs/specs/semconv/registry/attributes/http/#http-request-header
* @see https://opentelemetry.io/docs/specs/semconv/registry/attributes/http/#http-response-header
*
* @see https://getsentry.github.io/sentry-conventions/attributes/http/#http-request-header-key
* @see https://getsentry.github.io/sentry-conventions/attributes/http/#http-response-header-key
*/
export function httpHeadersToSpanAttributes(
headers: Record<string, string | string[] | undefined>,
sendDefaultPii: boolean = false,
lifecycle: 'request' | 'response' = 'request',
): Record<string, string> {
const spanAttributes: Record<string, string> = {};
try {
Object.entries(headers).forEach(([key, value]) => {
if (value == null) {
return;
}
const lowerCasedHeaderKey = key.toLowerCase();
const isCookieHeader = lowerCasedHeaderKey === 'cookie' || lowerCasedHeaderKey === 'set-cookie';
if (isCookieHeader && typeof value === 'string' && value !== '') {
// Set-Cookie: single cookie with attributes ("name=value; HttpOnly; Secure")
// Cookie: multiple cookies separated by "; " ("cookie1=value1; cookie2=value2")
const isSetCookie = lowerCasedHeaderKey === 'set-cookie';
const semicolonIndex = value.indexOf(';');
const cookieString = isSetCookie && semicolonIndex !== -1 ? value.substring(0, semicolonIndex) : value;
const cookies = isSetCookie ? [cookieString] : cookieString.split('; ');
for (const cookie of cookies) {
// Split only at the first '=' to preserve '=' characters in cookie values
const equalSignIndex = cookie.indexOf('=');
const cookieKey = equalSignIndex !== -1 ? cookie.substring(0, equalSignIndex) : cookie;
const cookieValue = equalSignIndex !== -1 ? cookie.substring(equalSignIndex + 1) : '';
const lowerCasedCookieKey = cookieKey.toLowerCase();
addSpanAttribute({
spanAttributes,
headerKey: lowerCasedHeaderKey,
cookieKey: lowerCasedCookieKey,
value: cookieValue,
sendDefaultPii,
lifecycle,
});
}
} else {
addSpanAttribute({
spanAttributes,
headerKey: lowerCasedHeaderKey,
value,
sendDefaultPii,
lifecycle,
});
}
});
} catch {
// Return empty object if there's an error
}
return spanAttributes;
}
function normalizeAttributeKey(key: string): string {
return key.replace(/-/g, '_');
}
type AddSpanAttributeOptions = {
spanAttributes: Record<string, string>;
/** Lowercased HTTP header name (e.g. `cookie`, `set-cookie`, `accept`). */
headerKey: string;
/**
* Lowercased cookie name when this attribute comes from a parsed `Cookie` / `Set-Cookie` value.
* Omit for non-cookie headers; when present and non-empty, cookie-specific sensitivity rules apply.
*/
cookieKey?: string;
value: string | string[] | undefined;
sendDefaultPii: boolean;
lifecycle: 'request' | 'response';
};
function addSpanAttribute({
spanAttributes,
headerKey,
cookieKey,
value,
sendDefaultPii,
lifecycle,
}: AddSpanAttributeOptions): void {
const isCookieSubKey = Boolean(cookieKey);
const nameForSensitivity = cookieKey || headerKey;
const headerValue = handleHttpHeader(nameForSensitivity, value, sendDefaultPii, isCookieSubKey);
if (headerValue == null) {
return;
}
const normalizedKey = `http.${lifecycle}.header.${normalizeAttributeKey(headerKey)}${cookieKey ? `.${normalizeAttributeKey(cookieKey)}` : ''}`;
spanAttributes[normalizedKey] = headerValue;
}
function handleHttpHeader(
lowerCasedKey: string,
value: string | string[] | undefined,
sendPii: boolean,
isCookieSubKey: boolean = false,
): string | undefined {
const snippetsForSensitivity = isCookieSubKey
? [...SENSITIVE_HEADER_SNIPPETS, ...SENSITIVE_COOKIE_NAME_SNIPPETS]
: SENSITIVE_HEADER_SNIPPETS;
const isSensitive = sendPii
? snippetsForSensitivity.some(snippet => lowerCasedKey.includes(snippet))
: [...PII_HEADER_SNIPPETS, ...snippetsForSensitivity].some(snippet => lowerCasedKey.includes(snippet));
if (isSensitive) {
return '[Filtered]';
} else if (Array.isArray(value)) {
return value.map(v => (v != null ? String(v) : v)).join(';');
} else if (typeof value === 'string') {
return value;
}
return undefined;
}
/** Extract the query params from an URL. */
export function extractQueryParamsFromUrl(url: string): string | undefined {
// url is path and query string
if (!url) {
return;
}
try {
// The `URL` constructor can't handle internal URLs of the form `/some/path/here`, so stick a dummy protocol and
// hostname as the base. Since the point here is just to grab the query string, it doesn't matter what we use.
const queryParams = new URL(url, 'http://s.io').search.slice(1);
return queryParams.length ? queryParams : undefined;
} catch {
return undefined;
}
}