@dash0/sdk-web 0.18.5 → 0.20.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/dist/dash0.iife.js +1 -1
- package/dist/dash0.iife.js.map +1 -1
- package/dist/dash0.js +1 -1
- package/dist/dash0.js.map +1 -1
- package/dist/dash0.umd.cjs +1 -1
- package/dist/dash0.umd.cjs.map +1 -1
- package/dist/modules/api/init.js +7 -2
- package/dist/modules/api/init_test.js +58 -0
- package/dist/modules/instrumentations/http/fetch.js +17 -3
- package/dist/modules/instrumentations/http/fetch_test.js +103 -0
- package/dist/modules/semantic-conventions.js +1 -0
- package/dist/modules/utils/index.js +1 -0
- package/dist/modules/utils/sanitize.js +12 -0
- package/dist/modules/utils/sanitize_test.js +52 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/semantic-conventions.d.ts +1 -0
- package/dist/types/types/options.d.ts +10 -0
- package/dist/types/utils/index.d.ts +1 -0
- package/dist/types/utils/sanitize.d.ts +9 -0
- package/dist/types/utils/sanitize_test.d.ts +1 -0
- package/package.json +1 -1
- package/src/api/init.ts +6 -1
- package/src/api/init_test.ts +75 -0
- package/src/instrumentations/http/fetch.ts +16 -2
- package/src/instrumentations/http/fetch_test.ts +123 -0
- package/src/semantic-conventions.ts +1 -0
- package/src/types/options.ts +11 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/sanitize.ts +13 -0
- package/src/utils/sanitize_test.ts +60 -0
package/src/api/init_test.ts
CHANGED
|
@@ -297,6 +297,81 @@ describe("init", () => {
|
|
|
297
297
|
expect(serviceNameAttr?.value.stringValue).toBe("test-hostname.example.com");
|
|
298
298
|
});
|
|
299
299
|
|
|
300
|
+
it.each([
|
|
301
|
+
["single quote", "evil';DROP TABLE users;--"],
|
|
302
|
+
["double quote", 'svc"name'],
|
|
303
|
+
["semicolon", "svc;injected"],
|
|
304
|
+
["open brace", "svc${tpl}"],
|
|
305
|
+
["close brace", "svc}name"],
|
|
306
|
+
["less-than", "<script>"],
|
|
307
|
+
["greater-than", "svc>name"],
|
|
308
|
+
["embedded newline", "svc\nname"],
|
|
309
|
+
["NUL byte", "svc\x00name"],
|
|
310
|
+
])("should fallback to location.hostname by default when serviceName contains %s", async (_label, suspicious) => {
|
|
311
|
+
init({
|
|
312
|
+
...baseOptions,
|
|
313
|
+
serviceName: suspicious,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
|
|
317
|
+
expect(serviceNameAttr?.value.stringValue).toBe("test-hostname.example.com");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it.each([
|
|
321
|
+
["single quote", "evil';DROP TABLE users;--"],
|
|
322
|
+
["double quote", 'svc"name'],
|
|
323
|
+
["semicolon", "svc;injected"],
|
|
324
|
+
["embedded newline", "svc\nname"],
|
|
325
|
+
])(
|
|
326
|
+
"should also fallback when rejectSuspiciousServiceName is explicitly true and serviceName contains %s",
|
|
327
|
+
async (_label, suspicious) => {
|
|
328
|
+
init({
|
|
329
|
+
...baseOptions,
|
|
330
|
+
serviceName: suspicious,
|
|
331
|
+
rejectSuspiciousServiceName: true,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
|
|
335
|
+
expect(serviceNameAttr?.value.stringValue).toBe("test-hostname.example.com");
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
it.each([
|
|
340
|
+
["single quote", "evil';DROP TABLE users;--"],
|
|
341
|
+
["semicolon", "svc;injected"],
|
|
342
|
+
["less-than", "<script>"],
|
|
343
|
+
])(
|
|
344
|
+
"should keep suspicious serviceName unchanged when rejectSuspiciousServiceName is explicitly false (%s)",
|
|
345
|
+
async (_label, suspicious) => {
|
|
346
|
+
init({
|
|
347
|
+
...baseOptions,
|
|
348
|
+
serviceName: suspicious,
|
|
349
|
+
rejectSuspiciousServiceName: false,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
|
|
353
|
+
expect(serviceNameAttr?.value.stringValue).toBe(suspicious);
|
|
354
|
+
}
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
it.each([
|
|
358
|
+
["forward slash", "myteam/myservice"],
|
|
359
|
+
["backslash", "myteam\\myservice"],
|
|
360
|
+
["spaces", "my service"],
|
|
361
|
+
["dots and hyphens", "svc-name.v2"],
|
|
362
|
+
])(
|
|
363
|
+
"should keep serviceName with allowed characters like %s under the default configuration",
|
|
364
|
+
async (_label, allowed) => {
|
|
365
|
+
init({
|
|
366
|
+
...baseOptions,
|
|
367
|
+
serviceName: allowed,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
|
|
371
|
+
expect(serviceNameAttr?.value.stringValue).toBe(allowed);
|
|
372
|
+
}
|
|
373
|
+
);
|
|
374
|
+
|
|
300
375
|
it("should fallback to 'unknown' when serviceName is empty and location.hostname is not available", async () => {
|
|
301
376
|
vi.resetModules();
|
|
302
377
|
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
HTTP_RESPONSE_STATUS_CODE,
|
|
30
30
|
SPAN_STATUS_ERROR,
|
|
31
31
|
SPAN_STATUS_UNSET,
|
|
32
|
+
WEB_REQUEST_CANCELLED,
|
|
32
33
|
} from "../../semantic-conventions";
|
|
33
34
|
import { vars, PropagatorType } from "../../vars";
|
|
34
35
|
import { httpRequestHeaderKey, httpResponseHeaderKey } from "../../utils/otel/http";
|
|
@@ -129,12 +130,20 @@ function wrapFetch(original: typeof fetch) {
|
|
|
129
130
|
() => performanceObserver.end(),
|
|
130
131
|
(e) => {
|
|
131
132
|
performanceObserver.cancel();
|
|
132
|
-
|
|
133
|
+
if (request.signal?.aborted) {
|
|
134
|
+
endSpanOnAbort(span);
|
|
135
|
+
} else {
|
|
136
|
+
endSpanOnError(span, e);
|
|
137
|
+
}
|
|
133
138
|
}
|
|
134
139
|
);
|
|
135
140
|
} catch (e) {
|
|
136
141
|
performanceObserver.cancel();
|
|
137
|
-
|
|
142
|
+
if (request.signal?.aborted) {
|
|
143
|
+
endSpanOnAbort(span);
|
|
144
|
+
} else {
|
|
145
|
+
endSpanOnError(span, e as Exception);
|
|
146
|
+
}
|
|
138
147
|
throw e;
|
|
139
148
|
}
|
|
140
149
|
};
|
|
@@ -277,6 +286,11 @@ function endSpanOnError(span: InProgressSpan, error: Exception) {
|
|
|
277
286
|
sendSpan(endSpan(span, errorToSpanStatus(error), undefined));
|
|
278
287
|
}
|
|
279
288
|
|
|
289
|
+
function endSpanOnAbort(span: InProgressSpan) {
|
|
290
|
+
addAttribute(span.attributes, WEB_REQUEST_CANCELLED, true);
|
|
291
|
+
sendSpan(endSpan(span, undefined, undefined));
|
|
292
|
+
}
|
|
293
|
+
|
|
280
294
|
function determinePropagatorTypes(url: string): PropagatorType[] {
|
|
281
295
|
const matchingTypes: PropagatorType[] = [];
|
|
282
296
|
const isUrlSameOrigin = isSameOrigin(url);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { vars } from "../../vars";
|
|
3
3
|
import { instrumentFetch } from "./fetch";
|
|
4
|
+
import { sendSpan } from "../../transport";
|
|
5
|
+
import type { Span } from "../../types/otlp";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../transport", () => ({
|
|
8
|
+
sendSpan: vi.fn(),
|
|
9
|
+
}));
|
|
4
10
|
|
|
5
11
|
describe("fetch test", () => {
|
|
6
12
|
let fetchMock: any;
|
|
@@ -188,4 +194,121 @@ describe("fetch test", () => {
|
|
|
188
194
|
expect(fetchHeaders.get("traceparent")).not.toBeNull();
|
|
189
195
|
expect(fetchHeaders.get("X-Amzn-Trace-Id")).toBeNull();
|
|
190
196
|
});
|
|
197
|
+
|
|
198
|
+
describe("aborted requests", () => {
|
|
199
|
+
const sendSpanMock = sendSpan as unknown as ReturnType<typeof vi.fn>;
|
|
200
|
+
const NativeRequest = Request;
|
|
201
|
+
|
|
202
|
+
const lastSpan = (): Span => {
|
|
203
|
+
const calls = sendSpanMock.mock.calls;
|
|
204
|
+
return calls[calls.length - 1]![0] as Span;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const hasAttribute = (span: Span, key: string, expectedValue: unknown) =>
|
|
208
|
+
span.attributes.some((a) => a.key === key && JSON.stringify(a.value) === JSON.stringify(expectedValue));
|
|
209
|
+
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
sendSpanMock.mockClear();
|
|
212
|
+
// jsdom's Request rejects its own AbortSignal instances (known jsdom bug).
|
|
213
|
+
// Stub Request with a minimal implementation that preserves signals verbatim.
|
|
214
|
+
class FakeRequest {
|
|
215
|
+
url: string;
|
|
216
|
+
method: string;
|
|
217
|
+
headers: Headers;
|
|
218
|
+
signal: AbortSignal | undefined;
|
|
219
|
+
constructor(input: string | FakeRequest, init?: RequestInit) {
|
|
220
|
+
if (input instanceof FakeRequest) {
|
|
221
|
+
this.url = input.url;
|
|
222
|
+
this.method = init?.method ?? input.method;
|
|
223
|
+
this.headers = new Headers(init?.headers ?? input.headers);
|
|
224
|
+
this.signal = init?.signal ?? input.signal;
|
|
225
|
+
} else {
|
|
226
|
+
this.url = String(input);
|
|
227
|
+
this.method = init?.method ?? "GET";
|
|
228
|
+
this.headers = new Headers(init?.headers);
|
|
229
|
+
this.signal = init?.signal ?? undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
vi.stubGlobal("Request", FakeRequest);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
afterEach(() => {
|
|
237
|
+
vi.stubGlobal("Request", NativeRequest);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("marks fetch as cancelled when the abort signal fires before the response arrives", async () => {
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
fetchMock.mockImplementation(
|
|
243
|
+
(_input: RequestInfo, init?: RequestInit) =>
|
|
244
|
+
new Promise((_resolve, reject) => {
|
|
245
|
+
const signal = init?.signal;
|
|
246
|
+
if (signal?.aborted) {
|
|
247
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
|
|
251
|
+
})
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
instrumentFetch();
|
|
255
|
+
// eslint-disable-next-line no-restricted-globals
|
|
256
|
+
const pending = fetch("http://localhost:3000/api/test", { signal: controller.signal });
|
|
257
|
+
controller.abort();
|
|
258
|
+
await expect(pending).rejects.toBeInstanceOf(DOMException);
|
|
259
|
+
|
|
260
|
+
expect(sendSpanMock).toHaveBeenCalledTimes(1);
|
|
261
|
+
const span = lastSpan();
|
|
262
|
+
expect(span.status?.code).toBe(0);
|
|
263
|
+
expect(span.status?.message).toBeUndefined();
|
|
264
|
+
expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(true);
|
|
265
|
+
expect(span.events.find((e) => e.name === "exception")).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("marks fetch as cancelled when the abort signal fires while reading the response body", async () => {
|
|
269
|
+
const controller = new AbortController();
|
|
270
|
+
const body = new ReadableStream({
|
|
271
|
+
start(streamController) {
|
|
272
|
+
streamController.enqueue(new Uint8Array([0x68, 0x69]));
|
|
273
|
+
controller.signal.addEventListener("abort", () => {
|
|
274
|
+
streamController.error(new DOMException("aborted", "AbortError"));
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
fetchMock.mockImplementation(() => Promise.resolve(new Response(body, { status: 200 })));
|
|
280
|
+
|
|
281
|
+
instrumentFetch();
|
|
282
|
+
// eslint-disable-next-line no-restricted-globals
|
|
283
|
+
const response = await fetch("http://localhost:3000/api/test", { signal: controller.signal });
|
|
284
|
+
const reader = response.body!.getReader();
|
|
285
|
+
await reader.read();
|
|
286
|
+
controller.abort();
|
|
287
|
+
await expect(reader.read()).rejects.toBeInstanceOf(DOMException);
|
|
288
|
+
|
|
289
|
+
expect(sendSpanMock).toHaveBeenCalledTimes(1);
|
|
290
|
+
const span = lastSpan();
|
|
291
|
+
// status was set from the 200 response and should not be overwritten
|
|
292
|
+
expect(span.status?.code).toBe(0);
|
|
293
|
+
expect(span.status?.message).toBeUndefined();
|
|
294
|
+
expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(true);
|
|
295
|
+
expect(hasAttribute(span, "http.response.status_code", { stringValue: "200" })).toBe(true);
|
|
296
|
+
expect(span.events.find((e) => e.name === "exception")).toBeUndefined();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("still treats non-abort rejections as failures", async () => {
|
|
300
|
+
fetchMock.mockImplementation(() => Promise.reject(new TypeError("network down")));
|
|
301
|
+
|
|
302
|
+
instrumentFetch();
|
|
303
|
+
// eslint-disable-next-line no-restricted-globals
|
|
304
|
+
await expect(fetch("http://localhost:3000/api/test")).rejects.toBeInstanceOf(TypeError);
|
|
305
|
+
|
|
306
|
+
expect(sendSpanMock).toHaveBeenCalledTimes(1);
|
|
307
|
+
const span = lastSpan();
|
|
308
|
+
expect(span.status?.code).toBe(2);
|
|
309
|
+
expect(span.status?.message).toBe("network down");
|
|
310
|
+
expect(hasAttribute(span, "dash0.web.request.cancelled", { boolValue: true })).toBe(false);
|
|
311
|
+
expect(span.events.some((e) => e.name === "exception")).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
191
314
|
});
|
|
@@ -10,6 +10,7 @@ export const DEPLOYMENT_ID = "deployment.id";
|
|
|
10
10
|
export const EVENT_NAME = "event.name";
|
|
11
11
|
export const WEB_EVENT_TITLE = "dash0.web.event.title";
|
|
12
12
|
export const WEB_EVENT_ID = "dash0.web.event.id";
|
|
13
|
+
export const WEB_REQUEST_CANCELLED = "dash0.web.request.cancelled";
|
|
13
14
|
export const PAGE_LOAD_ID = "page.load.id";
|
|
14
15
|
export const SESSION_ID = "session.id";
|
|
15
16
|
export const USER_AGENT = "user_agent.original";
|
package/src/types/options.ts
CHANGED
|
@@ -17,6 +17,17 @@ export type InitOptions = {
|
|
|
17
17
|
*/
|
|
18
18
|
additionalSignalAttributes?: Record<string, AttributeValueType | AnyValue>;
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* When enabled (the default), reject `serviceName` values that contain
|
|
22
|
+
* characters commonly associated with injection payloads (quotes, angle
|
|
23
|
+
* brackets, braces, semicolons, control characters) and fall back to
|
|
24
|
+
* `location.hostname`. This guards against automated security scanners or
|
|
25
|
+
* untrusted callers influencing the value sent to `init()`.
|
|
26
|
+
*
|
|
27
|
+
* Set to `false` to opt out and pass the `serviceName` through unchanged.
|
|
28
|
+
*/
|
|
29
|
+
rejectSuspiciousServiceName?: boolean;
|
|
30
|
+
|
|
20
31
|
/**
|
|
21
32
|
* OTLP endpoints to which the generated telemetry should be sent to.
|
|
22
33
|
*/
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const SUSPICIOUS_CHARS = /['"<>{};\x00-\x1F\x7F]/;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true if the value is safe to use as a `service.name` resource attribute.
|
|
5
|
+
*
|
|
6
|
+
* Rejects values containing characters commonly used in injection payloads:
|
|
7
|
+
* quotes, angle brackets, braces, semicolons, and C0 control characters / DEL.
|
|
8
|
+
* Forward and back slashes are intentionally permitted so names like
|
|
9
|
+
* `myteam/myservice` remain valid.
|
|
10
|
+
*/
|
|
11
|
+
export function isSafeServiceName(value: string): boolean {
|
|
12
|
+
return !SUSPICIOUS_CHARS.test(value);
|
|
13
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isSafeServiceName } from "./sanitize";
|
|
3
|
+
|
|
4
|
+
describe("isSafeServiceName", () => {
|
|
5
|
+
describe("accepts safe values", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
"my-service",
|
|
8
|
+
"my_service",
|
|
9
|
+
"my.service",
|
|
10
|
+
"MyService123",
|
|
11
|
+
"myteam/myservice",
|
|
12
|
+
"myteam\\myservice",
|
|
13
|
+
"service with spaces",
|
|
14
|
+
"service-name.v2",
|
|
15
|
+
"résumé-service",
|
|
16
|
+
"サービス",
|
|
17
|
+
"a",
|
|
18
|
+
])("accepts %j", (value) => {
|
|
19
|
+
expect(isSafeServiceName(value)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("accepts the empty string (empty/whitespace handling lives elsewhere)", () => {
|
|
23
|
+
expect(isSafeServiceName("")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("rejects suspicious characters", () => {
|
|
28
|
+
it.each([
|
|
29
|
+
["single quote", "evil';DROP TABLE"],
|
|
30
|
+
["double quote", 'say "hi"'],
|
|
31
|
+
["semicolon", "a;b"],
|
|
32
|
+
["open brace", "${injected}"],
|
|
33
|
+
["close brace", "trailing}"],
|
|
34
|
+
["less-than", "<script>"],
|
|
35
|
+
["greater-than", "value>other"],
|
|
36
|
+
])("rejects %s", (_label, value) => {
|
|
37
|
+
expect(isSafeServiceName(value)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects NUL", () => {
|
|
41
|
+
expect(isSafeServiceName("a\x00b")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects embedded newline (log injection)", () => {
|
|
45
|
+
expect(isSafeServiceName("line1\nline2")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects embedded carriage return", () => {
|
|
49
|
+
expect(isSafeServiceName("a\rb")).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects embedded tab", () => {
|
|
53
|
+
expect(isSafeServiceName("a\tb")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects DEL", () => {
|
|
57
|
+
expect(isSafeServiceName("a\x7Fb")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|