From 8f7bc59b38f1afa658aecb59314cf45af11d85e3 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 13:38:27 +0100 Subject: [PATCH 1/5] Keep a secret setting's raw value out of the boot error that rejects it --- CHANGELOG.md | 6 ++++++ docs/reading-cost.md | 17 +++++++++++++++++ src/plugin-host/settings.test.ts | 20 ++++++++++++++++++++ src/plugin-host/settings.ts | 12 +++++++----- todo.md | 8 -------- 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d4b58..25b068d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ at boot. Entries start at 0.3.0. ## Unreleased +### A plugin setting marked `secret` keeps its value out of the boot error that rejects it + +A `secret` setting of type `boolean`, `enum`, `number` or `url` that fails validation names its +variable and what it accepts, and no longer prints the value, so a DSN with a password doesn't reach +the log. A setting that isn't secret still prints the value it got. + ### The admin's recovery-code notice links to the recovery flow the code belongs to After an admin generates a recovery code for a user, the notice asks them to send the user both the diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 46aa50b..c3d477d 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -274,3 +274,20 @@ Read most (opens and searches): - `src/i18n/locales/en-US.ts` 3 - `e2e-tests/compose.full.yml` 2 - `src/auth/kratos-admin.ts` 2 + +## Reading cost, chunk d8b41620d631, finished 2026-10-31T12:37:38Z + +70583 new input tokens before the first commit. + +Read most (opens and searches): + +- `todo.md` 5 +- `AGENTS.md` 2 +- `README.md` 2 +- `examples/plugins/admin/README.md` 2 +- `src/auth/kratos-admin.ts` 2 +- `src/http/app-admin.test.ts` 2 +- `CHANGELOG.md` 1 +- `e2e-tests/full-flow.spec.ts` 1 +- `examples/plugins/admin/admin-users.test.ts` 1 +- `examples/plugins/admin/admin-users.ts` 1 diff --git a/src/plugin-host/settings.test.ts b/src/plugin-host/settings.test.ts index ee77e65..d87914b 100644 --- a/src/plugin-host/settings.test.ts +++ b/src/plugin-host/settings.test.ts @@ -115,6 +115,26 @@ test("each type coerces from the environment, and a bad value fails loud", () => assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_BASE.*URL/); }); +test("a secret's bad value never reaches the boot error, whatever its type", () => { + const cases: [SettingDecl, string, RegExp][] = [ + [{ key: "s", secret: true, type: "boolean" }, "hunter2-bool", /PLUGIN_SETTING_P_S.*"true".*"false"/], + [{ key: "s", secret: true, type: "enum", values: ["a", "b"] }, "hunter2-enum", /PLUGIN_SETTING_P_S.*a, b/], + [{ key: "s", secret: true, type: "number" }, "hunter2-number", /PLUGIN_SETTING_P_S.*number/], + [{ key: "s", secret: true, type: "url" }, "postgres user:hunter2-url@db", /PLUGIN_SETTING_P_S.*URL/], + ]; + for (const [decl, raw, names] of cases) { + const errors = resolveSettings([pluginWith("p", [decl])], { PLUGIN_SETTING_P_S: raw }).errors; + assert.equal(errors.length, 1, decl.type); + assert.match(errors[0] ?? "", names, decl.type); + assert.ok(!(errors[0] ?? "").includes("hunter2"), decl.type); + } +}); + +test("a non-secret's bad value is quoted in the boot error, so the operator sees the typo", () => { + const errors = resolveSettings([pluginWith("p", [{ key: "s", type: "url" }])], { PLUGIN_SETTING_P_S: "not a url" }).errors; + assert.match(errors[0] ?? "", /got "not a url"/); +}); + test("a boolean is only \"true\"/\"false\" — a typo never degrades to false", () => { const plugins = [pluginWith("p", [{ default: true, key: "flag", type: "boolean" }])]; assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "false" }).values.get("p")?.["flag"], false); diff --git a/src/plugin-host/settings.ts b/src/plugin-host/settings.ts index 282ac73..3104b1d 100644 --- a/src/plugin-host/settings.ts +++ b/src/plugin-host/settings.ts @@ -231,26 +231,28 @@ function summarize(decl: SettingDecl, name: string, source: SettingSummary["sour }; } -// A coerced value, or the boot error naming the variable and what it accepts. +// A coerced value, or the boot error naming the variable and what it accepts. A secret's value +// stays out of it: the error reaches the log. function coerce(decl: SettingDecl, raw: string, name: string): { value: SettingValue } | string { + const got = decl.secret === true ? "" : `, got "${raw}"`; switch (decl.type) { case "boolean": if (raw === "true") return { value: true }; if (raw === "false") return { value: false }; - return `${name} must be "true" or "false", got "${raw}"`; + return `${name} must be "true" or "false"${got}`; case "enum": return (decl.values ?? []).includes(raw) ? { value: raw } - : `${name} must be one of ${(decl.values ?? []).join(", ")}, got "${raw}"`; + : `${name} must be one of ${(decl.values ?? []).join(", ")}${got}`; case "number": { const value = Number(raw); - return Number.isFinite(value) ? { value } : `${name} must be a number, got "${raw}"`; + return Number.isFinite(value) ? { value } : `${name} must be a number${got}`; } case "url": try { new URL(raw); } catch { - return `${name} is not a valid URL: ${raw}`; + return `${name} must be a valid URL${got}`; } return { value: raw }; case "string": diff --git a/todo.md b/todo.md index eabe39d..2867f6f 100644 --- a/todo.md +++ b/todo.md @@ -12,7 +12,6 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 46 | 0.4.21 | | security | **Keep a secret setting's raw value out of the boot error that rejects it.** | 1 | 1 | 4 | 5 | 2 | | 0.0 | | 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 | | 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 | | 48 | 0.5.0 | | | **Correct the Keto tuple in the `login.ts` and `bootstrap.ts` comments to the `granted` relation the code writes.** | 1 | 1 | 1 | 1 | 1 | code health by 4 | 28.7 | @@ -34,13 +33,6 @@ ## Details -### 46. Keep a secret setting's raw value out of the boot error that rejects it. - -`coerce` in `src/plugin-host/settings.ts` puts `got "${raw}"` (boolean, enum, number) and `: ${raw}` -(url) into the error it returns, whatever `secret` says. A secret of type `url`, such as a DSN with -a password, that fails `new URL` reaches the boot error and so the log. Name the variable and what it -accepts, never the value; test one secret of each type. - ### 19. Run the documented Docker commands on macOS and fix whatever misbehaves. Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is -- 2.52.0 From 5be561c4ba2c32a9d5496384bfa37798da31f879 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 13:43:04 +0100 Subject: [PATCH 2/5] =?UTF-8?q?Answer=20stability=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=203=20=F0=9F=9F=A1=20(0=20misread);=20the=20tests?= =?UTF-8?q?=20quote=20each=20non-secret=20value=20and=20run=20one=20case?= =?UTF-8?q?=20per=20type,=20the=20config.ts=20URL=20leak=20is=20filed=20as?= =?UTF-8?q?=20item=2055?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin-host/settings.test.ts | 39 ++++++++++++++------------------ todo.md | 17 +++++++++++++- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/src/plugin-host/settings.test.ts b/src/plugin-host/settings.test.ts index d87914b..3e7cb11 100644 --- a/src/plugin-host/settings.test.ts +++ b/src/plugin-host/settings.test.ts @@ -109,31 +109,26 @@ test("each type coerces from the environment, and a bad value fails loud", () => PLUGIN_SETTING_P_MODE: "loose", }); assert.equal(bad.errors.length, 4); - assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_COUNT.*number/); - assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_FLAG.*"true".*"false"/); - assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_MODE.*strict, lenient/); - assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_BASE.*URL/); + assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_COUNT.*number, got "twelve"/); + assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_FLAG.*"true".*"false", got "yes"/); + assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_MODE.*strict, lenient, got "loose"/); + assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_BASE.*URL, got "not a url"/); }); -test("a secret's bad value never reaches the boot error, whatever its type", () => { - const cases: [SettingDecl, string, RegExp][] = [ - [{ key: "s", secret: true, type: "boolean" }, "hunter2-bool", /PLUGIN_SETTING_P_S.*"true".*"false"/], - [{ key: "s", secret: true, type: "enum", values: ["a", "b"] }, "hunter2-enum", /PLUGIN_SETTING_P_S.*a, b/], - [{ key: "s", secret: true, type: "number" }, "hunter2-number", /PLUGIN_SETTING_P_S.*number/], - [{ key: "s", secret: true, type: "url" }, "postgres user:hunter2-url@db", /PLUGIN_SETTING_P_S.*URL/], - ]; - for (const [decl, raw, names] of cases) { +const SECRET_CASES: [SettingDecl, string, RegExp][] = [ + [{ key: "s", secret: true, type: "boolean" }, "hunter2-bool", /PLUGIN_SETTING_P_S.*"true".*"false"/], + [{ key: "s", secret: true, type: "enum", values: ["a", "b"] }, "hunter2-enum", /PLUGIN_SETTING_P_S.*a, b/], + [{ key: "s", secret: true, type: "number" }, "hunter2-number", /PLUGIN_SETTING_P_S.*number/], + [{ key: "s", secret: true, type: "url" }, "postgres user:hunter2-url@db", /PLUGIN_SETTING_P_S.*URL/], +]; +for (const [decl, raw, names] of SECRET_CASES) { + test(`a secret ${decl.type}'s bad value never reaches the boot error`, () => { const errors = resolveSettings([pluginWith("p", [decl])], { PLUGIN_SETTING_P_S: raw }).errors; - assert.equal(errors.length, 1, decl.type); - assert.match(errors[0] ?? "", names, decl.type); - assert.ok(!(errors[0] ?? "").includes("hunter2"), decl.type); - } -}); - -test("a non-secret's bad value is quoted in the boot error, so the operator sees the typo", () => { - const errors = resolveSettings([pluginWith("p", [{ key: "s", type: "url" }])], { PLUGIN_SETTING_P_S: "not a url" }).errors; - assert.match(errors[0] ?? "", /got "not a url"/); -}); + assert.equal(errors.length, 1); + assert.match(errors[0] ?? "", names); + assert.ok(!(errors[0] ?? "").includes("hunter2")); + }); +} test("a boolean is only \"true\"/\"false\" — a typo never degrades to false", () => { const plugins = [pluginWith("p", [{ default: true, key: "flag", type: "boolean" }])]; diff --git a/todo.md b/todo.md index 2867f6f..c52b36d 100644 --- a/todo.md +++ b/todo.md @@ -6,12 +6,13 @@ `Bar = 1` -`Next ID = 55` +`Next ID = 56` ## Items | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| +| 55 | 0.4.21 | | security | **Keep a URL config value out of the boot error that rejects it.** | 1 | 1 | 4 | 5 | 2 | | 0.0 | | 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 | | 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 | | 48 | 0.5.0 | | | **Correct the Keto tuple in the `login.ts` and `bootstrap.ts` comments to the `granted` relation the code writes.** | 1 | 1 | 1 | 1 | 1 | code health by 4 | 28.7 | @@ -33,6 +34,20 @@ ## Details +### 55. Keep a URL config value out of the boot error that rejects it. + +`readOptionalUrl` and `readUrl` in `src/config.ts` throw `config: ${key} is not a valid URL: ${value}`, +so a malformed value reaches the boot error and the log. A DSN whose password breaks parsing, such as +`postgres://admin:pa/ss@db:5432/plugins`, leaks that password. The keys that can carry credentials: +`PLUGIN_DB_URL` (`readCredentiallessUrl` refuses userinfo only after `readOptionalUrl` has parsed it, +so a pasted admin DSN that fails to parse is echoed), and `OTLP_ENDPOINT`, whose collector URL may +embed basic-auth userinfo. The other URL keys, `APP_URL`, `HYDRA_ADMIN_URL`, `JWKS_URL`, +`KETO_READ_URL`, `KETO_WRITE_URL`, `KRATOS_ADMIN_URL` and `KRATOS_PUBLIC_URL`, pass through the same +two readers, so the fix covers them too. Name the variable and what it accepts, never the value, and +add a test per credential-bearing key. The other readers in `config.ts` echo only non-credential +values. `PLUGIN_DB_ADMIN_URL` isn't read in `config.ts`: `bootstrap.ts` hands it straight to +`postgres()`. Check whether the driver echoes a malformed one. + ### 19. Run the documented Docker commands on macOS and fix whatever misbehaves. Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is -- 2.52.0 From 396ce93a2f8b5e70b3bc679a3fd21a008bf51917 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 13:43:04 +0100 Subject: [PATCH 3/5] =?UTF-8?q?Answer=20prose=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20the=20redun?= =?UTF-8?q?dant=20comment=20goes,=20the=20changelog=20entry=20shrinks=20to?= =?UTF-8?q?=20one=20sentence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++---- src/plugin-host/settings.ts | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b068d..cc2a9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,10 @@ at boot. Entries start at 0.3.0. ## Unreleased -### A plugin setting marked `secret` keeps its value out of the boot error that rejects it +### A secret plugin setting's bad value stays out of the boot error -A `secret` setting of type `boolean`, `enum`, `number` or `url` that fails validation names its -variable and what it accepts, and no longer prints the value, so a DSN with a password doesn't reach -the log. A setting that isn't secret still prints the value it got. +When a `secret` setting's value fails validation, the boot error no longer prints it, so a malformed +DSN doesn't put its password in the log. ### The admin's recovery-code notice links to the recovery flow the code belongs to diff --git a/src/plugin-host/settings.ts b/src/plugin-host/settings.ts index 3104b1d..9f49f3d 100644 --- a/src/plugin-host/settings.ts +++ b/src/plugin-host/settings.ts @@ -231,8 +231,7 @@ function summarize(decl: SettingDecl, name: string, source: SettingSummary["sour }; } -// A coerced value, or the boot error naming the variable and what it accepts. A secret's value -// stays out of it: the error reaches the log. +// A coerced value, or the boot error naming the variable and what it accepts. function coerce(decl: SettingDecl, raw: string, name: string): { value: SettingValue } | string { const got = decl.secret === true ? "" : `, got "${raw}"`; switch (decl.type) { -- 2.52.0 From 306f322b622a681129e760351b77a615d59b7db1 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 13:43:04 +0100 Subject: [PATCH 4/5] =?UTF-8?q?Answer=20product-owner=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20the=20READM?= =?UTF-8?q?E=20says=20a=20mistyped=20value=20names=20the=20plugin=20and=20?= =?UTF-8?q?the=20variable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cfdb1ed..f70825e 100644 --- a/README.md +++ b/README.md @@ -868,7 +868,7 @@ export default definePlugin({ ``` `type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is -coerced and checked at boot, so a mistyped value names the plugin, the key and the variable instead +coerced and checked at boot, so a mistyped value names the plugin and the variable instead of surfacing later as a broken page. **`required` and `default` are mutually exclusive** — a default means the setting can never fail, so -- 2.52.0 From 03bcf216a7c01288af22e6e7588edf263e69027e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 13:43:04 +0100 Subject: [PATCH 5/5] =?UTF-8?q?Answer=20security=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20/security-r?= =?UTF-8?q?eview=20found=20nothing,=20the=20full=20scan's=20cost=20prompt?= =?UTF-8?q?=20had=20no=20user=20to=20answer=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.52.0