fix(auth): count the password minimum in characters, not UTF-8 bytes - #283
Merged
Merged
Conversation
The product announces one rule in four places — "the password must be at
least 8 characters" (the register placeholder in `ui/index.html`, both
the zh and en `err.weakPassword` packs, and the `ERR_MAP` literal that
carries the server's own wording) — but the server enforced it with
`String::len()`, i.e. UTF-8 BYTES, at three request-validation sites
(register / reset-password / change-password).
So a 5-character password such as `密码abc` (5 characters, 9 bytes) was
accepted by the API while the API's own error message promises 8
characters, and while the app's own forgot-password form refused the very
same password.
Fix: one unit everywhere.
* `src/routes/mod.rs` — a single source of truth, `MIN_PASSWORD_CHARS`
+ `password_too_short()`, used by all three sites. No new i18n key:
the four carriers were already correct and are deliberately left
untouched — rewording them to "bytes" would write the defect into the
documentation.
* `ui/js/app.js` — the form guard now counts Unicode scalar values
(`Array.from(pw).length`), matching Rust `chars().count()` instead of
`.length`, which is UTF-16 code units and splits an astral character
in two.
Tests: two boundary tests in `src/routes/mod.rs` — one pins the unit
(including the emoji case that only the byte-measured guard lets
through) and reads the announced N from the two packs instead of
hardcoding it; one pins the end-to-end behaviour at the register
endpoint. A `state_gate` rule derives the four carriers, the two
implementations and the 3+1 site shape from the tree.
Owner
Author
|
Self-review (the task's own role forbids self-approval, so this is a plain comment). Reviewed the diff against the tree it ships in:
Evidence gathered before opening this PR (all against the shipped bytes, head
Nothing is left open, and no acceptance item depends on host-side work. |
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.
Summary
The password minimum is announced in characters in four places but was enforced in UTF-8
bytes on the server. This PR makes one unit everywhere.
One rule, four carriers, all naming characters (none of them is touched — rewording them to
"bytes" would write the defect into the documentation):
docs/prototype/aitokenpool-console.html至少 8 位ui/js/i18n.jszh packerr.weakPassword=密码至少 8 位ui/js/i18n.jsen packerr.weakPassword=Password must be at least 8 charactersui/js/i18n.jsERR_MAPliteral密码至少 8 位→err.weakPassword(the wording the server returns)One implementation counted something else:
src/routes/mod.rsusedString::len()— the bytelength — at three request-validation sites, so the effective minimum was 8 bytes ≈ 2–3 CJK
characters. Concretely,
密码abc(5 characters / 9 bytes) was accepted by the API while theAPI's own error message promises 8 characters, and while the app's own forgot-password form
refused the very same password.
Related Issue
No issue: this is a defect found by the task's own recon, tracked in this project's ledger. The
linked-issue field is expected to stay empty.
Changes
src/routes/mod.rsMIN_PASSWORD_CHARS+password_too_short(pw) -> bool, countingpw.chars().count();pw.len() < 8;mod tests:password_minimum_is_counted_in_characters_not_bytes— pins the unit; three of its fivesamples are passwords the byte-measured guard would have let through, and it reads the
announced N out of the two language packs instead of hardcoding it;
register_rejects_a_password_short_in_characters_long_in_bytes— pins the end-to-endbehaviour at the register endpoint (
密码abc⇒400 密码至少 8 位).ui/js/app.js— the forgot-password guard now counts Unicode scalar values(
Array.from(pw).length < MIN_PW_CHARS)..lengthis UTF-16 code units, which splits an astralcharacter in two:
😀😀😀😀reads 8 there and would have been accepted, while the server nowcounts 4.
src/state_gate.rs— a new lexical gate,the_password_minimum_is_counted_in_the_unit_its_message_names, with four rules that each havetheir own teeth:
own counting expression must be the unit the carriers announce;
Array.from(...)— no barepw.length <;Rust constant (read from the source, never written down in the gate);
ui/README.md— the convention, its four carriers, and the honest scope of the gate.ui/index.html— cache-bust token bump (js/app.js?v=20260922-5→20260922-6).Tests
cargo test— 336 passed, 0 failed (baseline onmain: 331; +3 gate tests, +2 boundary tests)cargo fmt --check— cleancargo clippy --all-targets -- -D warnings— cleanTwo independent instruments were run against the bytes that ship, not against a paraphrase of them:
state_gatefragment into a materialized baseline(
git archiveofmain) and compiles/runs it twice: the un-fixed tree must fail exactly theaxis test and the teeth test (all other 42 pass), the sheet-applied tree must be fully green.
23/23 legs as declared, including a
clippy-driver -D warningsleg and a leg that fails loudly ifthat driver is missing. Disarming the site-shape rule inside the gate flips exactly the test it
should (negative control), 21/21 legs as declared.
#forgot-link, realsubmit):5/5 legs as declared; mutating the guard back to
pw.length < 8on the shipped bytes turnsexactly the axis leg red (
😀😀😀😀gets past the form and a reset request leaves the client),and a different spelling of the same contract (
[...pw].length) passes all of them — the legstest the contract, not the spelling.
Checklist
fix/…)fix(auth): …)