Skip to content

docs(deployment): state the address-enumeration disclosure the open audience posture carries, by design - #15925

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-15746-open-posture-enumeration-disclosure
Sep 5, 2026
Merged

docs(deployment): state the address-enumeration disclosure the open audience posture carries, by design#15925
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-15746-open-posture-enumeration-disclosure

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes #15746

Documentation only — one new section on the hand-written self-hosting page. No behaviour change, no spec change, no generated page edited, no changeset (nothing is published from content/docs/**).

The ruling this implements

Maintainer decision batch #48, 「15746 同意」, recorded on the card at comment 5551072668. Q0 — is open a supported internet-facing posture — was answered YES, so the card's "moot, close it" exit was not taken and the exposure belongs on the posture's own page. This PR is Disposition 1 (state it). Disposition 2 — wiring better-auth's onExistingUserSignUp as an operator knob — is explicitly the follow-on and is not done here. PR #15738 (card #15587), which made the duplicate refusal explicit, is neither reverted nor weakened; invite_only and email_domain behaviour is untouched.

Which page — and the reading behind picking one home rather than two

The ruling names "the open audience posture's documentation page". Three candidates, measured on origin/main:

Candidate Reading
content/docs/references/system/auth-config.mdx Not editable by hand — carries the AUTO-GENERATED — DO NOT EDIT banner, and .gitattributes:153 routes content/docs/references/** to merge=os-regen.
.describe() on posture in packages/spec/src/system/auth-config.zod.ts Legitimate, and it does reach further: @objectstack/spec's files[] ships src/**/*.zod.ts. But its rendering destination is one markdown table cell — the generated page renders each .describe() as the Description column of a three-row property table. A statement plus the honest-refusal rationale plus a named vendor hook does not fit a table cell without becoming unreadable, and that column is a contract summary, not a place to reason about a security trade.
content/docs/deployment/self-hosting.mdx Chosen. The only hand-written page in the corpus that documents the audience postures at all (invite_only appears there 4×; a repo-wide scan of hand-written content/docs/** finds posture prose in this page only). Operator-facing, which is the audience the ruling names ("the remedy an operator can request"), and it can carry multi-sentence prose.

One home, not two, deliberately. The tarball-reach argument for the .describe() is real, but a second copy of "on open, existence is disclosed" is the drift shape this repo keeps paying for: the day the behaviour moves, both copies must move, and only one of them is near the code. If the maintainer wants the statement to reach the published tarball as well, the right shape is a one-clause pointer added deliberately — a separate decision, not a rider here.

The section sits between ## First boot: create the admin and ## Go-live, at ## so it appears in the page nav: an operator choosing a posture is the reader it is for, and burying it inside the existing bootstrap-recovery subsection would have hidden it from exactly that person.

The three-posture table is measured, not quoted

The card and its review supplied the table; every row was re-driven against the real sign-up route before being written down, on a real ObjectQL engine (better-sqlite3, in-memory) with four seeded sys_user rows, driving POST /api/v1/auth/sign-up/email through AuthManager.handleRequest. Raw response bytes:

Case Measured
invite_only, registered address 403 {"code":"SELF_REGISTRATION_CLOSED","message":"Self-registration is closed on this environment (audience posture invite_only). Ask an administrator for an invitation."}
invite_only, unknown address 403, byte-equal body and equal headers — asserted with Buffer.equals, printed bodies byte-equal: true, headers equal: true
email_domain, on-domain registered 422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL
email_domain, on-domain unknown 200
email_domain, off-domain registered vs unknown both 403 EMAIL_DOMAIN_NOT_ALLOWED, byte-identical — the disclosure is confined to allowlisted domains
open, registered (any domain) 422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL
open, unknown 200

The harness was a temporary file under packages/plugins/plugin-auth/src/; it is not committed — this round changes nothing under plugin-auth (3 tests, all passing, log kept out of tree).

The counterweight, stated more precisely than the card did

The card and triage put it as "better-auth already runs a timing-equalising hash on the duplicate lane, so a careful attacker could already infer existence". Read at the source (better-auth 1.7.2, dist/api/routes/sign-up.mjs), the causality is the other way round and the conclusion still holds: the hash is a countermeasure — the duplicate lane hashes the password with the vendor's own comment "to reduce timing differences between existing and non-existing emails" and returns the synthetic 200. What it does not equalise is everything after it: the fresh lane additionally writes the user row, links the account, and dispatches a verification mail. So the silent 200 was never a real secret — the mail that never arrives, and the account that can never sign in, already told a patient caller what the 422 now states outright. The page says that, so it cannot be read as claiming the 422 created the exposure.

Verification

Gate family derived mechanically from the diff, not recalled — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (33 gates; the dispatch named check:doc-authoring / check:doc-anchors, the derivation added 31 more, including check:doc-security-posture, check:corpus-claim-drift, check:role-word and check:vendor-version-stamps). All 33 run on the final commit ceca11965, each exit code captured before any pipe, all exit=0. Selected 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. Scanned: 236 .md/.mdx file(s)
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)
27 ObjectSchema.create example(s) ... carry an os validate-clean security posture
257 prose examples type-check across 3 surface(s)

check:skill-examples first exited 1 on a prerequisite refusal, not a finding — packages/client-react/dist was unbuilt in this worktree and the gate refuses rather than green-lighting a read of absent declarations. Built it and re-ran: green.

Clause-② re-declared from the delivered diff: no. The diff is one hand-written .mdx file, +44 lines, zero source files — no accept-set change, no new exported symbol, nothing widened.

🤖 Generated with Claude Code


Generated by Claude Code

… audience posture carries, by design (#15746)

The `open` posture's sign-up endpoint tells any caller whether an address is
already registered: a registered address is refused
`422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` while an unregistered one is
accepted `200`. That is a property of the posture, not a defect in the refusal,
and until now it was documented nowhere an operator reads.

The self-hosting page gains one section: the three-posture disclosure table
(each row measured against the real sign-up route), the statement that `open`
discloses registration by design, the honest-refusal rationale — the explicit
422 replaced a silent 200 for a row that was never written — the counterweight
that the silent lane was never a real secret either (the engine equalised the
password hash and nothing else, so the verification mail that never arrives
already told a patient caller the same thing), and better-auth's designed
alternative `onExistingUserSignUp` as the remedy an operator can request.

Documentation only. No behaviour changes, no accept-set changes, and no edit
to the generated reference page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 5, 2026
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation labels Sep 5, 2026
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 5, 2026 15:07
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 0467941 Sep 5, 2026
37 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-15746-open-posture-enumeration-disclosure branch September 5, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants