You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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)
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.
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 "".
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.
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, nopm:queue, for triage to grade.The defect
ObjectQL.encryptSecretFields(packages/objectql/src/engine.ts) treats the empty string as an ordinary value:""is neither, so it falls through tocryptoProvider.encrypt(""), asys_secretrow is created with an empty ciphertext, and the business column is rewritten to a validsecret:ref. From that moment on:maskSecretFieldsmasks the column on every read, because the stored value is non-null — soGET /api/v1/data/:objectreports the field as••••••••, i.e. "a secret is set";resolveSecretFielddereferences it perfectly and returns"";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
ObjectQLengine (in-memory driver double, reversible test crypto, the realsys_webhookschema whosesigning_secretisField.secret()), onorigin/mainat116c0d95b: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_secretwas 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:
nullalready 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)
""as a clear, i.e. fold it into thenullbranch. Matches what a user emptying a field almost certainly means, costs no cipher row, and leaves the columnnullso 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.VALIDATION_ERRORnamingnullas 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"".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
secretchannel; nothing recent changed it.