diff --git a/.changeset/cli-connection-label-shared-url-grammar.md b/.changeset/cli-connection-label-shared-url-grammar.md new file mode 100644 index 00000000000..cf4484ecbd4 --- /dev/null +++ b/.changeset/cli-connection-label-shared-url-grammar.md @@ -0,0 +1,11 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): a connection label printed by the CLI removes a password or a credential query pair wherever the URL rules read one + +Clause-②: no +Metadata-protocol: none + +- **One credential grammar.** The database label that `os dev`, `os start` and `os serve` print, and the migrate and resync confirms, now remove URL credentials through the same shared function the datasource read door applies (`redactUrlCredentials` from `@objectstack/spec/data`): the userinfo password, and every credential query pair judged by the shared query-pair judge and its one list of names. Wherever those rules read a credential, on every path the label takes, the label drops it. When the shared rules read more, the label follows with no edit of its own. +- **Unchanged.** Every value the shared rules read no credential in prints exactly as before: `postgres://`, `libsql://` and `file:` values, values with no userinfo, a username-only userinfo, and everything the URL parser already cleaned. diff --git a/packages/cli/src/utils/connection-display.test.ts b/packages/cli/src/utils/connection-display.test.ts index 020dde4d0d3..bb259c48089 100644 --- a/packages/cli/src/utils/connection-display.test.ts +++ b/packages/cli/src/utils/connection-display.test.ts @@ -12,6 +12,11 @@ // shape leaks the credentials the DSN carries. import { describe, it, expect } from 'vitest'; +import { + CREDENTIAL_URL_QUERY_PARAM_NAMES, + urlCredentialQueryParams, + urlUserinfoPassword, +} from '@objectstack/spec/data'; import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from './connection-display.js'; describe('redactConnectionUrl', () => { @@ -56,6 +61,145 @@ describe('redactConnectionUrl', () => { }); }); +// The label is a print-time reader of the same question the datasource doors +// answer: where does a URL carry a password? It asks the platform's ONE +// userinfo grammar (`@objectstack/spec/data`) rather than keeping its own, so +// the oracle below is that grammar's own password accessor. +describe('redactConnectionUrl — the password rule is the shared URL grammar', () => { + const PASSWORD = 'S3CRET-PW'; + + it('removes the password wherever the shared grammar reads one, with or without a literal `://`', () => { + // A scheme-relative authority. + expect(redactConnectionUrl(`//admin:${PASSWORD}@db.example.com:5432/app`)) + .toBe('//admin@db.example.com:5432/app'); + // A tab inside the `//`, which a URL parser deletes before it reads. + expect(redactConnectionUrl(`postgres:/\t/admin:${PASSWORD}@db.example.com/app`)) + .toBe('postgres:/\t/admin@db.example.com/app'); + }); + + it('removes the password where the shared grammar reads a special scheme\'s authority without a `//`', () => { + expect(redactConnectionUrl(`https:admin:${PASSWORD}@db.example.com/app`)) + .toBe('https:admin@db.example.com/app'); + expect(redactConnectionUrl(`http:/admin:${PASSWORD}@db.example.com/app`)) + .toBe('http:/admin@db.example.com/app'); + expect(redactConnectionUrl(`wss:\\admin:${PASSWORD}@db.example.com/app`)) + .toBe('wss:\\admin@db.example.com/app'); + }); + + it('removes the whole password up to the last `@` of the authority, as the shared grammar bounds it', () => { + expect(redactConnectionUrl('postgres://admin:S3C@RET-PW@h1:5432,h2:5432/app')) + .toBe('postgres://h1:5432,h2:5432/app'); + }); + + it('never prints a password the shared grammar reads, in any URL shape', () => { + const shapes = [ + `postgres://admin:${PASSWORD}@db.example.com:5432/app`, + `postgres://:${PASSWORD}@db.example.com/app`, + `mysql://admin:${PASSWORD}@db.example.com:3306/app`, + `libsql://admin:${PASSWORD}@app-org.turso.io?authToken=jwt`, + `postgres://admin:${PASSWORD}@h1:5432,h2:5432/app`, + `//admin:${PASSWORD}@db.example.com:5432/app`, + `postgres:/\t/admin:${PASSWORD}@db.example.com/app`, + ` \npostgres://admin:${PASSWORD}@db.example.com/app`, + // Special schemes without the `//`, read as a URL parser reads them. + `https:admin:${PASSWORD}@db.example.com/app`, + `http:/admin:${PASSWORD}@db.example.com/app`, + `wss:\\admin:${PASSWORD}@db.example.com/app`, + ]; + const judged = shapes.filter((value) => urlUserinfoPassword(value) !== undefined); + // Non-vacuity floor: the shared grammar reads a password in every shape + // listed, so the property below is never checked over nothing. + expect(judged.length).toBe(shapes.length); + for (const value of judged) { + expect(urlUserinfoPassword(value)).toBe(PASSWORD); + expect(redactConnectionUrl(value), value).not.toContain(PASSWORD); + } + }); + + it('keeps its output byte for byte for every value the shared grammar reads no password in', () => { + const controls: ReadonlyArray = [ + ['postgres://admin@db.example.com:5432/app', 'postgres://db.example.com:5432/app'], + ['postgres://admin:@db.example.com/app', 'postgres://db.example.com/app'], + ['postgres://db.example.com:5432/app?sslmode=require', 'postgres://db.example.com:5432/app'], + ['postgres://admin@h1:5432,h2:5432/app', 'postgres://h1:5432,h2:5432/app'], + ['libsql://app-org.turso.io?authToken=secret-jwt', 'libsql://app-org.turso.io'], + ['https://db.example.com/app', 'https://db.example.com/app'], + ['file:./data/app.db', 'file:./data/app.db'], + ['file:///var/lib/app.db', 'file:///var/lib/app.db'], + ['sqlite:/var/lib/app.db', 'sqlite:/var/lib/app.db'], + ['//admin@db.example.com/app', '//admin@db.example.com/app'], + [':memory:', ':memory:'], + ['(in-memory)', '(in-memory)'], + ]; + for (const [value, printed] of controls) { + expect(urlUserinfoPassword(value), value).toBeUndefined(); + expect(redactConnectionUrl(value), value).toBe(printed); + } + }); + + it('keeps its output byte for byte for a password the URL parser already removed', () => { + expect(redactConnectionUrl(`postgres://admin:${PASSWORD}@db.example.com:5432/app`)) + .toBe('postgres://db.example.com:5432/app'); + expect(redactConnectionUrl(`libsql://admin:${PASSWORD}@app-org.turso.io?authToken=jwt`)) + .toBe('libsql://app-org.turso.io'); + }); +}); + +// The query half of the same rule: a credential query pair is judged by the +// shared query-pair judge and its one list of names, on every path the label +// takes — a value with no `://`, the URL parser's, and the hand fallback for a +// value the parser rejects. +describe('redactConnectionUrl — the credential query rule is the shared query-pair judge', () => { + const TOKEN = 'T0KEN-VALUE'; + + it('removes a credential query pair from a value with no `://`', () => { + expect(redactConnectionUrl(`libsql:app-org.turso.io?authToken=${TOKEN}`)) + .toBe('libsql:app-org.turso.io'); + expect(redactConnectionUrl(`file:local.db?authToken=${TOKEN}&mode=ro`)) + .toBe('file:local.db?mode=ro'); + }); + + it('removes a credential query pair on the hand fallback for a value the URL parser rejects', () => { + expect(redactConnectionUrl(`postgres://admin@h1:5432,h2:5432/app?password=${TOKEN}&sslmode=require`)) + .toBe('postgres://h1:5432,h2:5432/app?sslmode=require'); + }); + + it('removes a credential query pair on the URL parser path', () => { + expect(redactConnectionUrl(`postgres://db.example.com/app?PASSWORD=${TOKEN}`)) + .toBe('postgres://db.example.com/app'); + expect(redactConnectionUrl(`libsql://app-org.turso.io?authToken=${TOKEN}`)) + .toBe('libsql://app-org.turso.io'); + }); + + it('never prints a credential query value the shared judge reads, on any path', () => { + const shapes = [ + `libsql:app-org.turso.io?authToken=${TOKEN}`, + `file:local.db?mode=ro&AUTHTOKEN=${TOKEN}`, + `file:local.db?auth%54oken=${TOKEN}`, + `postgres://admin@h1:5432,h2:5432/app?password=${TOKEN}`, + `postgres://db.example.com/app?password=${TOKEN}`, + `libsql://app-org.turso.io?authToken=${TOKEN}#frag`, + ]; + for (const value of shapes) { + expect(urlCredentialQueryParams(value, CREDENTIAL_URL_QUERY_PARAM_NAMES), value).not.toEqual([]); + expect(redactConnectionUrl(value), value).not.toContain(TOKEN); + } + }); + + it('keeps its output byte for byte for every value with no credential query pair', () => { + const controls: ReadonlyArray = [ + ['postgres://admin@h1:5432,h2:5432/app?sslmode=require', 'postgres://h1:5432,h2:5432/app?sslmode=require'], + ['file:local.db?mode=ro', 'file:local.db?mode=ro'], + ['libsql:app-org.turso.io?authToken=', 'libsql:app-org.turso.io?authToken='], + ['postgres://db.example.com:5432/app?sslmode=require', 'postgres://db.example.com:5432/app'], + ]; + for (const [value, printed] of controls) { + expect(urlCredentialQueryParams(value, CREDENTIAL_URL_QUERY_PARAM_NAMES), value).toEqual([]); + expect(redactConnectionUrl(value), value).toBe(printed); + } + }); +}); + describe('describeDriverConnection', () => { it('reads a DSN string', () => { expect(describeDriverConnection({ client: 'pg', connection: 'postgres://u:p@host:5432/app' })) diff --git a/packages/cli/src/utils/connection-display.ts b/packages/cli/src/utils/connection-display.ts index 66ca3a885ea..12a5a8ad60b 100644 --- a/packages/cli/src/utils/connection-display.ts +++ b/packages/cli/src/utils/connection-display.ts @@ -24,18 +24,33 @@ * that the driver label and the host don't already say. */ +import { redactUrlCredentials } from '@objectstack/spec/data'; + /** * Strip credentials from a connection string. * - * Values that aren't URLs — a sqlite path, `:memory:`, the `(in-memory)` - * marker — pass through untouched, as does anything the URL parser rejects - * (minus its userinfo segment). Idempotent, so a call site that can't tell - * whether its input was already redacted may apply it defensively. + * Credentials go first, and by the platform's ONE URL credential grammar + * (`redactUrlCredentials` from `@objectstack/spec/data`, the composite the + * datasource read door applies): wherever that grammar reads a userinfo + * password or a credential query pair — the string judged as a URL parser + * cleans it, the pair judged by the shared query-pair judge and its one list + * of credential names — the label drops it, so the label cannot print a + * credential the doors would refuse or redact. No second grammar and no second + * name list live here; when the shared ones read more, this label follows + * without an edit. + * + * Then the display shaping, unchanged: a value carrying `://` loses its + * username and query string through the URL parser, or its userinfo segment + * by hand when the parser rejects it. Values that aren't URLs — a sqlite path, + * `:memory:`, the `(in-memory)` marker — pass through untouched unless the + * shared grammar read a credential in them. Idempotent, so a call site that + * can't tell whether its input was already redacted may apply it defensively. */ export function redactConnectionUrl(value: string): string { - if (!value.includes('://')) return value; + const withoutCredentials = redactUrlCredentials(value); + if (!withoutCredentials.includes('://')) return withoutCredentials; try { - const url = new URL(value); + const url = new URL(withoutCredentials); url.username = ''; url.password = ''; // Query params are where tokens hide (`libsql://….turso.io?authToken=…`), @@ -45,7 +60,7 @@ export function redactConnectionUrl(value: string): string { } catch { // Not parseable (a driver-specific dialect, a malformed URL). Drop the // userinfo segment by hand rather than printing the whole thing. - return value.replace(/:\/\/[^@/]*@/, '://'); + return withoutCredentials.replace(/:\/\/[^@/]*@/, '://'); } }