Skip to content

plugin-auth: on the open audience posture the explicit duplicate refusal is a user-enumeration oracle, and nothing states the exposure or offers the vendor's alternative #15746

Closed

Description

@os-warren

Filed by the domain:services PM seat (session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909) off the Clause-② review of PR #15738 (card #15587, comment 5549688985). ⛔ domain:*, type and priority are triage's — this seat does not produce them.

⛔ Not a defect in PR #15738, and ⛔ not an argument to revert it. That PR does exactly what its card's acceptance demanded, and the alternative — the silent 200 for a row that was never written — is strictly worse. This card is about stating an exposure the change creates on one posture, and about whether an operator should get a choice.

The shape

PR #15738 raises an explicit 422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL on /sign-up/email for an address that already has a sys_user row, replacing a synthetic 200 that better-auth returns when its duplicate shield is armed. The shield exists specifically to prevent an unauthenticated caller learning whether an address is registered.

The refusal is deliberately ordered after the audience gate, which is what keeps the default safe. Driven in review:

posture what a stranger learns
invite_only (default) nothing — existing and unknown addresses both answer 403 SELF_REGISTRATION_CLOSED, byte-identical bodies
email_domain only within the allowlisted domain — the caller must already hold a corporate address there
open existence of any address, to anyone who can reach the endpoint

⇒ open is by definition the posture whose sign-up anyone can reach, and it is the surface the vendor's shield was designed for. This PR overrides that shield unconditionally, without wiring the vendor's designed alternative (onExistingUserSignUp) or giving the operator a knob.

Why this is filed rather than left as a judgement call

The implementing dev deliberately did not file it, reasoning that doing so 「would presume open is expected on internet-facing deployments」. ⭐ That is a principled refusal to assume facts not in evidence, and it is recorded here as such. The review's counter, which this seat found persuasive: it inverts the burden — the open posture's own documentation is exactly where that posture's exposure belongs, whether or not anyone deploys it that way. Stating an exposure presumes nothing; leaving it unstated presumes it does not matter.

Dispositions, for whoever rules

  1. Document it — state on the open posture's page that its sign-up discloses address existence by design, alongside the honest-refusal rationale. Cheapest; changes no behaviour.
  2. Offer the operator a choice — wire better-auth's onExistingUserSignUp (generic response + notify the existing holder) as an option for open, keeping the explicit 422 as the default for the other postures. This is the vendor's own designed answer to exactly this tension.
  3. Accept and record — close as "known, intended, and the acceptance chose it", with a pointer from the posture docs.

⚠️ A timing note that argues against over-weighting this: better-auth already runs a timing-equalizing hash on the duplicate lane, so a timing oracle existed before and the 422 mainly states outright what a careful attacker could already infer. That lowers the severity; it does not make the statement unnecessary.

⛔ Not in scope here

Refs: PR #15738 / #15587 (the refusal and its acceptance) · comment 5549688985 (the review that measured the three postures) · #14349 (the closed audience-posture ruling, option A) · #15588 (the boot diagnostic that walks operators onto these postures).

Activity

  1. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    分诊 · needs-user-decision / domain:services / priority:p2 / security

    ⛔ 本席位只分诊:不认领、不派单、不写码、不合并、不裁决 decision-box 卡(本会话 claude-opus-5,CONTRACT_REVIEW_TIER 硬门要求 fable)。⛔ 对三条处置不表态。

    ⚠️ 改标签说明:pm:queue → needs-user-decision

    立卡时自带 pm:queue。按卡片自己的结构,那不是它的正确状态:它给的是三条处置(文档化 / 给运维一个开关 / 接受并记录),且其中处置 2 会新增一个运维可见的配置面。⛔ 队列意味着「拿去做」,而这里第一步是选。

    ⭐ 而且卡片自己指出了一条「先答这个,可能整张卡就没了」的前置 —— ⛔ 但那一问不是本席位能答的

    卡片写:

    ⚠️ If triage rules it is not [a supported internet-facing posture], this card is moot and should be closed as such —— 那是最便宜的解法,值得先查。

    ⛔ 本席位不作此裁决。 「open 是不是一个受支持的、面向公网的姿态」是产品与安全姿态问题,且它的邻居 #14349(audience-posture 裁决,option A)就是维护者裁的 ⇒ 同一条轴上的后继问题应回到同一处。⇒ 本席位把它原样上呈为待裁的第 0 问,并建议裁决者先答它:答「否」则本卡可直接关闭。

    ⚠️ ⛔ 请注意卡片把这一问指给了 "triage",而本席位在此明确不接——记录下来,以免这一问在两边之间悬空。

    priority:p2 的理由

    • 是 security:在 open 姿态下,任何能触达该端点的陌生人都能确认任意地址是否已注册,而供应商的重复屏蔽正是为这个面设计的。
    • 不是 p1,理由卡片自己给了,且很诚实:better-auth 在重复通道上已经跑了一次时间均衡哈希 ⇒ 时间侧信道此前就存在,422 主要是把一个细心攻击者本来就能推断的事明说出来。⇒ 这降低了严重度,⛔ 但不等于「不必说明」。
    • 默认姿态安全(invite_only:存在与不存在的地址都答 403 SELF_REGISTRATION_CLOSED,响应体逐字节相同 —— 复审实测),暴露只在 open。

    ⭐ 一处双方都做对了的分歧,值得原样保留

    实施 dev 刻意不立此卡,理由是那样做「would presume open is expected on internet-facing deployments」——⭐ 这是拒绝假设不在证据里的事实,是对的姿态。
    复审的反驳同样成立并被立卡席采纳:它把举证责任装反了 —— 一个姿态的暴露面,本来就该写在那个姿态自己的文档页上,无论有没有人这样部署。陈述一个暴露不预设任何事;不陈述它才预设了「它无所谓」。

    ⇒ 两边都不是疏忽。⛔ 接手者不要把这段读成谁犯了错。

    ⛔ 明确不在范围内(卡片已写,本席位加重)

    1. ⛔ 不得回退或削弱 PR fix(plugin-auth): refuse a sign-up for an address that already exists, instead of a 200 for a row never written #15738 的拒绝 —— 它正是其卡片验收所要求的,而它替换掉的那个静默 200(为一行从未被写入的记录返回成功)才是缺陷。
    2. ⛔ 不动 invite_only 与 email_domain —— 已驱动,无新增披露。

    为什么锚定 domain:services

    落点:packages/plugins/plugin-auth(若裁处置 2,接 onExistingUserSignUp)或该姿态的文档页(若裁处置 1/3)。按车道表 plugin-auth 属 domain:services。

    依赖旗标(收尾简报用)

    分诊席位 · claude-opus-5 · 本轮 R+159


    Generated by Claude Code

  2. os-warren commented on Sep 5, 2026

    @os-warren
    CollaboratorAuthor

    Maintainer ruling — decision batch #48 (director seat, summon #14)

    Recorded by the director seat (session session_01LsEjuNMPitCHwEfYftZ1um) from the maintainer's live reply, verbatim: 「15746 同意」 — the card is approved exactly as presented in batch #48 (presented ~07:1xZ, ledger on #12708).

    The ruling

    Q0 — is open a supported internet-facing posture? — YES. The maintainer did not take the card's "moot, close it" exit; the exposure is real and belongs on the posture's own page. This answers the question the card addressed to triage and triage explicitly declined (5549865383), so it no longer hangs between the two.

    Disposition 1 now — document it. On the open audience posture's documentation page, state that on open the sign-up endpoint discloses whether an address is already registered, by design, together with the honest-refusal rationale (the explicit 422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL replaced a silent 200 for a row that was never written, per PR #15738 / #15587). The same page names the vendor's designed alternative — better-auth's onExistingUserSignUp (generic response + notify the existing holder) — as the remedy an operator can request.

    Disposition 2 later — the operator knob is the follow-on, not this card. Wiring onExistingUserSignUp as an option for open (explicit 422 stays the default elsewhere) is filed only when the trigger fires: a real open, internet-facing deployment asks for it. Until then the documented statement is the whole deliverable.

    Disposition 3 (accept and record) is subsumed — the page written under disposition 1 is the record.

    Boundaries carried from the card, unchanged

    Execution

    Labels: needs-user-decision → pm:queue; domain:services, priority:p2, security stay.


    Generated by Claude Code

  3. claude commented on Sep 5, 2026

    @claude
    Contributor

    Claimed and dispatched — pm:queue → pm:dispatched. ⭐ And a measurement the ruling could not have carried: the page the ruling names is auto-generated and must not be hand-edited.

    Claim: domain:services execution seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ
    Branch: claude/issue-15746-open-posture-enumeration-disclosure
    Clause-②: no

    That no is the ruling's own (5551072668): 「Clause-②: no on the ruled scope — documentation only, no accept-set change, no new exported symbol. Re-judge from the delivered diff as always」. ⇒ ⛔ no contract review is owed unless the delivered diff widens something.

    What is settled, and ⛔ not reopenable

    Maintainer, decision batch #48, verbatim 「15746 同意」, recorded by the director seat:

    ⚠️ The complication this seat measured, which changes where the round lands

    The ruling says 「the open posture's page under content/docs/**」. Measured on origin/main, the obvious candidate cannot take a hand-written sentence:

    page reading
    content/docs/references/system/auth-config.mdx ⛔ AUTO-GENERATED — DO NOT EDIT, banner in the file, 「Source: packages/spec/src/system/auth-config.zod.ts」; also merge=os-regen (.gitattributes:153, content/docs/references/** is generated whole)
    content/docs/deployment/self-hosting.mdx hand-written, names invite_only 4 times
    content/docs/releases/v17.mdx ⛔ release-owned, untouchable in a code PR

    ⇒ ⭐ two legitimate homes, and the choice has a consequence. A sentence in self-hosting.mdx is hand-written prose where an operator reads. A sentence in the .describe() of packages/spec/src/system/auth-config.zod.ts generates into the reference page and ships in the published tarball — @objectstack/spec's files[] includes src/**/*.zod.ts (measured; see #15905). ⛔ The seat picks, with the reading behind it; ⛔ it does not hand-edit the generated page.

    ⚠️ The in-flight check, narrowed after a broad one misled. A lane-wide regex over content/docs/** flagged two PRs (#15837, #15814). Read per-file: #15837 touches none of the posture pages, and #15814 touches auth-config.mdx — the generated one, which is moot for a hand-write since that file must not be hand-edited anyway. self-hosting.mdx and auth-config.zod.ts: free. A regex scoped to a directory is not a reading about a page.

    The exposure, as driven in review — so it is not re-derived

    posture what a stranger learns
    invite_only (default) nothing — existing and unknown addresses both answer 403 SELF_REGISTRATION_CLOSED, byte-identical bodies
    email_domain only within the allowlisted domain — the caller must already hold a corporate address there
    open existence of any address, to anyone who can reach the endpoint

    ⚠️ And the honest counterweight the ruling keeps: better-auth already runs a timing-equalising hash on the duplicate lane, so a careful attacker could already infer existence; the 422 states outright what was already inferable. That is why it is p2 and ⛔ not higher — and why the deliverable is a statement, not a behaviour change.


    Generated by Claude Code

  4. claude commented on Sep 5, 2026

    @claude
    Contributor

    os-dev-report
    {
    "issue": 15746,
    "status": "done",
    "branch": "claude/issue-15746-open-posture-enumeration-disclosure",
    "pr": "#15925",
    "premise_still_valid": true,
    "summary": "ONE home chosen: content/docs/deployment/self-hosting.mdx, a new level-2 section 'Who may sign up - and what each posture tells a stranger' placed between 'First boot: create the admin' and 'Go-live' (+44 lines, one file, head sha ceca119). The reading: the generated reference page is confirmed unusable by hand (AUTO-GENERATED banner in the file, and .gitattributes:153 routes content/docs/references/** to merge=os-regen), and the .describe() on posture in auth-config.zod.ts is legitimate but its rendering destination is ONE markdown table cell - the generated page renders each describe string as the Description column of a three-row property table - which cannot carry a statement plus the honest-refusal rationale plus a named vendor hook. self-hosting.mdx is the only hand-written page in the corpus that documents audience postures at all (a scan of content/docs excluding references/ and releases/ finds posture prose only there), and it is the operator page the ruling's phrase 'the remedy an operator can request' addresses. Not both: the tarball-reach argument for the describe is real (spec files[] does ship src//*.zod.ts, confirmed) but a second copy of 'on open, existence is disclosed' is the drift shape, so it is raised as an open question instead of smuggled in. Clause-2 re-declared from the delivered diff: NO - one hand-written .mdx, zero source files, no accept-set change, no new exported symbol, nothing widened; no needs:contract-review label hung. Issue arrived with an empty assignee field (PM-side half-state); claim comment 5552317173 carries this session and branch, so the claim is mine. skip-changeset applied additively (HTTP 200) and read back comparatively: current set equals union(read set, target), no concurrent label stripped.",
    "tests": "THE THREE-POSTURE TABLE, RE-MEASURED (not quoted). Harness: a temporary vitest file under packages/plugins/plugin-auth/src/ driving POST /api/v1/auth/sign-up/email through AuthManager.handleRequest on a real ObjectQL engine (better-sqlite3 in-memory, 4 seeded sys_user rows). NOT committed - this round changes nothing under plugin-auth. Command: os-verify-lock.sh -c 'pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/tmp-15746-posture-table.measure.test.ts' -> captured exit 0, VERDICT command-exit 0, 3 tests passed. Raw readings: invite_only registered AND unknown both 403 SELF_REGISTRATION_CLOSED with 'bodies byte-equal: true, headers equal: true' (asserted via Buffer.equals, so the 'discloses nothing' claim is measured, not inherited); email_domain on-domain registered 422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL vs on-domain unknown 200, while off-domain registered and unknown are byte-identical 403 EMAIL_DOMAIN_NOT_ALLOWED (disclosure confined to allowlisted domains); open registered 422 on BOTH an on-domain and an off-domain address vs unknown 200. The measured code paths (auth-manager.ts, audience-posture.ts) did not move on origin/main since the branch base, checked with git diff --name-only HEAD...origin/main (grep exit 1 over auth-manager|audience-posture|self-hosting|sign-up|auth-config), so the reading holds for today's main. GATES: family derived mechanically, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (captured exit 0) -> 33 gates; the dispatch named 2 (check:doc-authoring, check:doc-anchors), the derivation added 31 more including check:doc-security-posture, check:corpus-claim-drift, check:role-word, check:vendor-version-stamps, check:docs-transcript-drift and 7 spec-side gates. ALL 33 RUN ON THE FINAL COMMIT ceca119 (git rev-parse --short HEAD from that run), each exit code captured before any pipe via redirect-then-capture, ALL exit=0. Verdict lines as the gates printed them: 'check-doc-anchors: 311 internal #fragment link(s) across 410 source file(s) all resolve to a real heading'; 'check-corpus-claim-drift: OK, no new claim sites beside a pinned spelling. Scanned: 236 .md/.mdx file(s)'; 'check-role-word: OK, no new occurrences of the reserved word'; 'check:vendor-version-stamps: OK - 6395 file(s) scanned. better-auth: 267 version stamp(s); resolved 1.7.2'; 'check-nul-bytes: OK (scanned 7666 text file(s) ... no raw ASCII control bytes)'; '257 prose examples type-check across 3 surface(s)'. ONE non-finding: check:skill-examples first exited 1 as a PREREQUISITE REFUSAL - packages/client-react/dist held no .d.ts in this worktree and the gate refuses rather than reading absent declarations (its own text: 'a FALSE GREEN on exactly the change it exists to catch'). Built client-react and re-ran: exit 0. Read as NOT MEASURED then MEASURED, never as a red gate. Control-byte self-scan of the edited file and of the PR body: grep -naP over the control ranges, exit 1 (clean), plus no tag-shaped fragments in either. PR body read back from the API and byte-compared against the file sent: identical except a stripped trailing newline, and the session-URL footer survived a REST create verbatim with no second bare footer appended. No changeset (docs-only, nothing published from content/docs/
    ).",
    "mcp_calls": "0 - a repo-scoped authenticated REST read probed green in this container (GET /repos HTTP 200, GET /issues/15746 HTTP 200), so every read and write went through REST: issue and comments read, PR created, body read back, label added and read back. MCP schemas were loaded but never called.",
    "open_questions": [
    {
    "question": "Should the same statement also reach the PUBLISHED tarball, via the .describe() on posture in packages/spec/src/system/auth-config.zod.ts? I deliberately did not do it, and it is a maintainer call rather than mine.",
    "options": [
    "A - leave it at one home (self-hosting.mdx), as delivered: one statement, one place to maintain, and the reference table stays a contract summary.",
    "B - add a one-CLAUSE pointer to the posture describe (e.g. 'open discloses whether an address is already registered - see the self-hosting guide'), accepting a regenerated reference page plus gen:schema/gen:docs, and accepting that two places now assert the same fact.",
    "C - restructure so the reference page carries the full statement, which the generated table cell cannot render readably."
    ],
    "recommendation": "A, because the ruling's deliverable is a statement WITH rationale and a named remedy, and the describe string renders into a single Description table cell - measured on the generated page today. B is defensible only if AI authoring agents reading the tarball are the audience that matters most; it should then be filed as its own card so the two copies are created knowingly rather than as a rider here."
    }
    ],
    "out_of_scope_findings": []
    }


    Generated by Claude Code

  5. claude commented on Sep 5, 2026

    @claude
    Contributor

    Delivery accepted — PR #15925, one file, +44/−0. Open question ruled A (one home). ⛔ Not armed yet: CI has 4 checks still running.

    domain:services dispatching seat, session_01ARYe3yQTQCUFm5qPYNgKaJ. Clause-② re-declared no and verified, ⛔ not accepted on the seat's word: 0 files under packages/*/src/ against a firing control of 1 total file in the diff; 0 governed-prefix hits. ⇒ ⛔ no contract review owed.

    ⭐ It re-measured the table instead of publishing mine, and that changed one row

    The brief handed over a three-posture table from a review and said: a table you publish is a claim you own — verify it, especially invite_only's 「byte-identical」, because that is exactly the kind of sentence that rots. It built a harness (real AuthManager.handleRequest, real ObjectQL engine, sqlite, 4 seeded sys_user rows) and drove POST /api/v1/auth/sign-up/email:

    posture measured
    invite_only registered and unknown both 403 SELF_REGISTRATION_CLOSED — bodies byte-equal: true, headers equal: true, asserted via Buffer.equals
    email_domain on-domain registered 422 vs on-domain unknown 200 ⇒ it does disclose; off-domain registered and unknown byte-identical 403 EMAIL_DOMAIN_NOT_ALLOWED
    open 422 on both an on-domain and an off-domain address vs 200 for unknown

    ⇒ 「discloses nothing」 for the default posture is now measured, ⛔ not inherited. And it checked the measured code paths had not moved on origin/main since the branch base, so the reading holds for today's tree rather than only for the worktree.

    ⭐ The home choice rests on a measurement the ruling could not have carried

    The brief named two legitimate homes and warned that picking is the judgement of the round. The round measured the one thing that settles it: the .describe() on posture renders into one markdown table cell — the Description column of a three-row property table — which cannot carry a statement plus the honest-refusal rationale plus a named vendor hook. And a scan of content/docs (excluding references/ and releases/) finds audience-posture prose in exactly one hand-written page.

    ⇒ content/docs/deployment/self-hosting.mdx, a new section between 「First boot」 and 「Go-live」 — the operator page the ruling's own phrase 「the remedy an operator can request」 addresses.

    Open question ruled: A — one home. ⛔ Not both, ⛔ not a card either.

    The round refused to do both and raised it instead of smuggling it in — correctly: 「the tarball-reach argument for the describe is real (spec files[] does ship src/**/*.zod.ts, confirmed) but a second copy … is the drift shape」.

    A, for two reasons:

    1. the ruling's deliverable is a statement with rationale and a named remedy, and the measured rendering destination cannot hold one;
    2. ⭐ the ruling already established the pattern for speculative additions on this very card — disposition 2 is filed 「only when the trigger fires」. The same logic governs B: a pointer aimed at tarball readers is filed when a tarball reader asks, ⛔ not on the chance that one might. So ⛔ no card is opened for B — that would be the widening the ruling's own shape refuses, one level up.

    ⚠️ ⛔ This does not dispute that src/**/*.zod.ts ships — it does, measured, and that fact is carried on #15905 (E4) where it belongs as a 「published」 boundary question. What is refused is manufacturing a second assertion site today with no consumer.

    One non-finding, handled the way this repo requires

    check:skill-examples first exited 1 — a PREREQUISITE REFUSAL, not a red gate: packages/client-react/dist held no .d.ts in that worktree and the gate refuses rather than reading absent declarations. Its own words: 「a FALSE GREEN on exactly the change it exists to catch」. Built client-react, re-ran, exit 0. ⇒ read as NOT MEASURED then MEASURED, ⛔ never as a failure. 33 gates derived mechanically (the dispatch named 2; the derivation added 31), all exit 0 on the final commit, every exit code captured before any pipe.

    Next

    ⛔ Nothing flipped, ⛔ nothing armed — 「4 check(s) still running」 and not-red-yet is not green. When it turns green this seat runs the pre-landing checks and arms. ⛔ No merge by hand, ⛔ no queue bypass.


    Generated by Claude Code

  6. claude commented on Sep 5, 2026

    @claude
    Contributor

    Green — PR #15925 flipped ready and armed.

    domain:services dispatching seat, session_01ARYe3yQTQCUFm5qPYNgKaJ. Readings taken now, ⛔ none recalled:

    check reading
    CI, all checks (⛔ not the required subset) GREEN — all 31 check-run(s) completed, none failed, exit 0, head ceca11965
    mergeable mergeable: true, mergeable_state: clean
    Clause-② no, verified — 0 files under packages/*/src/ against a firing control of 1 total file in the diff; 0 governed-prefix hits ⇒ ⛔ no contract review owed
    ⛔ release-owned red line content/docs/releases/**: 0 — in fact the diff touches no file outside the one hand-written page

    What lands: one section on content/docs/deployment/self-hosting.mdx stating that on the open audience posture the sign-up endpoint discloses whether an address is already registered, by design, with the honest-refusal rationale and better-auth’s onExistingUserSignUp named as the remedy an operator can request.

    ⭐ The three-posture table it publishes was re-measured, not quoted — invite_only’s two responses asserted byte-equal via Buffer.equals, and the measured code paths checked unmoved on origin/main since the branch base. A table published on a page that declares itself authoritative is a claim the page owns.

    Disposition 2 (wiring the operator knob) remains the follow-on, filed only when a real open internet-facing deployment asks — and by the same logic ⛔ no card was opened for pointing the statement at the published tarball. ⛔ No merge by hand, ⛔ no queue bypass.


    Generated by Claude Code

  7. github-actions commented on Sep 6, 2026

    @github-actions
    Contributor

    os-closed-card-sweep — machine-findable marker for this generated comment.

    Removed the pm-loop state label(s) this closed card no longer claims: pm:dispatched.

    A state label claims work is in flight. This card is closed on a merged delivery, so the claim
    is stale; every other label is left exactly as it was found. Nothing here is a judgement about
    the card, and no verdict-bearing label is ever touched by this sweep.

    posted by half-state-patrol run 34005012908 · trigger schedule

    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions