Skip to content

Commit b2b8254

Browse files
authored
[core] Sanitize URLs in tracing policy (Azure#29606)
### Packages impacted by this PR @azure/core-rest-pipeline @typespec/ts-http-runtime ### Issues associated with this PR Resolves Azure#29433 ### Describe the problem that is addressed by this PR Tracing spans capture the url as an attribute. A customer noticed that URLs are not sanitized correctly in Java - specifically the QS params. This was fixed in Java and will now be fixed in JS. It could be considered a breaking change; however, the OTEL instrumentation package is still in beta and as this should have been sanitized earlier we can consider this a bugfix. ### What are the possible designs available to address the problem? If there are more than one possible design, why was the one in this PR chosen? I could duplicate the Sanitizer#sanitizeUrl method; however, I wanted to make sure I can take advantge of the default query params if the list is ever updated.
1 parent 7bcd0ab commit b2b8254

File tree

11 files changed

+143
-65
lines changed

11 files changed

+143
-65
lines changed

sdk/core/core-rest-pipeline/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
### Bugs Fixed
1010

11+
- Tracing spans will now correctly sanitize query parameters in the http.url span attribute. [#29606](https://github.com/Azure/azure-sdk-for-js/pull/29606)
12+
1113
### Other Changes
1214

1315
## 1.16.0 (2024-05-02)

sdk/core/core-rest-pipeline/review/core-rest-pipeline.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ export const tracingPolicyName = "tracingPolicy";
478478

479479
// @public
480480
export interface TracingPolicyOptions {
481+
additionalAllowedQueryParameters?: string[];
481482
userAgentPrefix?: string;
482483
}
483484

sdk/core/core-rest-pipeline/src/createPipelineFromOptions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ export function createPipelineFromOptions(options: InternalPipelineOptions): Pip
9494
// properties (e.g., making the boundary constant in recorded tests).
9595
pipeline.addPolicy(multipartPolicy(), { afterPhase: "Deserialize" });
9696
pipeline.addPolicy(defaultRetryPolicy(options.retryOptions), { phase: "Retry" });
97-
pipeline.addPolicy(tracingPolicy(options.userAgentOptions), { afterPhase: "Retry" });
97+
pipeline.addPolicy(tracingPolicy({ ...options.userAgentOptions, ...options.loggingOptions }), {
98+
afterPhase: "Retry",
99+
});
98100
if (isNodeLike) {
99101
// Both XHR and Fetch expect to handle redirects automatically,
100102
// so only include this policy when we're in Node.

sdk/core/core-rest-pipeline/src/policies/tracingPolicy.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { getUserAgentValue } from "../util/userAgent.js";
1414
import { logger } from "../log.js";
1515
import { getErrorMessage, isError } from "@azure/core-util";
1616
import { isRestError } from "../restError.js";
17+
import { Sanitizer } from "../util/sanitizer.js";
1718

1819
/**
1920
* The programmatic identifier of the tracingPolicy.
@@ -30,6 +31,11 @@ export interface TracingPolicyOptions {
3031
* Defaults to an empty string.
3132
*/
3233
userAgentPrefix?: string;
34+
/**
35+
* Query string names whose values will be logged when logging is enabled. By default no
36+
* query string values are logged.
37+
*/
38+
additionalAllowedQueryParameters?: string[];
3339
}
3440

3541
/**
@@ -40,6 +46,9 @@ export interface TracingPolicyOptions {
4046
*/
4147
export function tracingPolicy(options: TracingPolicyOptions = {}): PipelinePolicy {
4248
const userAgent = getUserAgentValue(options.userAgentPrefix);
49+
const sanitizer = new Sanitizer({
50+
additionalAllowedQueryParameters: options.additionalAllowedQueryParameters,
51+
});
4352
const tracingClient = tryCreateTracingClient();
4453

4554
return {
@@ -49,7 +58,17 @@ export function tracingPolicy(options: TracingPolicyOptions = {}): PipelinePolic
4958
return next(request);
5059
}
5160

52-
const { span, tracingContext } = tryCreateSpan(tracingClient, request, userAgent) ?? {};
61+
const spanAttributes = {
62+
"http.url": sanitizer.sanitizeUrl(request.url),
63+
"http.method": request.method,
64+
"http.user_agent": userAgent,
65+
requestId: request.requestId,
66+
};
67+
if (userAgent) {
68+
spanAttributes["http.user_agent"] = userAgent;
69+
}
70+
71+
const { span, tracingContext } = tryCreateSpan(tracingClient, request, spanAttributes) ?? {};
5372

5473
if (!span || !tracingContext) {
5574
return next(request);
@@ -83,7 +102,7 @@ function tryCreateTracingClient(): TracingClient | undefined {
83102
function tryCreateSpan(
84103
tracingClient: TracingClient,
85104
request: PipelineRequest,
86-
userAgent?: string,
105+
spanAttributes: Record<string, unknown>,
87106
): { span: TracingSpan; tracingContext: TracingContext } | undefined {
88107
try {
89108
// As per spec, we do not need to differentiate between HTTP and HTTPS in span name.
@@ -92,11 +111,7 @@ function tryCreateSpan(
92111
{ tracingOptions: request.tracingOptions },
93112
{
94113
spanKind: "client",
95-
spanAttributes: {
96-
"http.method": request.method,
97-
"http.url": request.url,
98-
requestId: request.requestId,
99-
},
114+
spanAttributes,
100115
},
101116
);
102117

@@ -106,10 +121,6 @@ function tryCreateSpan(
106121
return undefined;
107122
}
108123

109-
if (userAgent) {
110-
span.setAttribute("http.user_agent", userAgent);
111-
}
112-
113124
// set headers
114125
const headers = tracingClient.createRequestHeaders(
115126
updatedOptions.tracingOptions.tracingContext,

sdk/core/core-rest-pipeline/src/util/sanitizer.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,26 @@ export class Sanitizer {
132132
);
133133
}
134134

135+
public sanitizeUrl(value: string): string {
136+
if (typeof value !== "string" || value === null) {
137+
return value;
138+
}
139+
140+
const url = new URL(value);
141+
142+
if (!url.search) {
143+
return value;
144+
}
145+
146+
for (const [key] of url.searchParams) {
147+
if (!this.allowedQueryParameters.has(key.toLowerCase())) {
148+
url.searchParams.set(key, RedactedString);
149+
}
150+
}
151+
152+
return url.toString();
153+
}
154+
135155
private sanitizeHeaders(obj: UnknownObject): UnknownObject {
136156
const sanitized: UnknownObject = {};
137157
for (const key of Object.keys(obj)) {
@@ -161,24 +181,4 @@ export class Sanitizer {
161181

162182
return sanitized;
163183
}
164-
165-
private sanitizeUrl(value: string): string {
166-
if (typeof value !== "string" || value === null) {
167-
return value;
168-
}
169-
170-
const url = new URL(value);
171-
172-
if (!url.search) {
173-
return value;
174-
}
175-
176-
for (const [key] of url.searchParams) {
177-
if (!this.allowedQueryParameters.has(key.toLowerCase())) {
178-
url.searchParams.set(key, RedactedString);
179-
}
180-
}
181-
182-
return url.toString();
183-
}
184184
}

sdk/core/core-rest-pipeline/test/tracingPolicy.spec.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ describe("tracingPolicy", function () {
154154

155155
const createdSpan = activeInstrumenter.lastSpanCreated;
156156
assert.exists(createdSpan);
157-
const mockSpan = createdSpan!;
157+
const mockSpan = createdSpan;
158158
assert.isTrue(mockSpan.endCalled, "expected span to be ended");
159159
assert.equal(mockSpan.name, "HTTP POST");
160160
assert.equal(mockSpan.getAttribute("http.method"), "POST");
@@ -163,6 +163,30 @@ describe("tracingPolicy", function () {
163163
assert.equal(mockSpan.getAttribute("http.status_code"), 200); // createTestRequest's response will return 200 OK
164164
});
165165

166+
it("will sanitize URLs", async () => {
167+
const policy = tracingPolicy({ additionalAllowedQueryParameters: ["allowedQueryParam"] });
168+
const request = createPipelineRequest({
169+
url: "https://bing.com/search?redactedParam=redactedValue&allowedQueryParam=allowedValue",
170+
tracingOptions: { tracingContext: noopTracingContext },
171+
});
172+
173+
const response: PipelineResponse = {
174+
headers: createHttpHeaders(),
175+
request: request,
176+
status: 200,
177+
};
178+
const next = vi.fn<Parameters<SendRequest>, ReturnType<SendRequest>>();
179+
next.mockResolvedValue(response);
180+
181+
await policy.sendRequest(request, next);
182+
const createdSpan = activeInstrumenter.lastSpanCreated;
183+
assert.exists(createdSpan);
184+
185+
const spanUrlValue = new URL(createdSpan.getAttribute("http.url") as string);
186+
assert.equal(spanUrlValue.searchParams.get("redactedParam"), "REDACTED");
187+
assert.equal(spanUrlValue.searchParams.get("allowedQueryParam"), "allowedValue");
188+
});
189+
166190
it("will set request headers correctly", async () => {
167191
vi.spyOn(activeInstrumenter, "createRequestHeaders").mockReturnValue({
168192
testheader: "testvalue",

sdk/core/ts-http-runtime/review/ts-http-runtime.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,7 @@ export const tracingPolicyName = "tracingPolicy";
777777

778778
// @public
779779
export interface TracingPolicyOptions {
780+
additionalAllowedQueryParameters?: string[];
780781
userAgentPrefix?: string;
781782
}
782783

sdk/core/ts-http-runtime/src/createPipelineFromOptions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ export function createPipelineFromOptions(options: InternalPipelineOptions): Pip
9393
// properties (e.g., making the boundary constant in recorded tests).
9494
pipeline.addPolicy(multipartPolicy(), { afterPhase: "Deserialize" });
9595
pipeline.addPolicy(defaultRetryPolicy(options.retryOptions), { phase: "Retry" });
96-
pipeline.addPolicy(tracingPolicy(options.userAgentOptions), { afterPhase: "Retry" });
96+
pipeline.addPolicy(tracingPolicy({ ...options.userAgentOptions, ...options.loggingOptions }), {
97+
afterPhase: "Retry",
98+
});
9799
if (isNodeLike) {
98100
// Both XHR and Fetch expect to handle redirects automatically,
99101
// so only include this policy when we're in Node.

sdk/core/ts-http-runtime/src/policies/tracingPolicy.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getUserAgentValue } from "../util/userAgent.js";
1010
import { logger } from "../log.js";
1111
import { getErrorMessage, isError } from "../util/error.js";
1212
import { isRestError } from "../restError.js";
13+
import { Sanitizer } from "../util/sanitizer.js";
1314

1415
/**
1516
* The programmatic identifier of the tracingPolicy.
@@ -26,6 +27,11 @@ export interface TracingPolicyOptions {
2627
* Defaults to an empty string.
2728
*/
2829
userAgentPrefix?: string;
30+
/**
31+
* Query string names whose values will be logged when logging is enabled. By default no
32+
* query string values are logged.
33+
*/
34+
additionalAllowedQueryParameters?: string[];
2935
}
3036

3137
/**
@@ -36,6 +42,9 @@ export interface TracingPolicyOptions {
3642
*/
3743
export function tracingPolicy(options: TracingPolicyOptions = {}): PipelinePolicy {
3844
const userAgent = getUserAgentValue(options.userAgentPrefix);
45+
const sanitizer = new Sanitizer({
46+
additionalAllowedQueryParameters: options.additionalAllowedQueryParameters,
47+
});
3948
const tracingClient = tryCreateTracingClient();
4049

4150
return {
@@ -45,7 +54,17 @@ export function tracingPolicy(options: TracingPolicyOptions = {}): PipelinePolic
4554
return next(request);
4655
}
4756

48-
const { span, tracingContext } = tryCreateSpan(tracingClient, request, userAgent) ?? {};
57+
const spanAttributes = {
58+
"http.url": sanitizer.sanitizeUrl(request.url),
59+
"http.method": request.method,
60+
"http.user_agent": userAgent,
61+
requestId: request.requestId,
62+
};
63+
if (userAgent) {
64+
spanAttributes["http.user_agent"] = userAgent;
65+
}
66+
67+
const { span, tracingContext } = tryCreateSpan(tracingClient, request, spanAttributes) ?? {};
4968

5069
if (!span || !tracingContext) {
5170
return next(request);
@@ -79,7 +98,7 @@ function tryCreateTracingClient(): TracingClient | undefined {
7998
function tryCreateSpan(
8099
tracingClient: TracingClient,
81100
request: PipelineRequest,
82-
userAgent?: string,
101+
spanAttributes: Record<string, unknown>,
83102
): { span: TracingSpan; tracingContext: TracingContext } | undefined {
84103
try {
85104
// As per spec, we do not need to differentiate between HTTP and HTTPS in span name.
@@ -88,11 +107,7 @@ function tryCreateSpan(
88107
{ tracingOptions: request.tracingOptions },
89108
{
90109
spanKind: "client",
91-
spanAttributes: {
92-
"http.method": request.method,
93-
"http.url": request.url,
94-
requestId: request.requestId,
95-
},
110+
spanAttributes,
96111
},
97112
);
98113

@@ -102,10 +117,6 @@ function tryCreateSpan(
102117
return undefined;
103118
}
104119

105-
if (userAgent) {
106-
span.setAttribute("http.user_agent", userAgent);
107-
}
108-
109120
// set headers
110121
const headers = tracingClient.createRequestHeaders(
111122
updatedOptions.tracingOptions.tracingContext,

sdk/core/ts-http-runtime/src/util/sanitizer.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,26 @@ export class Sanitizer {
132132
);
133133
}
134134

135+
public sanitizeUrl(value: string): string {
136+
if (typeof value !== "string" || value === null) {
137+
return value;
138+
}
139+
140+
const url = new URL(value);
141+
142+
if (!url.search) {
143+
return value;
144+
}
145+
146+
for (const [key] of url.searchParams) {
147+
if (!this.allowedQueryParameters.has(key.toLowerCase())) {
148+
url.searchParams.set(key, RedactedString);
149+
}
150+
}
151+
152+
return url.toString();
153+
}
154+
135155
private sanitizeHeaders(obj: UnknownObject): UnknownObject {
136156
const sanitized: UnknownObject = {};
137157
for (const key of Object.keys(obj)) {
@@ -161,24 +181,4 @@ export class Sanitizer {
161181

162182
return sanitized;
163183
}
164-
165-
private sanitizeUrl(value: string): string {
166-
if (typeof value !== "string" || value === null) {
167-
return value;
168-
}
169-
170-
const url = new URL(value);
171-
172-
if (!url.search) {
173-
return value;
174-
}
175-
176-
for (const [key] of url.searchParams) {
177-
if (!this.allowedQueryParameters.has(key.toLowerCase())) {
178-
url.searchParams.set(key, RedactedString);
179-
}
180-
}
181-
182-
return url.toString();
183-
}
184184
}

0 commit comments

Comments
 (0)