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
11 changes: 11 additions & 0 deletions .changeset/cli-connection-label-shared-url-grammar.md
Original file line number Diff line number Diff line change
@@ -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.
144 changes: 144 additions & 0 deletions packages/cli/src/utils/connection-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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('//[email protected]: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/[email protected]/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:[email protected]/app');
expect(redactConnectionUrl(`http:/admin:${PASSWORD}@db.example.com/app`))
.toBe('http:/[email protected]/app');
expect(redactConnectionUrl(`wss:\\admin:${PASSWORD}@db.example.com/app`))
.toBe('wss:\\[email protected]/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<readonly [string, string]> = [
['postgres://[email protected]: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'],
['//[email protected]/app', '//[email protected]/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<readonly [string, string]> = [
['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' }))
Expand Down
29 changes: 22 additions & 7 deletions packages/cli/src/utils/connection-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=…`),
Expand All @@ -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(/:\/\/[^@/]*@/, '://');
}
}

Expand Down
Loading