Skip to content

Commit ee48f70

Browse files
Rangshcursoragent
andcommitted
fix(core): redact URL userinfo credentials in shared redactors
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f077232 commit ee48f70

5 files changed

Lines changed: 186 additions & 0 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import assert from 'node:assert/strict';
21+
import { describe, test } from 'node:test';
22+
import {
23+
redactReversibleStreamingSuffix,
24+
redactSecrets,
25+
redactStableStreamingSuffix,
26+
} from '../display-redaction.js';
27+
28+
const USERINFO_CASES: Array<[string, string]> = [
29+
[
30+
'https://myuser:glpat-AbCdEf12345XyZ@gitlab.com/team/repo.git',
31+
'https://<redacted>@gitlab.com/team/repo.git',
32+
],
33+
[
34+
'https://alice:hunter2@internal.example.com/repo.git',
35+
'https://<redacted>@internal.example.com/repo.git',
36+
],
37+
[
38+
'https://alice:ATBBxyz123abc456@bitbucket.org/team/repo.git',
39+
'https://<redacted>@bitbucket.org/team/repo.git',
40+
],
41+
[
42+
'fatal: unable to access https://deploy:s3cretP@ss@git.corp.example/x.git/: 403',
43+
'fatal: unable to access https://<redacted>@git.corp.example/x.git/: 403',
44+
],
45+
[
46+
'https://user@host.example/team/repo.git',
47+
'https://<redacted>@host.example/team/repo.git',
48+
],
49+
[
50+
'origin https://alice:hunter2@internal.example.com/repo.git (fetch)',
51+
'origin https://<redacted>@internal.example.com/repo.git (fetch)',
52+
],
53+
[
54+
'see https://alice:hunter2@internal.example.com/repo.git.',
55+
'see https://<redacted>@internal.example.com/repo.git.',
56+
],
57+
[
58+
'clone (https://alice:hunter2@internal.example.com/repo.git)',
59+
'clone (https://<redacted>@internal.example.com/repo.git)',
60+
],
61+
];
62+
63+
describe('display redactSecrets', () => {
64+
test('masks URL userinfo credentials without swallowing host or path', () => {
65+
for (const [input, expected] of USERINFO_CASES) {
66+
assert.equal(redactSecrets(input), expected);
67+
}
68+
assert.equal(
69+
redactSecrets('https://api.example.com/v1?token=abc123'),
70+
'https://api.example.com/v1?token=<redacted>',
71+
);
72+
assert.equal(
73+
redactSecrets('https://ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@github.com/o/r.git'),
74+
'https://<redacted>@github.com/o/r.git',
75+
);
76+
assert.equal(
77+
redactSecrets('https://alice:hunter2@api.example.com/v1?token=abc123'),
78+
'https://<redacted>@api.example.com/v1?token=<redacted>',
79+
);
80+
});
81+
});
82+
83+
describe('display streaming suffix redactors', () => {
84+
test('keeps a stable userinfo suffix compacted until the authority ends', () => {
85+
const suffix = redactStableStreamingSuffix(
86+
'fatal: unable to access https://deploy:s3cretP@ss@',
87+
);
88+
assert.ok(suffix);
89+
assert.equal(suffix.text, 'fatal: unable to access https://<redacted>@');
90+
assert.equal(suffix.settledPrefixText, 'fatal: unable to access ');
91+
assert.equal(suffix.compactedSuffix, 'https://deploy:s3cretP@ss@');
92+
assert.equal(suffix.terminator.test('/'), true);
93+
assert.equal(suffix.terminator.test('?'), true);
94+
assert.equal(
95+
redactSecrets(suffix.settledPrefixText + suffix.compactedSuffix),
96+
suffix.text,
97+
);
98+
});
99+
100+
test('does not treat a completed userinfo URL as a streaming suffix', () => {
101+
for (const [input] of USERINFO_CASES) {
102+
const suffix = redactStableStreamingSuffix(input);
103+
assert.equal(suffix, undefined, input);
104+
assert.equal(redactReversibleStreamingSuffix(input), undefined, input);
105+
}
106+
});
107+
108+
test('still shortens a reversible provider token that reaches end-of-input', () => {
109+
const token = `ghp_${'A'.repeat(200)}`;
110+
const reversible = redactReversibleStreamingSuffix(token);
111+
assert.ok(reversible);
112+
assert.equal(redactSecrets(reversible.compactedInput), redactSecrets(token));
113+
assert.equal(reversible.compactedToken.length < token.length, true);
114+
});
115+
});

packages/core/src/__tests__/redaction.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,58 @@ describe('redactSecrets', () => {
8484
assert.equal(text.includes('secret-value'), false);
8585
});
8686

87+
test('masks URL userinfo credentials without swallowing host or path', () => {
88+
const cases: Array<[string, string]> = [
89+
[
90+
'https://myuser:glpat-AbCdEf12345XyZ@gitlab.com/team/repo.git',
91+
'https://[redacted]@gitlab.com/team/repo.git',
92+
],
93+
[
94+
'https://alice:hunter2@internal.example.com/repo.git',
95+
'https://[redacted]@internal.example.com/repo.git',
96+
],
97+
[
98+
'https://alice:ATBBxyz123abc456@bitbucket.org/team/repo.git',
99+
'https://[redacted]@bitbucket.org/team/repo.git',
100+
],
101+
[
102+
'fatal: unable to access https://deploy:s3cretP@ss@git.corp.example/x.git/: 403',
103+
'fatal: unable to access https://[redacted]@git.corp.example/x.git/: 403',
104+
],
105+
[
106+
'https://user@host.example/team/repo.git',
107+
'https://[redacted]@host.example/team/repo.git',
108+
],
109+
[
110+
'origin https://alice:hunter2@internal.example.com/repo.git (fetch)',
111+
'origin https://[redacted]@internal.example.com/repo.git (fetch)',
112+
],
113+
[
114+
'see https://alice:hunter2@internal.example.com/repo.git.',
115+
'see https://[redacted]@internal.example.com/repo.git.',
116+
],
117+
[
118+
'clone (https://alice:hunter2@internal.example.com/repo.git)',
119+
'clone (https://[redacted]@internal.example.com/repo.git)',
120+
],
121+
];
122+
for (const [input, expected] of cases) {
123+
assert.equal(redactSecrets(input), expected);
124+
}
125+
assert.equal(
126+
redactSecrets('https://api.example.com/v1?token=abc123'),
127+
'https://api.example.com/v1?token=[redacted]',
128+
);
129+
assert.equal(
130+
redactSecrets('https://ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@github.com/o/r.git'),
131+
'https://[redacted]@github.com/o/r.git',
132+
);
133+
assert.equal(
134+
redactSecrets('https://alice:hunter2@api.example.com/v1?token=abc123'),
135+
'https://[redacted]@api.example.com/v1?token=[redacted]',
136+
);
137+
});
138+
87139
test('masks quoted sensitive object keys in serialized JSON', () => {
88140
const text = redactSecrets(
89141
JSON.stringify({

packages/core/src/display-redaction.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ const PATTERNS: Pattern[] = [
5656
streamingTerminator: /[\s"'<>]/,
5757
streamingValueGroup: 3,
5858
},
59+
// URL userinfo: https://user:pass@host / https://token@host
60+
// Structural — any authority that contains `@` is credential-bearing, so
61+
// this does not depend on a provider prefix list. Runs before the query
62+
// rule so only the userinfo is replaced and host/path survive.
63+
{
64+
label: 'url userinfo',
65+
regex: /(https?:\/\/)([^/?#]*@)/gi,
66+
replacement: (m) => `${m[1]}<redacted>@`,
67+
streamingTerminator: /[/?#\s"'<>]/,
68+
streamingValueGroup: 2,
69+
},
5970
// URL query secrets: ?key=[redacted] ?token=[redacted] ?api_key=[redacted] &access_token=[redacted]
6071
// (runs before the api-key-header rule so the URL form isn't mangled.)
6172
{

packages/core/src/redaction.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export function redactSecrets(value: string): string {
6868

6969
function redactTextSecrets(value: string): string {
7070
let next = value;
71+
next = redactUrlUserinfoSecrets(next);
7172
next = redactUrlQuerySecrets(next);
7273
next = next.replace(QUOTED_SECRET_KEY_VALUE_PATTERN, (match, prefix: string, key: string) =>
7374
isSensitiveKey(key) ? `${prefix}[redacted]` : match,
@@ -168,6 +169,12 @@ function redactJsonValue(value: unknown): { value: unknown; changed: boolean } {
168169
return { value: next, changed };
169170
}
170171

172+
function redactUrlUserinfoSecrets(value: string): string {
173+
// Authority runs through the first `/`, `?`, or `#`. If it contains `@`,
174+
// everything from the host-start through the last `@` is userinfo.
175+
return value.replace(/(https?:\/\/)[^/?#]*@/gi, '$1[redacted]@');
176+
}
177+
171178
function redactUrlQuerySecrets(value: string): string {
172179
return value.replace(/([?&])([^=\s&?#]+)=([^&\s#]*)/g, (match, sep: string, key: string) => {
173180
if (!isSensitiveKey(key)) return match;

packages/ui/src/__tests__/streaming-display-redaction.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ describe('streaming display redaction', () => {
9191
`Authorization:${' '.repeat(2_048)}Bearer arbitrary-secret-value tail`,
9292
'Authorization:\n\nBearer newline-secret-value tail',
9393
'x-api-key\n:\nnewline-api-key-value tail',
94+
'https://alice:hunter2@internal.example.com/repo.git tail',
9495
];
9596
for (const input of cases) {
9697
for (const sizes of [[1], [3], [7], [20], [64], [1, 31, 2, 127, 5]]) {

0 commit comments

Comments
 (0)