diff --git a/.changeset/url-credential-special-scheme-read-however-many-slashes.md b/.changeset/url-credential-special-scheme-read-however-many-slashes.md new file mode 100644 index 00000000000..fb4a0dd1f53 --- /dev/null +++ b/.changeset/url-credential-special-scheme-read-however-many-slashes.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a URL with a special scheme is read for credentials the way a URL parser reads it, however many slashes follow the scheme + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing at the two metadata doors that judge a URL credential: an authored connector instance (`DeclarativeConnectorEntrySchema` — `defineStack({ connectors })`, `os validate` / `os compile`, the artifact load, `PUT /api/v1/meta/connector/:name`) and a datasource driver config's URL keys (`config.url` on postgres, mysql, mongo and turso, turso `config.syncUrl`) at every door that parses the datasource. + +- **A special scheme's authority is read as a URL parser reads it.** For `http`, `https`, `ws`, `wss` and `ftp`, in any letter case, the authority starts after the whole run of `/` and `\` that follows the scheme, however long, none included. A connector string holding a userinfo there, and a datasource URL holding a userinfo password there, are refused (`422 INVALID_METADATA` at the save door, located at the string, naming the parts, never the value); a bare username in a datasource URL stays accepted. +- **One grammar at both doors.** The connector entry and the datasource doors read the one userinfo grammar, and both read redactors rewrite exactly what the write refuses, at the position the write judged, serving every other character as stored. +- **Unchanged.** Every other scheme keeps `scheme://` or a scheme-relative `//` (`postgres:`, `libsql:`, `mongodb:` and the rest carry no authority without `//`); `file:` carries no userinfo; a URL spelled `scheme://` reads exactly as before. A stored row is never re-judged: a save of its served form keeps the stored value, and an edit of the served value stores exactly the edit. + +## FROM → TO + +```ts +// before — parsed green; now refused at providerConfig.baseUrl +{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest', + providerConfig: { baseUrl: 'https:///billing:…@api.billing.example.com/v1' }, + auth: { type: 'none' } }, + +// after — the URL holds no credential, and the password is a reference +{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest', + providerConfig: { baseUrl: 'https://api.billing.example.com/v1' }, + auth: { type: 'basic', username: 'billing', credentialRef: 'BILLING_API_PASSWORD' } }, +``` + +The same holds for `https:billing:…@host`, `https:/billing:…@host` and `https:\billing:…@host`, and for a datasource URL key: spell it `scheme://`, keep it credential-free (a bare username stays accepted) and bind the password through the Setup → Datasources connection form's secret field, or reference it with `external.credentialsRef`. There is deliberately no automatic rewrite: the value in the string may be the only copy of the secret. The upgrade guide entry is `url-credential-special-scheme-read-however-many-slashes`. diff --git a/packages/spec/src/data/datasource-credential-redaction.test.ts b/packages/spec/src/data/datasource-credential-redaction.test.ts index 5dbc15500cb..2449e906e6c 100644 --- a/packages/spec/src/data/datasource-credential-redaction.test.ts +++ b/packages/spec/src/data/datasource-credential-redaction.test.ts @@ -814,3 +814,62 @@ describe('driver identity: every accepted spelling of a builtin driver is redact } }); }); + +// A URL with a special scheme — `http`, `https`, `ws`, `wss`, `ftp`, in any +// letter case — is read for a password the way a URL parser reads it, however +// many `/` or `\` follow the scheme (none included); the datasource read +// removes the password exactly where the write door judges it, at every +// URL-bearing driver key, turso `syncUrl` included. +describe('the datasource read rewrites a special-scheme URL\'s password where the write door judges it, however many slashes follow the scheme', () => { + const BS = String.fromCharCode(0x5c); + const KEYS: Array<[name: string, driver: string, key: string, make: (url: string) => Record]> = [ + ['postgres url', 'postgres', 'url', (url) => ({ url })], + ['mysql url', 'mysql', 'url', (url) => ({ url })], + ['mongodb url', 'mongodb', 'url', (url) => ({ url })], + ['turso url', 'turso', 'url', (url) => ({ url })], + ['turso syncUrl', 'turso', 'syncUrl', (syncUrl) => ({ url: 'file:./data/replica.db', syncUrl })], + ]; + const FORMS: Array<[string, (s: string) => string]> = [ + ['no slash', (s) => `${s}:`], + ['one slash', (s) => `${s}:/`], + ['a backslash', (s) => `${s}:${BS}`], + ['three slashes', (s) => `${s}:///`], + ['an uppercase scheme and no slash', (s) => `${s.toUpperCase()}:`], + ]; + const leadsOf = (schemes: readonly string[]) => + schemes.flatMap((scheme) => FORMS.map(([form, lead]) => [`\`${scheme}:\` with ${form}`, lead(scheme)] as [string, string])); + const SPECIAL = leadsOf(['http', 'https', 'ws', 'wss', 'ftp']); + const NO_AUTHORITY = leadsOf(['file', 'postgresql', 'libsql']).filter(([, lead]) => !lead.endsWith('///')); + + /** Whether the write door refuses `config` for a password in the userinfo at `key`. */ + const writeRefuses = (driver: string, key: string, config: Record): boolean => { + const result = getDriverConfigSchema(driver)!.safeParse(config); + return !result.success && result.error.issues.some( + (i) => i.path.join('.') === key && i.message.includes('embeds a password in its userinfo'), + ); + }; + + describe.each(KEYS)('%s', (_name, driver, key, make) => { + it.each(SPECIAL)('after %s: refused at the write door, served with the password removed, and the served form passes', (_label, lead) => { + const stored = make(`${lead}svc:hunter2@db.example.com/prod`); + expect(writeRefuses(driver, key, stored), 'the write door refuses the stored URL').toBe(true); + const { config, redactedKeys } = redactDatasourceConfig(driver, stored); + expect(config[key]).toBe(`${lead}svc@db.example.com/prod`); + expect(redactedKeys).toContain(key); + expect(writeRefuses(driver, key, config), 'the served URL passes the write door').toBe(false); + }); + + it.each([...SPECIAL, ...NO_AUTHORITY])('refused ⇔ rewritten after %s', (_label, lead) => { + for (const url of [`${lead}svc:hunter2@db.example.com/prod`, `${lead}svc@db.example.com/prod`, `${lead}db.example.com/prod`]) { + const config = make(url); + const rewritten = redactDatasourceConfig(driver, config).redactedKeys.includes(key); + expect(writeRefuses(driver, key, config), 'refused ⇔ rewritten').toBe(rewritten); + } + }); + + it.each(NO_AUTHORITY)('CONTROL: after %s there is no authority, so the URL is served as stored', (_label, lead) => { + const stored = make(`${lead}svc:hunter2@db.example.com/prod`); + expect(redactDatasourceConfig(driver, stored).config[key]).toBe(stored[key]); + }); + }); +}); diff --git a/packages/spec/src/data/datasource-credential-redaction.ts b/packages/spec/src/data/datasource-credential-redaction.ts index a4614a7eb84..8ba7f513390 100644 --- a/packages/spec/src/data/datasource-credential-redaction.ts +++ b/packages/spec/src/data/datasource-credential-redaction.ts @@ -400,7 +400,9 @@ export function redactableConfigKeys(driver: unknown): string[] { * write refuses: the string is judged as a URL parser reads it — trimmed of * its leading and trailing C0 control characters and spaces, with every ASCII * tab, LF and CR deleted — and rewritten at the matching offsets of the - * authored string; a scheme-relative `//` authority counts; the userinfo ends + * authored string; a scheme-relative `//` authority counts, and a special + * scheme's (`http`, `https`, `ws`, `wss`, `ftp`) authority starts after + * however many `/` or `\` follow it, none included; the userinfo ends * at the LAST `@` in the authority — * a lazier boundary stopping at the first `@` would split * `postgres://u:p@ss@host/db` after `p`, leave `ss@host` in place, and publish diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts index 87688843321..4d17ab8e09e 100644 --- a/packages/spec/src/data/driver/common.zod.ts +++ b/packages/spec/src/data/driver/common.zod.ts @@ -201,10 +201,12 @@ export const URL_EMBEDDED_CREDENTIAL_REFUSED = (key: string): string => * with every ASCII tab, LF and CR deleted — and the userinfo answered is that * cleaned text (why WHATWG parsing itself is refused is on that parse). * - * A string with no `//` (sqlite/turso `file:` paths, `:memory:`) has no - * authority and answers `undefined`. The FIRST `:` inside the returned - * userinfo splits username from password; that split belongs to the two - * accessors, not here. + * A special scheme (`http`, `https`, `ws`, `wss`, `ftp`, any letter case) + * starts the authority after however many `/` or `\` follow it, none + * included, as a URL parser does; any other string with no `//` + * (sqlite/turso `file:` paths, `:memory:`) has no authority and answers + * `undefined`. The FIRST `:` inside the returned userinfo splits username + * from password; that split belongs to the two accessors, not here. */ function urlUserinfo(value: string): string | undefined { return urlUserinfoSpan(value)?.userinfo; diff --git a/packages/spec/src/data/driver/driver-credential-refusal.test.ts b/packages/spec/src/data/driver/driver-credential-refusal.test.ts index 42975d4169a..abe92c0d3b5 100644 --- a/packages/spec/src/data/driver/driver-credential-refusal.test.ts +++ b/packages/spec/src/data/driver/driver-credential-refusal.test.ts @@ -1302,3 +1302,82 @@ describe('datasource — bound credentialsRef + composed mongo config naming no expect(result.error!.issues.some((i) => i.message.includes('not accepted in the driver-options passthrough'))).toBe(true); }); }); + +// A URL with a special scheme — `http`, `https`, `ws`, `wss`, `ftp`, in any +// letter case — is read for a password the way a URL parser reads it: its +// authority starts after the whole run of `/` and `\` that follows the scheme, +// however long, none included. Every other scheme keeps `scheme://` (or a +// scheme-relative `//`), and `file:` carries no userinfo. Judged at every +// URL-bearing driver key, turso `syncUrl` included. +const BS = String.fromCharCode(0x5c); +/** [form, the scheme and the run after it] — `s` is the scheme. */ +const SPECIAL_SCHEME_FORMS: Array<[string, (s: string) => string]> = [ + ['no slash', (s) => `${s}:`], + ['one slash', (s) => `${s}:/`], + ['a backslash', (s) => `${s}:${BS}`], + ['three slashes', (s) => `${s}:///`], + ['a run of slashes and backslashes', (s) => `${s}:/${BS}/`], + ['an uppercase scheme and no slash', (s) => `${s.toUpperCase()}:`], + ['an uppercase scheme and three slashes', (s) => `${s.toUpperCase()}:///`], +]; +const schemeLeadsOf = (schemes: readonly string[]) => + schemes.flatMap((scheme) => SPECIAL_SCHEME_FORMS.map(([form, lead]) => [`\`${scheme}:\` with ${form}`, lead(scheme)] as [string, string])); +const SPECIAL_SCHEME_LEADS = schemeLeadsOf(['http', 'https', 'ws', 'wss', 'ftp']); +const NO_AUTHORITY_LEADS = schemeLeadsOf(['file', 'postgresql', 'mysql', 'mongodb', 'libsql']).filter(([, lead]) => !lead.endsWith('///')); + +describe.each(URL_FAMILY)('$name — a URL with a special scheme is read for a password the way a URL parser reads it, however many slashes follow the scheme', (f) => { + /** The userinfo refusal at `f.key` — other refusals at the same key (a driver's own URL grammar) are not this rule's. */ + const userinfoRefusalAt = (config: Record) => { + const result = f.schema.safeParse(config); + if (result.success) return undefined; + return result.error.issues.find( + (i) => i.path.join('.') === f.key && i.message.includes('embeds a password in its userinfo'), + ); + }; + + it.each(SPECIAL_SCHEME_LEADS)('refuses `user:password@host` after %s', (_label, lead) => { + const issue = userinfoRefusalAt(f.make(`${lead}svc:hunter2@db.example.com/prod`)); + expect(issue, `refusal must be pathed at \`${f.key}\``).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain('external.credentialsRef'); + }); + + it.each(SPECIAL_SCHEME_LEADS)('CONTROL: a bare username after %s carries no password and is not refused for one', (_label, lead) => { + expect(userinfoRefusalAt(f.make(`${lead}svc@db.example.com/prod`))).toBeUndefined(); + expect(userinfoRefusalAt(f.make(`${lead}db.example.com/prod/a:b@c`))).toBeUndefined(); + }); + + it.each(NO_AUTHORITY_LEADS)('CONTROL: no authority without `//` after a scheme that is not special, or `file:` — %s', (_label, lead) => { + expect(userinfoRefusalAt(f.make(`${lead}svc:hunter2@db.example.com/prod`))).toBeUndefined(); + }); + + it('CONTROL: the `//` forms keep their reading — a password after `scheme://` or `//` is refused, an empty `scheme:///` authority holds none', () => { + expect(userinfoRefusalAt(f.make(f.sample('svc:hunter2@')))).toBeDefined(); + expect(userinfoRefusalAt(f.make('//svc:hunter2@db.example.com/prod'))).toBeDefined(); + for (const scheme of ['file', 'postgresql', 'libsql']) { + expect(userinfoRefusalAt(f.make(`${scheme}:///svc:hunter2@db.example.com/prod`)), scheme).toBeUndefined(); + } + }); +}); + +describe('the shared userinfo parse reads a special scheme\'s authority however many slashes follow the scheme', () => { + it.each(SPECIAL_SCHEME_LEADS)('after %s: the password and the username are read', (_label, lead) => { + expect(urlUserinfoPassword(`${lead}u:p@h/db`)).toBe('p'); + expect(urlUserinfoUsername(`${lead}u:p@h/db`)).toBe('u'); + expect(urlUserinfoUsername(`${lead}u@h/db`)).toBe('u'); + // CONTROL: no userinfo, and an `@` in the path, carry none. + expect(urlUserinfoUsername(`${lead}h/db`)).toBeUndefined(); + expect(urlUserinfoUsername(`${lead}h/a:b@c`)).toBeUndefined(); + }); + + it('after a special scheme the authority is read on the cleaned string, tab and newline characters deleted', () => { + const TAB = String.fromCharCode(0x09); + const LF = String.fromCharCode(0x0a); + expect(urlUserinfoPassword(`ht${TAB}tps:u:p@h/db`)).toBe('p'); + expect(urlUserinfoPassword(` https:/${LF}/${BS}u:p@h/db`)).toBe('p'); + }); + + it.each(NO_AUTHORITY_LEADS)('CONTROL: after %s there is no authority, so no userinfo', (_label, lead) => { + expect(urlUserinfoUsername(`${lead}u:p@h/db`)).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/data/driver/url-boundary.ts b/packages/spec/src/data/driver/url-boundary.ts index 58bf0e4bb8d..71496e946da 100644 --- a/packages/spec/src/data/driver/url-boundary.ts +++ b/packages/spec/src/data/driver/url-boundary.ts @@ -81,6 +81,34 @@ export function urlAsParsed(value: string): UrlAsParsed { /** `scheme://` or a scheme-relative `//`: where an authority starts. */ const AUTHORITY_START_RE = /^(?:[a-z][a-z0-9+.-]*:)?\/\//i; +/** A leading `scheme:`, the scheme captured. */ +const SCHEME_RE = /^([a-z][a-z0-9+.-]*):/i; + +/** + * The special schemes (WHATWG URL Standard) after which a URL parser starts + * the authority, and so reads a userinfo, past however many `/` or `\` + * follow the scheme — none included. `file`, the remaining special scheme, + * carries no userinfo. + */ +const AUTHORITY_SPECIAL_SCHEMES: ReadonlySet = new Set(['ftp', 'http', 'https', 'ws', 'wss']); + +/** + * The offset in `text` (a cleaned string, {@link urlAsParsed}) where its + * authority starts, or `undefined` when it has none: after a special scheme + * ({@link AUTHORITY_SPECIAL_SCHEMES}, in any letter case) and the whole run of + * `/` and `\` that follows it, however long, as a URL parser reads it; + * otherwise after `scheme://` or a scheme-relative `//`. + */ +function authorityStart(text: string): number | undefined { + const scheme = SCHEME_RE.exec(text); + if (scheme && AUTHORITY_SPECIAL_SCHEMES.has(scheme[1]!.toLowerCase())) { + let start = scheme[0].length; + while (start < text.length && (text[start] === '/' || text[start] === '\\')) start += 1; + return start; + } + return AUTHORITY_START_RE.exec(text)?.[0].length; +} + /** Where the userinfo of an authored string sits, and what a URL parser reads there. */ export interface UrlUserinfoSpan { /** Offset in the authored string where the userinfo starts. */ @@ -105,27 +133,36 @@ export interface UrlUserinfoSpan { * Deliberately NOT `new URL()`: real DSNs take forms WHATWG parsing rejects or * mangles (postgres/mongo multi-host `user:pass@h1:5432,h2:5432/db` throws * `ERR_INVALID_URL`, bare `:memory:`, `file:` paths), and a parse that throws - * on the exact inputs it must judge would fail open. The boundaries are - * RFC 3986's, read on the cleaned string ({@link urlAsParsed}): + * on the exact inputs it must judge would fail open. The boundaries are read + * on the cleaned string ({@link urlAsParsed}): * - * - the authority is what follows `//` (scheme-relative included), up to the - * first `/`, `?` or `#` — a `:` or `@` in a path or query is never userinfo; + * - for a special scheme — `http`, `https`, `ws`, `wss`, `ftp`, in any letter + * case — the authority starts where a URL parser starts it: after the whole + * run of `/` and `\` that follows the scheme, however long, none included + * (`https:host`, `https:/host`, `https:\host` and `https:///host` each have + * the authority `host`); + * - for any other scheme, and with no scheme, the authority is RFC 3986's: + * what follows `scheme://` or a scheme-relative `//`; + * - either way the authority runs up to the first `/`, `?` or `#` — a `:` or + * `@` in a path or query is never userinfo. A `\` does not end it: a URL + * parser ends a special scheme's authority there, an RFC 3986 parser (the + * libsql client's) does not, and the longer authority holds every userinfo + * either reads; * - userinfo ends at the LAST `@` in the authority (a malformed literal `@` * inside a password must not decide how much of it goes unjudged). * - * A string with no `//` (sqlite/turso `file:` paths, `:memory:`) has no - * authority and answers `undefined`. + * A string with no authority — a non-special scheme with no `//` (sqlite/turso + * `file:` paths, `libsql:host`), `:memory:` — answers `undefined`. */ export function urlUserinfoSpan(value: string): UrlUserinfoSpan | undefined { const { text, rawOffsets } = urlAsParsed(value); - const scheme = AUTHORITY_START_RE.exec(text); - if (!scheme) return undefined; - const rest = text.slice(scheme[0].length); + const userinfoStart = authorityStart(text); + if (userinfoStart === undefined) return undefined; + const rest = text.slice(userinfoStart); const authorityEnd = rest.search(/[/?#]/); const authority = authorityEnd === -1 ? rest : rest.slice(0, authorityEnd); const at = authority.lastIndexOf('@'); if (at === -1) return undefined; - const userinfoStart = scheme[0].length; const userinfo = authority.slice(0, at); const colon = userinfo.indexOf(':'); return { diff --git a/packages/spec/src/integration/connector-credential-redaction.ts b/packages/spec/src/integration/connector-credential-redaction.ts index 5c484a93f1e..942a8ac38fd 100644 --- a/packages/spec/src/integration/connector-credential-redaction.ts +++ b/packages/spec/src/integration/connector-credential-redaction.ts @@ -75,9 +75,11 @@ * and any URL a kind declares later. A string is judged as a URL parser * reads it — trimmed of its leading and trailing C0 control characters * and spaces, with every ASCII tab, LF and CR deleted: the userinfo of an - * authority that starts at `scheme://` or a scheme-relative `//`, - * rewritten at the matching position of the authored string; and a - * credential query parameter in any string. What a credential part is, + * authority that starts where a URL parser starts it — after a special + * scheme (`http`, `https`, `ws`, `wss`, `ftp`) and however many `/` or `\` + * follow it, none included; after `scheme://` or a scheme-relative `//` + * otherwise — rewritten at the matching position of the authored string; + * and a credential query parameter in any string. What a credential part is, * and the walk that finds every string holding one, live in ONE place, * `./connector-url-credentials.ts`, which the write door's refinement * runs too: an authored URL holding one is refused, so only a row stored diff --git a/packages/spec/src/integration/connector-entry-url-credentials.test.ts b/packages/spec/src/integration/connector-entry-url-credentials.test.ts index 1e23e91cce7..c24195fbd81 100644 --- a/packages/spec/src/integration/connector-entry-url-credentials.test.ts +++ b/packages/spec/src/integration/connector-entry-url-credentials.test.ts @@ -394,3 +394,92 @@ describe('a URL is judged as a URL parser cleans it: tab and newline characters }); }); }); + +// A URL with a special scheme — `http`, `https`, `ws`, `wss`, `ftp`, in any +// letter case — is read for credentials the way a URL parser reads it: its +// authority starts after the whole run of `/` and `\` that follows the scheme, +// however long, none included. Every other scheme keeps `scheme://` (or a +// scheme-relative `//`), and `file:` carries no userinfo. Both doors read the +// one grammar, so the write refusal and the read rewrite move together. +describe('a URL with a special scheme is read for credentials the way a URL parser reads it, however many slashes follow the scheme', () => { + const BS = String.fromCharCode(0x5c); + const HOST = 'api.vendor.example.com/v1'; + const withNote = (note: string) => ({ ...rest(`https://${HOST}`), providerConfig: { baseUrl: `https://${HOST}`, note } }); + /** [form, the scheme and the run after it] — `s` is the scheme. */ + const FORMS: Array<[string, (s: string) => string]> = [ + ['no slash', (s) => `${s}:`], + ['one slash', (s) => `${s}:/`], + ['a backslash', (s) => `${s}:${BS}`], + ['three slashes', (s) => `${s}:///`], + ['a run of slashes and backslashes', (s) => `${s}:/${BS}/`], + ['an uppercase scheme and no slash', (s) => `${s.toUpperCase()}:`], + ['an uppercase scheme and three slashes', (s) => `${s.toUpperCase()}:///`], + ]; + const leadsOf = (schemes: readonly string[]) => + schemes.flatMap((scheme) => FORMS.map(([form, lead]) => [`\`${scheme}:\` with ${form}`, lead(scheme)] as [string, string])); + const SPECIAL = leadsOf(['http', 'https', 'ws', 'wss', 'ftp']); + const NO_AUTHORITY = leadsOf(['file', 'postgres', 'libsql']).filter(([, lead]) => !lead.endsWith('///')); + + describe('REFUSED at the string, and the read rewrites it at the position the write judged', () => { + it.each(SPECIAL)('a userinfo after %s', (_label, lead) => { + for (const userinfo of [`u:${PLACEHOLDER}@`, `${PLACEHOLDER}@`]) { + const entry = rest(`${lead}${userinfo}${HOST}`); + const { located, messages } = refusals(entry); + expect(located).toEqual([['custom', 'providerConfig.baseUrl']]); + expect(messages[0]).toContain('auth: { type, credentialRef }'); + expect(messages[0]).not.toContain(PLACEHOLDER); + const { item: served, redactedKeys } = redactConnectorItem(entry); + expect(redactedKeys).toEqual(['providerConfig.baseUrl']); + expect((served.providerConfig as Record).baseUrl).toBe(`${lead}${HOST}`); + accepts(served); + } + }); + + it.each(SPECIAL)('a userinfo after %s, in a string that is not the base URL', (_label, lead) => { + const entry = withNote(`${lead}u:${PLACEHOLDER}@hooks.vendor.example.com`); + expect(refusals(entry).located).toEqual([['custom', 'providerConfig.note']]); + const { item: served, redactedKeys } = redactConnectorItem(entry); + expect(redactedKeys).toEqual(['providerConfig.note']); + expect((served.providerConfig as Record).note).toBe(`${lead}hooks.vendor.example.com`); + accepts(served); + }); + }); + + describe('CONTROL: what a URL parser reads no userinfo from is accepted and served as stored', () => { + it.each(SPECIAL)('a URL after %s with no userinfo, and with an `@` only in its path', (_label, lead) => { + for (const value of [`${lead}${HOST}`, `${lead}${HOST}/users/@me`]) { + accepts(rest(value)); + expect(redactConnectorItem(rest(value)).redactedKeys).toEqual([]); + } + }); + + it.each(NO_AUTHORITY)('no authority without `//` after a scheme that is not special, or `file:` — %s', (_label, lead) => { + const value = `${lead}u:${PLACEHOLDER}@${HOST}`; + accepts(rest(value)); + expect(redactConnectorItem(rest(value)).redactedKeys).toEqual([]); + }); + + it.each(['file', 'postgres', 'libsql'])('a `%s:///` authority is empty, so what follows the third slash is path', (scheme) => { + const value = `${scheme}:///u:${PLACEHOLDER}@${HOST}`; + accepts(rest(value)); + expect(redactConnectorItem(rest(value)).redactedKeys).toEqual([]); + }); + + it('the `//` forms keep their reading: a userinfo after `scheme://` or `//` is refused, none is accepted', () => { + for (const value of [`https://u:${PLACEHOLDER}@${HOST}`, `postgres://u:${PLACEHOLDER}@${HOST}`, `//u:${PLACEHOLDER}@${HOST}`]) { + expect(refusals(rest(value)).located).toEqual([['custom', 'providerConfig.baseUrl']]); + } + for (const value of [`https://${HOST}`, `postgres://${HOST}`, `//${HOST}`]) accepts(rest(value)); + }); + }); + + it.each([...SPECIAL, ...NO_AUTHORITY])('refused ⇔ rewritten after %s', (_label, lead) => { + for (const value of [`${lead}u:${PLACEHOLDER}@${HOST}`, `${lead}${PLACEHOLDER}@${HOST}`, `${lead}${HOST}`]) { + for (const entry of [rest(value), withNote(value)]) { + const refused = !DeclarativeConnectorEntrySchema.safeParse(entry).success; + const rewritten = redactConnectorItem(entry as Record).redactedKeys.length > 0; + expect(refused, 'refused ⇔ rewritten').toBe(rewritten); + } + } + }); +}); diff --git a/packages/spec/src/integration/connector-url-credentials.ts b/packages/spec/src/integration/connector-url-credentials.ts index 801f6ba4899..0b1462f2d5c 100644 --- a/packages/spec/src/integration/connector-url-credentials.ts +++ b/packages/spec/src/integration/connector-url-credentials.ts @@ -29,8 +29,10 @@ * (`data/driver/url-boundary.ts`), the one userinfo grammar the datasource * doors read too: on the string as a URL parser reads it — trimmed of its * leading and trailing C0 control characters and spaces, with every ASCII - * tab, LF and CR deleted — whose authority starts at `scheme://` or a - * scheme-relative `//`; + * tab, LF and CR deleted — whose authority starts where a URL parser + * starts it: after a special scheme (`http`, `https`, `ws`, `wss`, `ftp`, + * any letter case) and however many `/` or `\` follow it, none included; + * after `scheme://` or a scheme-relative `//` otherwise; * - **each credential query parameter** with a non-empty value, in ANY * judged string, whether or not it starts with a scheme: the names in * {@link CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES}, plus the diff --git a/packages/spec/src/migrations/entries/semantic/18.url-credential-special-scheme-read-however-many-slashes.ts b/packages/spec/src/migrations/entries/semantic/18.url-credential-special-scheme-read-however-many-slashes.ts new file mode 100644 index 00000000000..8ac6c50b31b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.url-credential-special-scheme-read-however-many-slashes.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'url-credential-special-scheme-read-however-many-slashes', + surface: 'the URL credential rule at the two metadata doors that judge one, for a URL with a special scheme ' + + '(`http`, `https`, `ws`, `wss`, `ftp`, any letter case): connector entries AUTHORED through defineStack ' + + '`connectors:`, `os validate` / `os compile`, the artifact load and `PUT /meta/connector/:name` — every string ' + + 'under `providerConfig` and `auth` — and a datasource driver config\'s URL keys (`config.url` on postgres, ' + + 'mysql, mongo and turso, and turso `config.syncUrl`) at every door that parses the datasource', + replacement: 'spell a special-scheme URL with `scheme://` and keep it credential-free as a URL parser reads it: ' + + 'for a connector, name the secret with `auth: { type, credentialRef }` and set its value under that name; for a ' + + 'datasource, bind the secret through the Setup → Datasources connection form\'s secret field (or reference it ' + + 'with `external.credentialsRef`) and keep a bare username in the URL if the driver needs one. Delete the ' + + 'credential from the string.', + reason: + 'A URL with a special scheme is read for credentials the way a URL parser reads it, however many slashes follow ' + + 'the scheme. For `http`, `https`, `ws`, `wss` and `ftp`, in any letter case, a URL parser starts the authority ' + + 'after the whole run of `/` and `\\` that follows the scheme — none, one, a backslash, three or more — so ' + + '`https:user:pass@host`, `https:/user:pass@host`, `https:\\user:pass@host` and `https:///user:pass@host` each ' + + 'carry the userinfo `user:pass`. The one userinfo grammar both doors read now starts a special scheme\'s ' + + 'authority there too: a connector string holding such a userinfo, and a datasource URL holding such a userinfo ' + + 'password, are refused, and both read redactors rewrite exactly that position and serve every other character ' + + 'as stored. Every other scheme keeps `scheme://` or a scheme-relative `//`, and `file:` carries no userinfo. ' + + 'There is deliberately no mechanical rewrite: the value in the string may be the only copy of the secret. A row ' + + 'stored before this refusal keeps working: a save of the served form keeps the stored value, and an edit of the ' + + 'served value stores the edit.', + acceptanceCriteria: + 'Every authored connector entry parses through `DeclarativeConnectorEntrySchema` with no issue located at a ' + + 'string under `providerConfig` or `auth`, and every authored datasource parses through `DatasourceSchema` with ' + + 'no issue at `config.url` or `config.syncUrl`; each secret that sat after a special scheme is set under its ' + + 'reference instead, and the connector still materializes (`GET /api/v1/automation/connectors` reports it ' + + '`ready`).', +};