Skip to content

Commit 5c95e43

Browse files
committed
fix(datasource): contractless walk reads userinfo, tail and query credentials holding ; = : @, libpq ; values, header tuples; whole-word one-word keys
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 97f650b commit 5c95e43

5 files changed

Lines changed: 381 additions & 138 deletions

File tree

‎.changeset/21840-contractless-datasource-credentials.md‎

Lines changed: 56 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -27,46 +27,65 @@ Datasources create and update; the connection test answers `ok: false`), each at
2727
own `config.<path>` — array elements included (`config.servers.0.password`):
2828

2929
- **A value under a credential-shaped key:** a non-empty string, a number, or an
30-
array holding a non-empty string or number. The key is judged on its whole name,
31-
split into words at separators and camel-case boundaries, case-insensitively. It is
32-
credential-shaped when one of its words is `password`, `passwd`, `passphrase`,
33-
`secret` or `credential`; when its last word is `token`, `pass`, `pw`, `pwd`, `jwt`,
34-
`pat`, `cookie`, `sas`, `auth`, `authorization`, `bearer` or `apikey`; when its
35-
last word is `key` alone or beside `api`, `private`, `secret`, `signing`, `master`,
36-
`encryption`, `decryption`, `account`, `shared`, `client`, `session`, `auth`,
37-
`hmac`, `license`, `subscription`, `ssh`, `aes` or `storage`; when its last word is
38-
`signature` beside `shared`, `access`, `sas` or `hmac`; when it names
39-
service-account key material (`serviceAccountKey`, `serviceAccountJson`); or when it
40-
is one of the existing canonical spellings. A trailing `value`, `values`, `pem`,
41-
`json`, `b64` or `base64` and a plural `s` are ignored first (`apiKeys`, `tokens`,
42-
`privateKeyPem`, `apiKeyValue`, `keyJson`). A one-word key with no surviving
43-
boundary (`APIKEY`, `accesstoken`) is judged on its folded spelling against the
44-
same stems. Never credential-shaped: a key whose last word is a locator,
45-
identifier or descriptor (`ref`, `refs`, `reference`, `arn`, `id`, `ids`, `name`,
46-
`names`, `path`, `paths`, `file`, `files`, `filename`, `url`, `urls`, `uri`,
47-
`endpoint`, `env`, `type`, `header`, `headers`, `field`, `prefix`, `mode`,
48-
`region`, `provider`, `source`, `chain`, `policy`, `method`, `enabled`,
49-
`authentication`, `format`, `version`, `expiry`, `expires`, `length`, `count`, or
50-
a word ending in `less`), and a multi-word key whose first word is `use`,
51-
`enable`, `enabled`, `disable`, `require`, `required`, `allow`, `has`, `is`, `no`,
52-
`skip`, `max`, `min`, `num`, `count` or `total`. So `credentialsRef`,
53-
`accessKeyId`, `tokenUrl`, `passwordFile`, `secretsManagerRegion`,
54-
`credentialProvider`, `useDefaultCredentials`, `passwordless`, `maxTokens`,
55-
`primaryKey`, `partitionKey`, `passive`, `bypass…` and a bare `accessKey` (the
56-
identity half of an access-key pair) stay accepted.
30+
array holding a non-empty string or number (an array of objects there has each
31+
object judged as a credential-shaped object, below). The key
32+
(`isCredentialShapedConfigKey`) is judged on its whole name, split into words at
33+
separators and camel-case boundaries, case-insensitively — words, never substrings.
34+
It is never credential-shaped when its last word is a locator, identifier or
35+
descriptor (`ref`, `refs`, `reference`, `arn`, `id`, `ids`, `name`, `names`, `path`,
36+
`paths`, `file`, `files`, `filename`, `url`, `urls`, `uri`, `endpoint`, `env`,
37+
`type`, `header`, `headers`, `field`, `prefix`, `mode`, `region`, `provider`,
38+
`source`, `chain`, `policy`, `method`, `enabled`, `authentication`, `format`,
39+
`version`, `expiry`, `expires`, `length`, `count`, or a word ending in `less`), or
40+
when it has more than one word and its first is `use`, `enable`, `enabled`,
41+
`disable`, `require`, `required`, `allow`, `has`, `is`, `no`, `skip`, `max`, `min`,
42+
`num`, `count` or `total`. Otherwise it is credential-shaped when it is one of the
43+
existing canonical spellings or, after dropping trailing `value`, `values`, `pem`,
44+
`json`, `b64` or `base64` words and a plural `s` (never the `s` of a word already
45+
ending in `s`: `sass` is not `sas`), when one of its words is `password`, `passwd`,
46+
`passphrase`, `secret` or `credential`; when its last word is `token`, `pass`,
47+
`pw`, `pwd`, `jwt`, `pat`, `cookie`, `sas`, `auth`, `authorization`, `bearer` or
48+
`apikey`, or folds a compound ending (`db_accesstoken`); when its last word is `key`
49+
alone or beside `api`, `private`, `secret`, `signing`, `master`, `encryption`,
50+
`decryption`, `account`, `shared`, `client`, `session`, `auth`, `hmac`, `license`,
51+
`subscription`, `ssh`, `aes` or `storage`; when its last word is `signature` beside
52+
`shared`, `access`, `sas` or `hmac`; or when it names service-account key material
53+
(`serviceAccountKey`, and `serviceAccount` before a dropped qualifier:
54+
`serviceAccountJson`, `serviceAccountPem`). So `apiKeys`, `tokens`, `privateKeyPem`,
55+
`apiKeyValue`, `tokenValue`, `keyJson`, `pass`, `pw`, `key`, `auth`,
56+
`Authorization`, `bearer`, `jwt`, `pat`, `cookie` and `sas` are credential-shaped,
57+
while `credentialsRef`, `accessKeyId`, `tokenUrl`, `passwordFile`,
58+
`secretsManagerRegion`, `credentialProvider`, `useDefaultCredentials`,
59+
`passwordless`, `maxTokens`, `primaryKey`, `partitionKey`, `passive`, `bypass…` and
60+
a bare `accessKey` (the identity half of an access-key pair) stay accepted. A
61+
one-word key with no boundary left (`APIKEY`, `accesstoken`, `dbpassword`) is judged
62+
on its folded spelling by the same rules: it is credential-shaped when it is one of
63+
the stems above (`key` and `signature` included), when it holds `password`, `passwd`, `passphrase`, `secret` or
64+
`credential` followed by nothing, `key`, `accesskey`, `hash` or `string`
65+
(`secretaccesskey` — not `secretary`), or when it ends in a folded key-material
66+
compound (`accesstoken`, `apikey`, `privatekey`, `secretkey`, `serviceaccountkey`,
67+
`serviceaccountjson`, …); a one-word key starting with `max`, `min`, `num`, `total`
68+
or `count` is not.
5769
- **The secret leaves of a credential-shaped object** (`credentials: {…}`,
58-
`auth: {…}`): every leaf except a descriptor or an identity (`type`, `clientId`,
59-
`user`, `username`, `email`, `scope`, `region`, …).
70+
`auth: {…}`): every leaf except one whose last word is a descriptor (the list above)
71+
or an identity (`user`, `username`, `login`, `email`, `issuer`, `audience`, `scope`,
72+
`scopes`, `algorithm`, `alg`, `domain`, `host`, `hostname`, `port`, `realm`,
73+
`project`, `tenant`, `subject`, `kind`, `label`, `description`) — so
74+
`credentials: { type, clientId }` is accepted whole.
6075
- **The `value` of a `{ name, value }` pair** (also `key`, `header` or `headerName`)
6176
whose name is credential-shaped — a headers list carrying `Authorization`,
62-
`X-API-Key` or `Cookie`.
63-
- **A string carrying a credential, anywhere:** a URL userinfo password; a URL query
64-
parameter whose name is credential-shaped; a credential property in a URL's
65-
`;key=value` tail (`sqlserver://h;user=u;password=p`); a credential segment of a
66-
semicolon-delimited connection string (`Server=h;Password=p`, `Pwd=`,
67-
`AccountKey=`; quoted values honoured); a credential keyword of a libpq
68-
keyword/value string (`host=h password=p`); and a scheme-less userinfo password
69-
(`user:password@host/db`).
77+
`X-API-Key` or `Cookie` — and **the second element of a `[name, value]` tuple** in
78+
a list, judged the same way (`headers: [['Authorization', '…']]`).
79+
- **A string carrying a credential, anywhere:** a URL userinfo password
80+
(`scheme://`, a stacked `jdbc:mysql://` or a scheme-relative `//`); a URL query
81+
pair whose name is credential-shaped, or whose `;key=value` run carries a
82+
credential; a credential property in a URL's `;key=value` tail
83+
(`sqlserver://h;user=u;password=p`); the Oracle thin-driver userinfo
84+
(`jdbc:oracle:thin:user/password@…`); a credential segment of a semicolon-delimited
85+
connection string (`Server=h;Password=p`, `Pwd=`, `AccountKey=`; quoted values
86+
honoured); a credential keyword of a libpq keyword/value string (`host=h
87+
password=p`, an unquoted `;` in the value included); and a scheme-less userinfo
88+
password (`user:password@host/db`).
7089
- **A subtree nested deeper than 16 levels**, which cannot be judged and is not
7190
accepted unjudged.
7291

‎packages/services/service-datasource/src/__tests__/datasource-contractless-credentials.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,24 @@ describe('the edit round trip on a legacy row', () => {
153153
expect(patch.servers).toEqual([{ host: 'a' }, { host: 'b' }]);
154154
});
155155

156+
it('a `[name, value]` header tuple and a credential embedded with a `;` round-trip too', async () => {
157+
const stored: StoredDatasource = {
158+
name: 'tuples',
159+
driver: DRIVER,
160+
origin: 'runtime',
161+
config: {
162+
headers: [['Accept', 'application/json'], ['Authorization', 'Bearer tpl-1']],
163+
libpq: 'host=wh password=lp-1;x dbname=d',
164+
},
165+
};
166+
const { service, records } = makeService([stored]);
167+
const read = await service.getDatasource('tuples');
168+
expect(read!.config).toEqual({ headers: [['Accept', 'application/json'], ['Authorization']], libpq: 'host=wh dbname=d' });
169+
expect(JSON.stringify(read)).not.toMatch(/tpl-1|lp-1/);
170+
await service.updateDatasource('tuples', { config: read!.config });
171+
expect(records[0]!.config).toEqual(stored.config);
172+
});
173+
156174
it('an author who changed an array element keeps their word; one who removed the array keeps it removed', () => {
157175
const changed = { host: 'wh.internal', servers: [{ host: 'a', password: 'new-1' }, { host: 'b' }] };
158176
const restored = restoreRedactedConfig(DRIVER, changed, LEGACY.config) as Record<string, any>;

‎packages/spec/src/data/datasource-contractless-credentials.test.ts‎

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ const CREDENTIAL_SHAPED = [
4242
'serviceAccountJson', 'sharedAccessSignature',
4343
// a trailing plural or qualifier
4444
'apiKeys', 'tokens', 'passwords', 'privateKeyPem', 'apiKeyValue', 'tokenValue', 'keyJson', 'clientSecretValue',
45+
// one-word stems, header names, folded compounds
46+
'pass', 'pw', 'pat', 'authorization', 'x-auth-token', 'Proxy-Authorization', 'set-cookie', 'service_account_json',
47+
'dbpassword', 'secretaccesskey', 'passwordhash',
4548
] as const;
4649

4750
/** Spellings that must NOT be judged credential-shaped. */
@@ -52,7 +55,10 @@ const NOT_CREDENTIAL_SHAPED = [
5255
'primaryKey', 'partitionKey', 'sortKey', 'cacheKey', 'idempotencyKey', 'accessKey', 'accessKeyId', 'clientId',
5356
'tenantId',
5457
// words that merely contain a stem
55-
'passive', 'bypass', 'bypassCache', 'passThrough', 'cookieDomain', 'tokenTtl',
58+
'passive', 'bypass', 'bypassCache', 'passThrough', 'cookieDomain', 'tokenTtl', 'compass', 'author', 'authorName',
59+
'tokenizer', 'keyspace', 'secretary', 'credentialing',
60+
// a stem already ending in `s` takes no plural `s`
61+
'sass', 'compileSass',
5662
// references, locators, identifiers, descriptors
5763
'credentialsRef', 'secretArn', 'secretName', 'passwordFile', 'privateKeyPath', 'tokenUrl', 'tokenEndpoint',
5864
'passwordEnv', 'tokenType', 'apiKeyHeader', 'tokenPrefix', 'clientSecretId', 'secretsManagerRegion',
@@ -83,6 +89,15 @@ describe('embedded credentials in a string value', () => {
8389
['libpq keyword/value', 'host=h port=5432 password=p'],
8490
['libpq quoted value', "host=h password='a b'"],
8591
['scheme-less userinfo', 'u:p@h/db'],
92+
['scheme-relative userinfo', '//u:p@h/db'],
93+
['stacked-scheme userinfo', 'jdbc:postgresql://u:p@h/db'],
94+
['Oracle thin userinfo', 'jdbc:oracle:thin:scott/tiger@//h:1521/svc'],
95+
['libpq unquoted `;` in a password', 'host=h password=a;b'],
96+
['URL userinfo with `;` in the password', 'sqlserver://u:a;b=c@h/db'],
97+
['URL tail property whose value holds `@`', 'sqlserver://h;password=a@b;databaseName=d'],
98+
['URL tail property whose value holds `:` and `@`', 'sqlserver://h;password=a:b@c'],
99+
['query pair holding `;`', 'https://h/x?token=a;b'],
100+
['query pair whose `;` run carries a credential', 'https://h/x?mode=ro;password=p'],
86101
])('finds %s', (_label, value) => {
87102
expect(embeddedCredentialOf(value)).toBeDefined();
88103
});
@@ -96,6 +111,9 @@ describe('embedded credentials in a string value', () => {
96111
['libpq with no credential', 'host=h port=5432 dbname=d'],
97112
['an email address', 'ops@example.com'],
98113
['a label', 'just a label'],
114+
['a stacked-scheme URL with no userinfo', 'jdbc:postgresql://h/db?ssl=true'],
115+
['an Oracle thin URL with no userinfo', 'jdbc:oracle:thin:@//h:1521/svc'],
116+
['a scheme-relative URL with no userinfo', '//h/db'],
99117
])('finds nothing in %s', (_label, value) => {
100118
expect(embeddedCredentialOf(value)).toBeUndefined();
101119
expect(redactEmbeddedCredentials(value)).toBe(value);
@@ -123,10 +141,27 @@ describe('embedded credentials in a string value', () => {
123141
expect(redactEmbeddedCredentials('Driver={x};PWD={a;}}b};Database=d')).toBe('Driver={x};Database=d');
124142
});
125143

126-
it('an unquoted password containing `;` and `=` takes its whole tail with it (no partial leak)', () => {
127-
const out = redactEmbeddedCredentials('Server=h;Password=ab;cd=ef;gh;Database=d');
128-
expect(out).toBe('Server=h;Database=d');
129-
expect(out).not.toMatch(/cd|ef|gh/);
144+
it.each([
145+
['Server=h;Password=SEK;RIT=a;b;Database=d', 'Server=h;Database=d'],
146+
['Password=SEK;RIT;Server=h', 'Server=h'],
147+
['host=h password=SEK;RIT dbname=d', 'host=h dbname=d'],
148+
["host=h password='SEK RIT' dbname=d", 'host=h dbname=d'],
149+
['sqlserver://u:SEK;RIT=x@h:1433;databaseName=d', 'sqlserver://u@h:1433;databaseName=d'],
150+
['sqlserver://h;password=SEK;RIT=x;databaseName=d', 'sqlserver://h;databaseName=d'],
151+
['sqlserver://h;password=SEK@RIT;databaseName=d', 'sqlserver://h;databaseName=d'],
152+
['sqlserver://h;password=SEK:RIT@x;databaseName=d', 'sqlserver://h;databaseName=d'],
153+
['https://h/x?token=SEK;RIT&mode=ro', 'https://h/x?mode=ro'],
154+
['https://h/x?mode=ro;password=SEKRIT#f', 'https://h/x#f'],
155+
['//u:SEKRIT@h/db', '//u@h/db'],
156+
['jdbc:mysql://u:SEKRIT@h/db?useSSL=true', 'jdbc:mysql://u@h/db?useSSL=true'],
157+
['jdbc:oracle:thin:scott/SEKRIT@//h:1521/svc', 'jdbc:oracle:thin:scott@//h:1521/svc'],
158+
])('an unquoted credential holding `;`, `=`, `:` or `@` leaves no tail: %s', (value, expected) => {
159+
expect(embeddedCredentialOf(value)).toBeDefined();
160+
const out = redactEmbeddedCredentials(value);
161+
expect(out).toBe(expected);
162+
expect(out).not.toMatch(/SEK|RIT/);
163+
// What the read door serves is itself credential-free: the write door accepts it back.
164+
expect(embeddedCredentialOf(out)).toBeUndefined();
130165
});
131166
});
132167

@@ -172,6 +207,18 @@ describe('write door: DatasourceSchema refuses inline credentials for a driver w
172207
).toEqual(['config.headers.0.value', 'config.headers.1.value', 'config.hosts.0', 'config.servers.0.password']);
173208
});
174209

210+
it('a credential-shaped key inside array data is refused — array data is no longer off the walk', () => {
211+
// Inverts the earlier pin that accepted `seed: [{ password: 'row-data' }]`.
212+
expect(refusals({ seed: [{ password: 'row-data' }] })).toEqual(['config.seed.0.password']);
213+
});
214+
215+
it('refuses the value of a `[name, value]` header tuple naming a credential, and only that', () => {
216+
expect(
217+
refusals({ headers: [['Authorization', 'Bearer t'], ['Cookie', 'sid=1'], ['Accept', 'application/json']] }).sort(),
218+
).toEqual(['config.headers.0.1', 'config.headers.1.1']);
219+
expect(refusals({ pairs: [['token', '']], range: ['password', 'x'] })).toEqual([]);
220+
});
221+
175222
it('control: plain row data in an array is accepted', () => {
176223
expect(refusals({ seed: [{ name: 'a', amount: 1 }, { name: 'b', amount: 2 }], tags: ['x', 'y'] })).toEqual([]);
177224
});
@@ -208,8 +255,13 @@ describe('write door: DatasourceSchema refuses inline credentials for a driver w
208255
connectionString: 'Server=h;Password=p',
209256
libpq: 'host=h password=p',
210257
target: 'u:p@h/db',
258+
libpqSemicolon: 'host=h password=a;b',
259+
oracle: 'jdbc:oracle:thin:scott/tiger@//h:1521/svc',
211260
}),
212-
).toEqual(['config.url', 'config.dsn', 'config.jdbc', 'config.connectionString', 'config.libpq', 'config.target']);
261+
).toEqual([
262+
'config.url', 'config.dsn', 'config.jdbc', 'config.connectionString', 'config.libpq', 'config.target',
263+
'config.libpqSemicolon', 'config.oracle',
264+
]);
213265
});
214266

215267
it('accepts an environment-name placeholder in place of a value — and only that grammar', () => {
@@ -263,6 +315,7 @@ const STORED = {
263315
credentials: { type: 'service_account', value: 'sa' },
264316
servers: [{ host: 'a', password: 'p1' }, { host: 'b' }],
265317
headers: [{ name: 'Authorization', value: 'Bearer t' }, { name: 'Accept', value: 'application/json' }],
318+
tuples: [['Authorization', 'Bearer t2'], ['Accept', 'application/json']],
266319
connectionString: 'Server=h;User Id=u;Password=p;Database=d',
267320
libpq: 'host=h password=p dbname=d',
268321
url: 'https://u:p@h/x?mode=ro',
@@ -280,6 +333,7 @@ describe('read door: redactDatasourceConfig for a driver with no shipped contrac
280333
credentials: { type: 'service_account' },
281334
servers: [{ host: 'a' }, { host: 'b' }],
282335
headers: [{ name: 'Authorization' }, { name: 'Accept', value: 'application/json' }],
336+
tuples: [['Authorization'], ['Accept', 'application/json']],
283337
connectionString: 'Server=h;User Id=u;Database=d',
284338
libpq: 'host=h dbname=d',
285339
url: 'https://u@h/x?mode=ro',
@@ -295,11 +349,43 @@ describe('read door: redactDatasourceConfig for a driver with no shipped contrac
295349
'libpq',
296350
'oauth.client_secret',
297351
'servers.0.password',
352+
'tuples.0.1',
298353
'url',
299354
]);
300355
expect(JSON.stringify(config)).not.toMatch(/"k"|k1|"cs"|"sa"|p1|Bearer|Password=p|password=p|u:p@/);
301356
});
302357

358+
it('withholds a subtree too deep to judge — the same position the write door refuses', () => {
359+
let deep: Record<string, unknown> = { leaf: 'x' };
360+
for (let i = 0; i < 20; i += 1) deep = { n: deep };
361+
const { config, redactedKeys } = redactDatasourceConfig(DRIVER, { host: 'h', deep });
362+
expect(redactedKeys).toHaveLength(1);
363+
expect(['config', ...(redactedKeys[0] as string).split('.')].join('.')).toEqual(refusals({ host: 'h', deep })[0]);
364+
expect(JSON.stringify(config)).not.toContain('leaf');
365+
});
366+
367+
it('an array element withheld before its siblings is nulled, never shifting them; one at the end is spliced', () => {
368+
// Twenty nested `[inner, 'sib']` pairs: the walk's depth cap lands on an
369+
// `inner` that has a sibling after it.
370+
let nested: unknown = ['x', 'sib'];
371+
for (let i = 0; i < 20; i += 1) nested = [nested, 'sib'];
372+
const { config, redactedPaths } = redactDatasourceConfig(DRIVER, { list: nested });
373+
expect(redactedPaths).toHaveLength(1);
374+
let node = (config as { list: unknown }).list;
375+
let levels = 0;
376+
while (Array.isArray(node)) {
377+
expect(node).toHaveLength(2);
378+
expect(node[1]).toBe('sib');
379+
node = node[0];
380+
levels += 1;
381+
}
382+
expect(node).toBeNull();
383+
expect(levels).toBe((redactedPaths[0] as readonly string[]).length - 1);
384+
// A withheld element at the END of its array is spliced.
385+
const tail = redactDatasourceConfig(DRIVER, { headers: [['Accept', 'json'], ['Authorization', 'Bearer t']] });
386+
expect(tail.config).toEqual({ headers: [['Accept', 'json'], ['Authorization']] });
387+
});
388+
303389
it('the input is never mutated', () => {
304390
const before = JSON.stringify(STORED);
305391
redactDatasourceConfig(DRIVER, STORED);

0 commit comments

Comments
 (0)