Login throttling, Secure cookie flag, POST-only logout, and coverage gaps #14
No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No assignees
3 participants
Notifications
Due date
No due date set.
Reference
marvin8/xique!14
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/issue-7-hardening"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The remaining items from #7, audited against the current code (the worker-side items — transactional migrations,
recover_stale_jobs,jobs.statusindex, complete/fail guards, poll-interval setting — were already absorbed by phases 1b–1d).Closes #7
What this does
login_attemptstable keyed on(username, source_ip). AfterXIQUE_LOGIN_ATTEMPT_LIMIT(default 5) failures withinXIQUE_LOGIN_WINDOW_SECONDS(default 900), the pair gets 429too many failed login attempts— evaluated before credential checks, and applied to unknown usernames too (no user enumeration through the throttle). A successful login resets the pair. Pair-keying means attacker-rotated usernames cannot lock out a victim, and one NAT address cannot lock out everyone.Secureflag —XIQUE_COOKIE_SECURE(defaulttrue) addsSecureto both the session and CSRF cookies; plain-HTTP deployments opt out withfalse(secure-by-default per review).GET /logoutis gone (405); logout is now aPOSTcarrying the session-bound CSRF token, rendered as a form in the nav of every authenticated page. State changes no longer ride on GET.password is requiredpath (previously a 422 form error, since python-multipart drops empty fields); unicode passwords round-trip; multi-byte passwords measured in bytes; malformed CSRF cookie halves (token.sigvariants) rejected without raising;get_dbcloses its connection when the handler raises;server.main/worker.mainentry-point coverage landed on #13.Testing
cf0bb94): 24 new tests acrosstest_auth_hardening.py,test_login_attempts_db.py,test_deps.py.uv run noxgreen except the zaojun/pre-commit sessions that fail onmaintoo (stale upstream pins — #12 fixes those).Merge order note: same dependency-pin situation as #13 — after #12 merges I will rebase this branch so CI runs clean.
Review fix waves
19220e3): atomic throttle recording (conditionalINSERT ... SELECT), logout deletion cookie mirrors set attributes, disabled accounts with valid credentials no longer recorded as failures, 400 when no source address is available,XIQUE_TRUSTED_PROXIESforX-Forwarded-For-based throttling,>= 1validation on the throttle settings,XIQUE_COOKIE_SECUREdefault flipped totrue.e49f0b5): recording now purgeslogin_attemptsrows older than the window (bounded table), andX-Forwarded-Foris honored only when the socket peer is inXIQUE_TRUSTED_PROXIES.353 tests passing;
uv run noxfully green.a06c6e91ab5140c5f2395140c5f2392f21d015d52f21d015d52fffbea100WuMing
Found 9 issue(s). See inline comments below.
@ -39,6 +42,9 @@ class Settings(BaseSettings):import_dir: Path = Path(tempfile.gettempdir()) / "xique-imports"log_level: str = "INFO"log_file: Path | None = Nonecookie_secure: bool = Falsesecurity [CRITICAL]
A05: Security Misconfiguration - cookie_secure defaults to False, causing session and CSRF cookies to be transmitted without the Secure flag. In production TLS deployments, this significantly increases risk of credential theft via MITM attacks. Insecure defaults often remain unchanged in production.
@ -40,2 +43,4 @@log_level: str = "INFO"log_file: Path | None = Nonecookie_secure: bool = Falselogin_attempt_limit: int = 5code [LOW]
login_attempt_limitandlogin_window_secondshave no validation constraints. A value of 0 or a negative number could disable the throttle or cause SQL errors. Addfield_validatorto enforce reasonable bounds.@ -254,1 +262,3 @@raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="password is too long")password_value = password or ""_validate_credentials(username, password_value)source_ip = request.client.host if request.client else "unknown"code [LOW]
Fallback to
"unknown"whenrequest.clientis missing may group all such requests under a single throttle bucket, enabling a single attacker to lock out every user behind a misconfigured proxy. Prefer rejecting the request or logging an error instead.security [HIGH]
A01: Broken Access Control - source_ip extracted from request.client.host won't respect X-Forwarded-For headers. When deployed behind a reverse proxy, all users appear to share the proxy's IP, causing: (1) login throttling to become ineffective (attackers bypass limits) and (2) all users behind the proxy to share a single throttle bucket (DoS risk).
@ -255,0 +268,4 @@source_ip=source_ip,window_seconds=settings.login_window_seconds,)if recent_failures >= settings.login_attempt_limit:code [HIGH]
The throttle check is not atomic: concurrent requests can all see a failure count below the limit and proceed to password verification, each eventually recording a failure. This may allow an attacker to exceed the attempt limit by sending many requests in parallel. Use an atomic approach (e.g., INSERT then count, or a row-level lock) to close the race window.
@ -260,2 +279,4 @@await record_failed_login(db, username=username, source_ip=source_ip)raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid username or password")if user["disabled_at"] is not None:await record_failed_login(db, username=username, source_ip=source_ip)code [MEDIUM]
Correct password for a disabled account is treated as a failed login and recorded, which may permanently throttle a legitimate user who retries and then gets the account re-enabled. Consider not recording failures when the credentials are valid but the account is disabled.
@ -288,3 +321,4 @@verify_csrf(request, csrf_token or "")settings: Settings = request.app.state.settingstoken = request.cookies.get(settings.session_cookie_name)if token is not None:code [HIGH]
The session cookie deletion (in the code following this line) does not appear to include the
secureflag. Ifcookie_secureis enabled, the browser may ignore the deletion instruction for the secure cookie, rendering logout ineffective. Addsecure=settings.cookie_secureto theresponse.delete_cookie()call.@ -0,0 +1,92 @@# SPDX-License-Identifier: AGPL-3.0-or-later"""Tests for the login-attempt throttle store.Migration 3 adds the ``login_attempts`` table and :mod:`xique.db.login_attempts`code [LOW]
Docstring mentions 'Migration 3' but this table is created by migration 4.
@ -0,0 +65,4 @@async def test_old_failures_are_ignored(db: DbDep) -> None:for _attempt in range(3):await record_failed_login(db, username="marvin8", source_ip="203.0.113.7")await db.execute(code [LOW]
SQL update uses string formatting instead of parameterized placeholders, which sets a poor example. While safe in this test, it is worth parameterizing to avoid a future copy-paste mistake.
Addressing all 9 findings from this review — fixes in
19220e3, tests pinned red in5d05da2(351 tests green, fulluv run noxDoD passing).✅ Fixed — A correct password for a disabled account
src/xique/web/routes/auth.pyline 282The record call is gone from the disabled branch — valid credentials for a disabled account now answer 401 without touching the throttle.
✅ Fixed — Fallback to
"unknown"src/xique/web/routes/auth.pyline 264Requests without a resolvable source address are now rejected with 400 (
source address unavailable) instead of being grouped under a shared bucket.✅ Fixed — A01: Broken Access Control
src/xique/web/routes/auth.pyline 264New
XIQUE_TRUST_PROXY_HEADERSsetting, defaultfalse. When enabled, the throttle address is the rightmostX-Forwarded-Forentry — the one the single trusted reverse proxy appended — which defeats client-side header spoofing for the documented single-proxy deployment. Left off by default so untrusted headers never decide throttling; README documents when to enable it.✅ Fixed — The throttle check is not atomic
src/xique/web/routes/auth.pyline 271Recording is now a single conditional
INSERT ... SELECTthat counts the window inside the same statement. SQLite serialises write statements, so concurrent recorders each see exactly the failures committed before them; oncelimitis reached the insert is refused and the route answers 429. Pinned byrecording is refused once the pair reaches the limit.✅ Fixed — The session cookie deletion
src/xique/web/routes/auth.pyline 285The deletion now mirrors the set attributes:
secure=settings.cookie_secure, httponly=True, samesite="lax"— pinned for both the secure and insecure settings.✅ Fixed —
login_attempt_limitandlogin_window_secondshave no validationsrc/xique/config.pyline 46A
field_validatornow rejects either setting below 1 withValueError, parameterised over both fields.✅ Fixed — A05: Security Misconfiguration
src/xique/config.pyline 45cookie_securenow defaults totrue; plain-HTTP deployments opt out withXIQUE_COOKIE_SECURE=false, documented in the README config table.✅ Fixed — Docstring mentions 'Migration 3'
tests/test_login_attempts_db.pyline 4Docstring corrected to migration 4.
✅ Fixed — SQL update uses string formatting
tests/test_login_attempts_db.pyline 68The test
UPDATEnow uses?placeholders.WuMing
Found 8 issue(s). See inline comments below.
@ -107,6 +107,10 @@ Every setting is read from the environment with the `XIQUE_` prefix:| `XIQUE_IMPORT_DIR` | `<tempdir>/xique-imports` | directory uploaded Trakt export zips stream into || `XIQUE_LOG_LEVEL` | `INFO` | minimum level for the log file (`DEBUG`–`CRITICAL`, case-insensitive) || `XIQUE_LOG_FILE` | unset | log file path; unset logs errors to stdout only || `XIQUE_COOKIE_SECURE` | `true` | add the `Secure` flag to session and CSRF cookies — set `false` only for plain-HTTP deployments without TLS |docs [HIGH]
The default value in the table ('true') contradicts the PR description, which explicitly states that XIQUE_COOKIE_SECURE defaults to 'false'. Update the table to 'false' to match the implementation and prevent accidental cookie drops on plain-HTTP or local deployments.
@ -39,6 +46,10 @@ class Settings(BaseSettings):import_dir: Path = Path(tempfile.gettempdir()) / "xique-imports"log_level: str = "INFO"log_file: Path | None = Nonecookie_secure: bool = Truecode [CRITICAL]
Default value for 'cookie_secure' is True, but the pull request description states it should be False. This mismatch will cause session and CSRF cookies to require the Secure flag by default, breaking all plain‑HTTP development setups. The intended default appears to be False; change this to 'cookie_secure: bool = False'.
@ -0,0 +1,93 @@# SPDX-License-Identifier: AGPL-3.0-or-latercode [MEDIUM]
There is no maintenance logic to purge old rows from the 'login_attempts' table. Over time the table will grow indefinitely with stale failed‑login records, consuming storage. Add a periodic cleanup task (e.g., a background worker or a delete of entries older than the throttle window plus a safety margin).
@ -157,0 +176,4 @@address grouping is refused instead of collapsed."""if settings.trust_proxy_headers:security [MEDIUM]
A05: When trust_proxy_headers is enabled, the application accepts X-Forwarded-For headers to determine the client IP for login throttling without additional validation that the request actually originated from a trusted reverse proxy. If this configuration is enabled in an environment without a properly configured proxy that sanitizes this header, attackers can spoof arbitrary source IP addresses. This allows bypass of rate limiting (by rotating spoofed IPs) or denial-of-service against legitimate users (by spoofing their IP to exhaust their attempt limit).
@ -260,2 +343,4 @@if not verify_password(password_value, str(user["password_hash"])):await _record_failed_login(db, settings, username=username, source_ip=source_ip)raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid username or password")if user["disabled_at"] is not None:security [LOW]
A04: The disabled account check occurs after password verification (line 343). This creates a timing side-channel that can reveal whether a disabled account has a valid password (the execution path differs between disabled+correct_password and disabled+wrong_password), and wastes computational resources performing expensive password hash verification on disabled accounts. Move the disabled_at check before the password verification to ensure consistent timing and resource usage.
@ -0,0 +1,494 @@# SPDX-License-Identifier: AGPL-3.0-or-latertests [LOW]
New test file test_auth_hardening.py was added but its contents are truncated in the diff (495 lines). Cannot verify assertion or state-restoration quality for the new login throttling, secure cookie, and POST-only logout tests. Ensure all tests have meaningful assertions and proper teardown.
@ -64,0 +85,4 @@except ValidationError as error:raised = Trueexpect(str(error)).to_contain(field)expect(raised).to_be_truthy()tests [MEDIUM]
Assertion
expect(raised).to_be_truthy()after a ValidationError catch relies on a boolean flag. The test would still pass if ValidationError were never raised butraisedremained False, making the assertion tautological (it checks the flag set inside the try block, not whether an exception actually occurred). Consider using context managers (e.g.,pytest.raises) or restructuring to assert that the expected exception was indeed raised.@ -0,0 +84,4 @@raised = Trueexpect(raised).to_be_truthy()closed_cleanly = Falsetests [MEDIUM]
Boolean flags
closed_cleanlyandraisedare used to track state within try/except blocks. If the code path does not throw or the exception type changes, these assertions could pass falsely (e.g., if no ValueError is raised butclosed_cleanlyis never set to True). Consider using context managers likepytest.raisesor direct exception assertion patterns to make the tests fail explicitly when expected exceptions do not occur.Wave-2 findings — fixes in
e49f0b5, tests pinned red in9c551b0(353 tests green, fulluv run noxDoD passing). The two 🔴 refusals keep the deliberate secure-by-default behaviour from wave 1 and the anti-enumeration ordering; rationale on each.🔴 Not actioned — Default value for 'cookie_secure' is True, but the pull requ
src/xique/config.pyline 49Default flipped to
truedeliberately — this was the fix for your own wave-1 A05 CRITICAL ("cookie_secure defaults to False ... Insecure defaults often remain"). Reverting would reintroduce that finding. Plain-HTTP development opts out withXIQUE_COOKIE_SECURE=false, documented in the README. The stale text was the PR description, which now saystrue.✅ Fixed — There is no maintenance logic to purge old rows from the 'lo
src/xique/db/login_attempts.pyline 1Recording now purges rows older than the window in the same call (
DELETE ... WHERE attempted_at < datetime('now', -window)), so the table stays bounded by the window's worth of failures. Pinned byrecording purges failures older than the window.🔴 Not actioned — The default value in the table ('true') contradicts the PR d
README.mdline 110Same substance as the config finding:
trueis the deliberate secure-by-default fix for wave-1's A05 CRITICAL. The README and the PR description (now corrected) agree ontruewith a documentedfalseopt-out.🔴 Not actioned — A04: The disabled account check occurs after password verifi
src/xique/web/routes/auth.pyline 256Declined. Moving the disabled check before verification lets an unauthenticated caller distinguish disabled-with-valid-password from wrong-password, revealing both account existence and disabled status; the route deliberately answers identical
invalid username or passwordfor both, with the module docstring documenting that account existence is not revealed. Argon2 runs identically on every path, so the timing channel is negligible. It would also reintroduce the wave-1 MEDIUM behaviour (disabled accounts feeding the throttle).✅ Fixed — A05: When trust_proxy_headers is enabled, the application ac
src/xique/web/routes/auth.pyline 179XIQUE_TRUST_PROXY_HEADERS(bool) is replaced byXIQUE_TRUSTED_PROXIES— a comma-separated list of peer addresses allowed to assertX-Forwarded-For. The header is honored only when the socket peer is in that list; empty (the default) disables proxy-header trust entirely. Pinned byX-Forwarded-For is ignored when the peer is not a trusted proxy(peer192.0.2.77not in list → failure keyed on the peer).🔴 Not actioned — New test file test_auth_hardening.py was added but its conte
tests/test_auth_hardening.pyline 1Informational. The full file is in the repo and runs in CI; all 353 tests carry real assertions against the app and database, with teardown via tryke fixtures (
per="test"temporary directories).🔴 Not actioned — Assertion
expect(raised).to_be_truthy()after a Validationtests/test_config.pyline 88Declined — the premise is incorrect. If
ValidationErroris never raised,raisedstaysFalseand the finalexpect(raised).to_be_truthy()fails, so the test does fail when the exception is missing; a non-ValidationErrorexception propagates and errors the test. This is tryke (not pytest), and the catch-and-flag form is the documented way to also assert the message content, which.to_raise()cannot.🔴 Not actioned — Boolean flags
closed_cleanlyandraisedare used to tractests/test_deps.pyline 87Declined — same premise issue as the test_config comment.
closed_cleanlystaysFalsewhen the expectedValueErrornever occurs, so the finalexpect(closed_cleanly).to_be_truthy()fails; a different exception type propagates and fails loudly. The flags cannot produce a false pass.WuMing
Found 10 issue(s). See inline comments below.
@ -107,6 +107,10 @@ Every setting is read from the environment with the `XIQUE_` prefix:| `XIQUE_IMPORT_DIR` | `<tempdir>/xique-imports` | directory uploaded Trakt export zips stream into || `XIQUE_LOG_LEVEL` | `INFO` | minimum level for the log file (`DEBUG`–`CRITICAL`, case-insensitive) || `XIQUE_LOG_FILE` | unset | log file path; unset logs errors to stdout only || `XIQUE_COOKIE_SECURE` | `true` | add the `Secure` flag to session and CSRF cookies — set `false` only for plain-HTTP deployments without TLS |docs [HIGH]
The documentation incorrectly lists the default value for XIQUE_COOKIE_SECURE as true. The pull request description explicitly states the default is false. Updating this to false ensures the documentation matches the implementation and prevents accidental security misconfigurations in production.
@ -39,6 +46,10 @@ class Settings(BaseSettings):import_dir: Path = Path(tempfile.gettempdir()) / "xique-imports"log_level: str = "INFO"log_file: Path | None = Nonecookie_secure: bool = Truecode [LOW]
Default
cookie_secure = Truemay break out-of-the-box local development without TLS. The PR description says default is False, but code shows True. This discrepancy could cause session cookies not to be sent in plain HTTP. Align default with documented behavior or explicitly document that local development requires setting it to False.@ -0,0 +53,4 @@"DELETE FROM login_attempts WHERE attempted_at < datetime('now', ?)",(f"-{window_seconds} seconds",),)await db.commit()code [HIGH]
Commit after every call couples business logic to connection-level transactions and can interfere with a larger request-scoped transaction if the caller ever wraps calls in an explicit transaction. For a standalone module this is acceptable, but if get_db provides a shared connection, this may prematurely commit other pending operations. Consider making transaction control the caller's responsibility or documenting that commit is intentionally immediate.
@ -0,0 +45,4 @@"SELECT ?, ? ""WHERE (SELECT COUNT(*) FROM login_attempts ""WHERE username = ? AND source_ip = ? ""AND attempted_at >= datetime('now', ?)) < ?",code [MEDIUM]
The datetime('now', ?) function in SQLite accepts modifiers like '-900 seconds' but the string format is brittle with negative numbers. Concatenation with f-string creates a dynamic value that may cause SQL injection if window_seconds is not validated as an integer. Since window_seconds is already validated as int >= 1 by pydantic, it's safe, but the pattern is still risky. Consider using a more explicit parameterized query or SQLite's datetime functions with bound parameters.
@ -0,0 +48,4 @@"AND attempted_at >= datetime('now', ?)) < ?",(username, source_ip, username, source_ip, f"-{window_seconds} seconds", limit),) as cursor:recorded = cursor.rowcount == 1code [CRITICAL]
rowcount may be -1 for SELECT statements in some aiosqlite configurations or drivers, which would make the throttle permanently count failures as not recorded (returning False) and incorrectly raising 429, or vice versa. Relying on rowcount for INSERT ... SELECT is fragile and not guaranteed by DB-API 2.0 for all statement types. Should verify the result explicitly by querying the count after the INSERT, or use a different concurrency-safe approach.
@ -0,0 +50,4 @@) as cursor:recorded = cursor.rowcount == 1await db.execute("DELETE FROM login_attempts WHERE attempted_at < datetime('now', ?)",code [MEDIUM]
The DELETE statement is executed but its result is not awaited for a cursor fetch. While aiosqlite can execute write statements without a cursor, the pattern is common and may silently fail on certain exceptions. Explicitly await execute and optionally check rowcount for consistency.
@ -157,0 +184,4 @@if usable:return usable[-1]if request.client is None:raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="source address unavailable")code [MEDIUM]
Raising 400 when source address is unavailable may confuse API clients expecting a throttling-related response. A 400 status is not typical for server-side address resolution issues; 500 Internal Server Error or a specific 503 might be more appropriate, or log and fallback to a safe default if possible.
@ -253,2 +328,2 @@if len(password.encode()) > _MAX_PASSWORD_BYTES:raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="password is too long")password_value = password or ""_validate_credentials(username, password_value)code [LOW]
_validate_credentialsis called but the function is not defined in the diff nor imported. If it is a new validation function, ensure it handles empty/missing password and oversized password correctly. If it is defined elsewhere, no issue. The diff context does not show its definition, which may be a coverage or review gap.@ -255,0 +334,4 @@source_ip=source_ip,window_seconds=settings.login_window_seconds,)if recent_failures >= settings.login_attempt_limit:code [HIGH]
Throttle check uses
>=while the recording logic uses<with the same limit. This means once the limit is reached, the 429 is raised before credential validation, but when_record_failed_loginis called after a failed credential check, it will also raise 429 because it checks< limit(meaning at limit,recordedis False). This double reporting is redundant and could cause confusing error handling. Should consolidate throttle enforcement in one place to avoid duplicate exceptions.@ -64,0 +84,4 @@Settings()except ValidationError as error:raised = Trueexpect(str(error)).to_contain(field)code [LOW]
The test expects a ValidationError but does not validate the exact error message beyond containing the field name, which is acceptable. However, it does not test that the value is rejected (i.e.,
raisedis True). The assertion covers this, but the try/except could be simplified with a context manager for clarity.Wave-3 triage — one fix (
32632ef, tests pinned red in2796ea0; 354 tests green, fulluv run noxDoD passing), nine declines: five duplicate the wave-1/2 answers, three rest on false premises about the code, and one is the documented db-layer convention. Rationale on each below.🔴 Not actioned — The test expects a ValidationError but does not validate the
tests/test_config.pyline 87Duplicate of the wave-2 test_config comment, and your own text concedes the assertion covers the rejection. As before: with no exception,
raisedstaysFalseand the finalexpect(raised).to_be_truthy()fails; this is tryke, not pytest.🔴 Not actioned — The documentation incorrectly lists the default value for XI
README.mdline 110Duplicate of the wave-2 README finding: the README matches the implementation (
true), and the PR description has been corrected to saytrueas well — the contradiction you cite was against the stale description text, which no longer exists.🔴 Not actioned — rowcount may be -1 for SELECT statements in some aiosqlite c
src/xique/db/login_attempts.pyline 49Declined with executed evidence: the statement is
INSERT ... SELECT— DML, not a bare SELECT. Run against this project's aiosqlite:INSERT ... SELECT ? WHERE (SELECT COUNT(*) ...) < 5→ rowcount1; same with< 1→ rowcount0; bareSELECT→-1. Python's sqlite3 derives rowcount fromsqlite3_changes()for INSERT-family statements, and aiosqlite is a thin asyncio wrapper over that module — there are no alternative drivers in play. The suite pins both outcomes (recording is refused once the pair reaches the limit, and every throttle route test).🔴 Not actioned — Commit after every call couples business logic to connection
src/xique/db/login_attempts.pyline 25Declined — committing inside write helpers is the db package's documented convention (
The write helpers commit, so each mutation is—src/xique/db/__init__.pymodule docstring), applied uniformly by users.py, connections.py, media.py, ledger.py, and the pre-PR record/clear helpers.🔴 Not actioned — The DELETE statement is executed but its result is not await
src/xique/db/login_attempts.pyline 53Declined — false premise: the DELETE is
await db.execute(...)(awaited; aiosqlite propagates exceptions through the await). Nothing is fetched because the purge's rowcount has no consumer.🔴 Not actioned — The datetime('now', ?) function in SQLite accepts modifiers
src/xique/db/login_attempts.pyline 46Declined — the modifier string is itself the bound parameter (
datetime('now', ?)); sqlite modifier grammar cannot execute SQL, andwindow_secondsis a pydantic-validated positive int, as you note. There is no injection surface and no unvalidated input.🔴 Not actioned — Throttle check uses
>=while the recording logic uses<src/xique/web/routes/auth.pyline 271Declined — the two 429 sites are not duplicate enforcement, they are the wave-1 race fix itself: the pre-check refuses already-throttled pairs before spending argon2, and the refusal inside recording is the concurrency backstop you asked for in wave 1 (concurrent failures cannot race past the limit — the loser of the race answers 429). Dropping either reopens the pre-check's cost advantage or the recording race; both return the same 429 detail by design.
✅ Fixed — Raising 400 when source address is unavailable may confuse A
src/xique/web/routes/auth.pyline 186Accepted — an unreachable source address is a server/transport misconfiguration, not a client error. The route now answers 500
source address unavailable; pinned bya login with no resolvable source address is rejected with 500(ASGI scope with no client).🔴 Not actioned —
_validate_credentialsis called but the function is not desrc/xique/web/routes/auth.pyline 263False premise:
_validate_credentialsis defined in this same file (src/xique/web/routes/auth.py, alongside the other private helpers), introduced by this PR's original commit before the fix waves — it is outside the fix-wave diff you reviewed but present at HEAD.🔴 Not actioned — Default
cookie_secure = Truemay break out-of-the-box locasrc/xique/config.pyline 49Duplicate of the wave-2 config finding (already answered):
trueis the deliberate secure-by-default fix for wave-1's A05 CRITICAL, and the requested documentation — local development setsXIQUE_COOKIE_SECURE=false— is exactly what the README config table says.WuMing
Found 1 issue(s). See inline comments below.
@ -0,0 +89,4 @@await connection.execute("SELECT 1")except ValueError:closed_cleanly = Trueexpect(closed_cleanly).to_be_truthy()tests [MEDIUM]
Asserting that the connection closed by checking for a ValueError on execute('SELECT 1') is fragile; aiosqlite might raise a different exception (e.g., sqlite3.ProgrammingError) depending on the platform or version, causing the test to fail even though the connection is closed. Replace with a more reliable closed-state check or catch the base Exception and verify the connection property.
Wave-4 triage — single finding, declined with evidence; no code change.
🔴 Not actioned — Asserting that the connection closed by checking for a Value
tests/test_deps.pyline 92🔴 Declined with evidence. The
ValueErroris not incidental — it is aiosqlite's deliberate closed-state signal:core.pyraisesValueError("no active connection")(line 137) andValueError("Connection closed")(line 153), and executing after close on this project's installed version raisesValueError: no active connection(verified by execution). The suggestedclosedproperty does not exist onaiosqlite.Connection(verified: attribute absent), and catching bareExceptionwould weaken the test — any bug would read as "closed". If a future aiosqlite changes the type, this contract test fails loudly and is updated; that is the intended tradeoff.