docs(deployment): state the address-enumeration disclosure the open audience posture carries, by design - #15925
Merged
zhuangjianguo merged 1 commit intoSep 5, 2026
Conversation
… 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
zhuangjianguo
marked this pull request as ready for review
September 5, 2026 15:07
zhuangjianguo
enabled auto-merge
September 5, 2026 15:07
zhuangjianguo
deleted the
claude/issue-15746-open-posture-enumeration-disclosure
branch
September 5, 2026 15:30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 — isopena 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'sonExistingUserSignUpas 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_onlyandemail_domainbehaviour is untouched.Which page — and the reading behind picking one home rather than two
The ruling names "the
openaudience posture's documentation page". Three candidates, measured onorigin/main:content/docs/references/system/auth-config.mdxAUTO-GENERATED — DO NOT EDITbanner, and.gitattributes:153routescontent/docs/references/**tomerge=os-regen..describe()onpostureinpackages/spec/src/system/auth-config.zod.ts@objectstack/spec'sfiles[]shipssrc/**/*.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.mdxinvite_onlyappears there 4×; a repo-wide scan of hand-writtencontent/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 "onopen, 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 adminand## 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_userrows, drivingPOST /api/v1/auth/sign-up/emailthroughAuthManager.handleRequest. Raw response bytes:invite_only, registered address403{"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 address403, byte-equal body and equal headers — asserted withBuffer.equals, printedbodies byte-equal: true, headers equal: trueemail_domain, on-domain registered422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAILemail_domain, on-domain unknown200email_domain, off-domain registered vs unknown403 EMAIL_DOMAIN_NOT_ALLOWED, byte-identical — the disclosure is confined to allowlisted domainsopen, registered (any domain)422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAILopen, unknown200The harness was a temporary file under
packages/plugins/plugin-auth/src/; it is not committed — this round changes nothing underplugin-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 synthetic200. 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 silent200was never a real secret — the mail that never arrives, and the account that can never sign in, already told a patient caller what the422now states outright. The page says that, so it cannot be read as claiming the422created 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 namedcheck:doc-authoring/check:doc-anchors, the derivation added 31 more, includingcheck:doc-security-posture,check:corpus-claim-drift,check:role-wordandcheck:vendor-version-stamps). All 33 run on the final commitceca11965, each exit code captured before any pipe, allexit=0. Selected verdict lines, as the gates printed them:check:skill-examplesfirst exited 1 on a prerequisite refusal, not a finding —packages/client-react/distwas 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.mdxfile, +44 lines, zero source files — no accept-set change, no new exported symbol, nothing widened.🤖 Generated with Claude Code
Generated by Claude Code