Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/api/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import {
fetch,
generateUniqueId,
isSafeServiceName,
PAGE_LOAD_ID_BYTES,
warn,
debug,
Expand Down Expand Up @@ -50,9 +51,13 @@ export function init(opts: InitOptions) {
return;
}

if (!opts.serviceName.trim()) {
const trimmedServiceName = opts.serviceName.trim();
if (!trimmedServiceName) {
debug("Missing or empty serviceName value. Falling back to location.hostname.");
opts.serviceName = loc?.hostname ?? "unknown";
} else if (opts.rejectSuspiciousServiceName !== false && !isSafeServiceName(trimmedServiceName)) {
debug("serviceName contains disallowed characters. Falling back to location.hostname.");
opts.serviceName = loc?.hostname ?? "unknown";
}

vars.endpoints = opts.endpoint instanceof Array ? opts.endpoint : [opts.endpoint];
Expand Down
75 changes: 75 additions & 0 deletions src/api/init_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,81 @@ describe("init", () => {
expect(serviceNameAttr?.value.stringValue).toBe("test-hostname.example.com");
});

it.each([
["single quote", "evil';DROP TABLE users;--"],
["double quote", 'svc"name'],
["semicolon", "svc;injected"],
["open brace", "svc${tpl}"],
["close brace", "svc}name"],
["less-than", "<script>"],
["greater-than", "svc>name"],
["embedded newline", "svc\nname"],
["NUL byte", "svc\x00name"],
])("should fallback to location.hostname by default when serviceName contains %s", async (_label, suspicious) => {
init({
...baseOptions,
serviceName: suspicious,
});

const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
expect(serviceNameAttr?.value.stringValue).toBe("test-hostname.example.com");
});

it.each([
["single quote", "evil';DROP TABLE users;--"],
["double quote", 'svc"name'],
["semicolon", "svc;injected"],
["embedded newline", "svc\nname"],
])(
"should also fallback when rejectSuspiciousServiceName is explicitly true and serviceName contains %s",
async (_label, suspicious) => {
init({
...baseOptions,
serviceName: suspicious,
rejectSuspiciousServiceName: true,
});

const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
expect(serviceNameAttr?.value.stringValue).toBe("test-hostname.example.com");
}
);

it.each([
["single quote", "evil';DROP TABLE users;--"],
["semicolon", "svc;injected"],
["less-than", "<script>"],
])(
"should keep suspicious serviceName unchanged when rejectSuspiciousServiceName is explicitly false (%s)",
async (_label, suspicious) => {
init({
...baseOptions,
serviceName: suspicious,
rejectSuspiciousServiceName: false,
});

const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
expect(serviceNameAttr?.value.stringValue).toBe(suspicious);
}
);

it.each([
["forward slash", "myteam/myservice"],
["backslash", "myteam\\myservice"],
["spaces", "my service"],
["dots and hyphens", "svc-name.v2"],
])(
"should keep serviceName with allowed characters like %s under the default configuration",
async (_label, allowed) => {
init({
...baseOptions,
serviceName: allowed,
});

const serviceNameAttr = vars.resource.attributes.find((attr) => attr.key === SERVICE_NAME);
expect(serviceNameAttr?.value.stringValue).toBe(allowed);
}
);

it("should fallback to 'unknown' when serviceName is empty and location.hostname is not available", async () => {
vi.resetModules();

Expand Down
11 changes: 11 additions & 0 deletions src/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ export type InitOptions = {
*/
additionalSignalAttributes?: Record<string, AttributeValueType | AnyValue>;

/**
* When enabled (the default), reject `serviceName` values that contain
* characters commonly associated with injection payloads (quotes, angle
* brackets, braces, semicolons, control characters) and fall back to
* `location.hostname`. This guards against automated security scanners or
* untrusted callers influencing the value sent to `init()`.
*
* Set to `false` to opt out and pass the `serviceName` through unchanged.
*/
rejectSuspiciousServiceName?: boolean;

/**
* OTLP endpoints to which the generated telemetry should be sent to.
*/
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export * from "./math";
export * from "./origin";
export * from "./url";
export * from "./pick";
export * from "./sanitize";
export * from "./wrap";
13 changes: 13 additions & 0 deletions src/utils/sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const SUSPICIOUS_CHARS = /['"<>{};\x00-\x1F\x7F]/;

/**
* Returns true if the value is safe to use as a `service.name` resource attribute.
*
* Rejects values containing characters commonly used in injection payloads:
* quotes, angle brackets, braces, semicolons, and C0 control characters / DEL.
* Forward and back slashes are intentionally permitted so names like
* `myteam/myservice` remain valid.
*/
export function isSafeServiceName(value: string): boolean {
return !SUSPICIOUS_CHARS.test(value);
}
60 changes: 60 additions & 0 deletions src/utils/sanitize_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { isSafeServiceName } from "./sanitize";

describe("isSafeServiceName", () => {
describe("accepts safe values", () => {
it.each([
"my-service",
"my_service",
"my.service",
"MyService123",
"myteam/myservice",
"myteam\\myservice",
"service with spaces",
"service-name.v2",
"résumé-service",
"サービス",
"a",
])("accepts %j", (value) => {
expect(isSafeServiceName(value)).toBe(true);
});

it("accepts the empty string (empty/whitespace handling lives elsewhere)", () => {
expect(isSafeServiceName("")).toBe(true);
});
});

describe("rejects suspicious characters", () => {
it.each([
["single quote", "evil';DROP TABLE"],
["double quote", 'say "hi"'],
["semicolon", "a;b"],
["open brace", "${injected}"],
["close brace", "trailing}"],
["less-than", "<script>"],
["greater-than", "value>other"],
])("rejects %s", (_label, value) => {
expect(isSafeServiceName(value)).toBe(false);
});

it("rejects NUL", () => {
expect(isSafeServiceName("a\x00b")).toBe(false);
});

it("rejects embedded newline (log injection)", () => {
expect(isSafeServiceName("line1\nline2")).toBe(false);
});

it("rejects embedded carriage return", () => {
expect(isSafeServiceName("a\rb")).toBe(false);
});

it("rejects embedded tab", () => {
expect(isSafeServiceName("a\tb")).toBe(false);
});

it("rejects DEL", () => {
expect(isSafeServiceName("a\x7Fb")).toBe(false);
});
});
});
Loading