Skip to content

objectql accepts "" into a secret field: it encrypts the empty string, mints a sys_secret row for it, and every read path then reports "a secret is set" #8559

Description

@os-zhuang

Found while implementing #8542 (measured, not traced). Out of scope there and deliberately not fixed on that branch#8542's file surface is packages/plugins/plugin-webhooks, and this is one layer down in the engine. Unassigned, no pm:queue, for triage to grade.

The defect

ObjectQL.encryptSecretFields (packages/objectql/src/engine.ts) treats the empty string as an ordinary value:

if(value===null||typeofvalue==='undefined')continue;// clearif(isSecretRef(value))continue;// already a ref

"" is neither, so it falls through to cryptoProvider.encrypt(""), a sys_secret row is created with an empty ciphertext, and the business column is rewritten to a validsecret: ref. From that moment on:

  • maskSecretFields masks the column on every read, because the stored value is non-null — so GET /api/v1/data/:object reports the field as ••••••••, i.e. "a secret is set";
  • resolveSecretField dereferences it perfectly and returns "";
  • there is a real orphan-able cipher row backing a credential with no content.

The result is a row whose read surface and whose actual content disagree, with nothing anywhere recording that they do.

Evidence — measured, on a real engine

Reproduced against a real ObjectQL engine (in-memory driver double, reversible test crypto, the real sys_webhook schema whose signing_secret is Field.secret()), on origin/main at 116c0d95b:

update(sys_webhook, { signing_secret: '' }) threw = undefined # accepted
row at rest = "secret:sec_2" # a valid ref
sys_secret row = { key: "signing_secret", ciphertext: "" }
read back through engine.find() = "••••••••" # reports "set"
resolveSecretField(...) = "" # resolves to nothing

For contrast, the two adjacent write shapes are both defended and behaved exactly as documented in the same run: an echoed read-mask is dropped (the stored ref survives untouched) and cleartext is re-encrypted into a fresh ref.

Why it is worth fixing rather than tolerating

It manufactures the contradictory state that consumers then have to guess about. It is precisely how the third trigger of #8542 is reached from the ordinary data API with no privileged access — a webhook whose signing_secret was set to "" reported as signed on every surface an operator can see while its dereference returned nothing. #8542 fixes the consumer's half (the webhook seam now refuses rather than delivering unsigned), and that fix stands on its own; this card is about not creating the state in the first place.

Note the asymmetry that makes it a decision rather than an oversight: null already has a defined meaning on this path (clear the secret), and "" is the spelling a form control most naturally submits for "I emptied this field".

Options (⛔ not decided here)

  1. Treat "" as a clear, i.e. fold it into the null branch. Matches what a user emptying a field almost certainly means, costs no cipher row, and leaves the column null so every read reports "not set" — which is then true. Risk: it silently reinterprets a write rather than refusing it, and a caller that genuinely meant to store an empty credential gets something else.
  2. Refuse it, with a located ADR-0112 VALIDATION_ERROR naming null as the way to clear. Loudest and most contract-first; makes the authoring mistake visible at the door instead of at the consumer. Costs a rejection where today there is silent acceptance, so it is a behaviour change for any caller currently sending "".
  3. Leave it and fix consumers — what [security] a stored webhook signing secret that resolves to null is treated as "authored unsigned" — the subscription arms and delivers UNSIGNED #8542 had to do anyway. ⛔ Weakest: it leaves every future secret-typed consumer to rediscover the same trap, which is exactly the "one rule, N places" shape [security] a stored webhook signing secret that resolves to null is treated as "authored unsigned" — the subscription arms and delivers UNSIGNED #8542's seam fix rejected.

A decision here should also say what password-typed fields do with "", since they share the read mask and the same echoed-mask drop but not the encryption path.

Explicitly NOT claimed

  • No live leak. Nothing is exposed by this; the failure is a credential that is advertised as present and is empty.
  • Not a regression. The behaviour is as old as the secret channel; nothing recent changed it.
  • Whether any deployment has such a row is unmeasured.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions