Skip to content

The audit binder launders a caller-supplied created_by on an ordinary create, the same way #15964 does created_at #16311

Description

@claude

Measured while implementing the ruled fix for #15964 (option A, 2026-09-06). The triage comment on that card asked this question explicitly and recorded it as untested:

⚠️ 无论哪一支,都请一并回答一个本席在源码上看到、卡片没展开的问题:1117updated_by 也带 preserveAudit 分支,而 created_by(若存在)在本席读到的这段里没有出现。⇒ created_by 是否也有同样的洞?

It has. Measured, not inferred.

The reading

Rig: a real ObjectKernel + ObjectQLPlugin (so the shipped sys_stamp_audit_insert hook is bound through bindHooksToEngine) over a capturing driver, so what reaches driver.create is the stored row. Object declares id (readonly) and title; created_at / created_by / updated_by are the registry-injected AUDIT_FIELD_DEFS shapes, all readonly: true.

row0: title=a created_by=forged_user updated_by=real_user   session { userId: 'real_user' }
row1: title=b created_by=undefined   updated_by=undefined   session {} (no userId)
row2: title=c created_by=forged_user updated_by=real_user   session { userId: 'real_user' }
  • row0 / row2: an ordinary authenticated caller sent created_by: 'forged_user' and it landed, while the session's real user was real_user. updated_by in the same payload was overwritten with real_user — the in-experiment control that proves the strip ran on this row and took the sibling audit field.
  • row1 is the second control: with no session.userId the hook's if (session?.userId) guard is false, nothing is assigned, and the engine strip then takes the forged value correctly (undefined). So the hole is created by the hook's assignment, exactly as on created_at.

Mechanism — identical to #15964, one field over

packages/objectql/src/plugin.ts, the audit binder's beforeInsert:

if (isInsert && hasField(objectName, 'created_by')) {
  record.created_by = record.created_by ?? session.userId;
}
if (hasField(objectName, 'updated_by')) {
  record.updated_by = preserveAudit ? (record.updated_by ?? session.userId) : session.userId;
}

created_by is ?? (client-preferred, ungated); updated_by is the preserveAudit ternary. Since #15395 the static-readonly strip runs inside engine.insert AFTER the hooks, and #14259's guard treats a key a beforeInsert hook ASSIGNED as the hook's write rather than a caller forgery (rowHookWrittenKeys) — so the ?? launders the caller's value past it. That is the same two-part mechanism #15964 documented for created_at.

Why it is filed rather than fixed in that PR

The 2026-09-06 ruling (decision batch #54) adopted option A for created_at and named only that field. The triage seat was explicit that it does not claim created_by is broken, only that it had not been measured. It now has been. Fixing it is the same one-line shape change (record.created_by = preserveAudit ? (record.created_by ?? session.userId) : session.userId;) but it is a decision about a different field's write contract, so it is a card, not a rider on a ruled PR.

⚠️ Note that created_by is NOT symmetric with created_at in one respect worth deciding on: the created_at stamp is unconditional (driver-sql provisions the column on every table), while created_by is guarded by hasField and by session.userId. A shape decision has to say what an insert with no session should do — today it leaves the caller's forgery for the strip to take, which is the correct outcome and happens for a different reason.

Severity

Same class as #15964 and reached the same way — a plain authenticated POST with no flag and no privilege — but about attribution rather than timing: a row can claim it was created by a user who did not create it. Audit integrity, not privilege escalation. Grading is triage's; this card states the measurement.

Filed from the #15964 round. Fix lands in packages/objectql/src/plugin.ts, the same function.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions