Login throttling, Secure cookie flag, POST-only logout, and coverage gaps #14

Manually merged
marvin8 merged 8 commits from feat/issue-7-hardening into main 2026-10-16 08:54:50 +00:00
Collaborator

The remaining items from #7, audited against the current code (the worker-side items — transactional migrations, recover_stale_jobs, jobs.status index, complete/fail guards, poll-interval setting — were already absorbed by phases 1b–1d).

Closes #7

What this does

  • Login throttling — migration 4 adds the login_attempts table keyed on (username, source_ip). After XIQUE_LOGIN_ATTEMPT_LIMIT (default 5) failures within XIQUE_LOGIN_WINDOW_SECONDS (default 900), the pair gets 429 too 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.
  • Cookie Secure flag — XIQUE_COOKIE_SECURE (default true) adds Secure to both the session and CSRF cookies; plain-HTTP deployments opt out with false (secure-by-default per review).
  • POST-only logout — GET /logout is gone (405); logout is now a POST carrying the session-bound CSRF token, rendered as a form in the nav of every authenticated page. State changes no longer ride on GET.
  • Test gaps — empty password now reaches the 400 password is required path (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.sig variants) rejected without raising; get_db closes its connection when the handler raises; server.main/worker.main entry-point coverage landed on #13.

Testing

  • TDD, RED first (cf0bb94): 24 new tests across test_auth_hardening.py, test_login_attempts_db.py, test_deps.py.
  • Two existing contract tests updated: the logout test now POSTs with the session CSRF token; the schema-version tests expect migration 4.
  • Full suite 330 passing; uv run nox green except the zaojun/pre-commit sessions that fail on main too (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

  • Wave 1 (19220e3): atomic throttle recording (conditional INSERT ... 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_PROXIES for X-Forwarded-For-based throttling, >= 1 validation on the throttle settings, XIQUE_COOKIE_SECURE default flipped to true.
  • Wave 2 (e49f0b5): recording now purges login_attempts rows older than the window (bounded table), and X-Forwarded-For is honored only when the socket peer is in XIQUE_TRUSTED_PROXIES.

353 tests passing; uv run nox fully green.

The remaining items from #7, audited against the current code (the worker-side items — transactional migrations, `recover_stale_jobs`, `jobs.status` index, complete/fail guards, poll-interval setting — were already absorbed by phases 1b–1d). Closes #7 ## What this does - **Login throttling** — migration 4 adds the `login_attempts` table keyed on `(username, source_ip)`. After `XIQUE_LOGIN_ATTEMPT_LIMIT` (default 5) failures within `XIQUE_LOGIN_WINDOW_SECONDS` (default 900), the pair gets 429 `too 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. - **Cookie `Secure` flag** — `XIQUE_COOKIE_SECURE` (default `true`) adds `Secure` to both the session and CSRF cookies; plain-HTTP deployments opt out with `false` (secure-by-default per review). - **POST-only logout** — `GET /logout` is gone (405); logout is now a `POST` carrying the session-bound CSRF token, rendered as a form in the nav of every authenticated page. State changes no longer ride on GET. - **Test gaps** — empty password now reaches the 400 `password is required` path (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.sig` variants) rejected without raising; `get_db` closes its connection when the handler raises; `server.main`/`worker.main` entry-point coverage landed on #13. ## Testing - TDD, RED first (`cf0bb94`): 24 new tests across `test_auth_hardening.py`, `test_login_attempts_db.py`, `test_deps.py`. - Two existing contract tests updated: the logout test now POSTs with the session CSRF token; the schema-version tests expect migration 4. - Full suite 330 passing; `uv run nox` green except the zaojun/pre-commit sessions that fail on `main` too (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 - Wave 1 (`19220e3`): atomic throttle recording (conditional `INSERT ... 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_PROXIES` for `X-Forwarded-For`-based throttling, `>= 1` validation on the throttle settings, `XIQUE_COOKIE_SECURE` default flipped to `true`. - Wave 2 (`e49f0b5`): recording now purges `login_attempts` rows older than the window (bounded table), and `X-Forwarded-For` is honored only when the socket peer is in `XIQUE_TRUSTED_PROXIES`. 353 tests passing; `uv run nox` fully green.
Settings fields XIQUE_COOKIE_SECURE / XIQUE_LOGIN_ATTEMPT_LIMIT /
XIQUE_LOGIN_WINDOW_SECONDS plus a login_attempts store stub; failing tests
pin the Secure cookie flags, POST-only logout with session-bound CSRF, the
throttled login flow, migration 3, get_db close-on-exception, malformed
CSRF cookie halves, and empty/unicode password handling.
✨ feat: cookie Secure flag, POST-only logout, and login throttling
Some checks failed
/ pr-review (pull_request) Successful in 6s
/ gitleaks (pull_request) Successful in 23s
/ checks (pull_request) Failing after 3m23s
/ container (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
a06c6e91ab
Migration 4 adds the login_attempts table keyed on (username, source_ip).
Login failures inside XIQUE_LOGIN_WINDOW_SECONDS throttle the pair with 429
after XIQUE_LOGIN_ATTEMPT_LIMIT failures; a successful login resets the
count. XIQUE_COOKIE_SECURE adds the Secure flag to session and CSRF cookies
for TLS deployments. Logout moves from GET to a CSRF-verified POST rendered
as a form on every authenticated page; empty passwords now hit the 400
validation path instead of a 422 form error.
agent-pi force-pushed feat/issue-7-hardening from a06c6e91ab
Some checks failed
/ pr-review (pull_request) Successful in 6s
/ gitleaks (pull_request) Successful in 23s
/ checks (pull_request) Failing after 3m23s
/ container (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
to 5140c5f239
All checks were successful
/ gitleaks (pull_request) Successful in 29s
/ pr-review (pull_request) Successful in 31s
/ checks (pull_request) Successful in 4m52s
/ container (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
2026-10-15 07:44:01 +00:00
Compare
agent-pi force-pushed feat/issue-7-hardening from 5140c5f239
All checks were successful
/ gitleaks (pull_request) Successful in 29s
/ pr-review (pull_request) Successful in 31s
/ checks (pull_request) Successful in 4m52s
/ container (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
to 2f21d015d5
All checks were successful
/ pr-review (pull_request) Successful in 6s
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 3m25s
/ container (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
2026-10-15 08:06:25 +00:00
Compare
agent-pi force-pushed feat/issue-7-hardening from 2f21d015d5
All checks were successful
/ pr-review (pull_request) Successful in 6s
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 3m25s
/ container (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
to 2fffbea100
All checks were successful
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 3m29s
/ container (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 7m20s
2026-10-16 07:26:08 +00:00
Compare
forgejo-actions left a comment

WuMing

Found 9 issue(s). See inline comments below.

## WuMing 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 = None
cookie_secure: bool = False

security [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.

**security** [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. <!-- wuming:sha256:bf61d91d128925d4043310888a57aabfda00291e02adb7d560183db5c3817ed1 -->
@ -40,2 +43,4 @@
log_level: str = "INFO"
log_file: Path | None = None
cookie_secure: bool = False
login_attempt_limit: int = 5

code [LOW]

login_attempt_limit and login_window_seconds have no validation constraints. A value of 0 or a negative number could disable the throttle or cause SQL errors. Add field_validator to enforce reasonable bounds.

**code** [LOW] `login_attempt_limit` and `login_window_seconds` have no validation constraints. A value of 0 or a negative number could disable the throttle or cause SQL errors. Add `field_validator` to enforce reasonable bounds. <!-- wuming:sha256:d076674c4dddd532752d743a636169b277176b1510f3f0eb08a414a2cd2ba2f6 -->
marvin8 marked this conversation as resolved
@ -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" when request.client is 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.

**code** [LOW] Fallback to `"unknown"` when `request.client` is 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. <!-- wuming:sha256:2d23e1f72cb4b89bbf74e606590fcbebac0169e55de0ad1472b1cd90724bbaaf -->

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).

**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). <!-- wuming:sha256:2d23e1f72cb4b89bbf74e606590fcbebac0169e55de0ad1472b1cd90724bbaaf -->
@ -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.

**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. <!-- wuming:sha256:3d4c735d2ddee0fcfc31faf2c81e3fed321bc97c34804f31f8bc8540ad5947df -->
marvin8 marked this conversation as resolved
@ -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.

**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. <!-- wuming:sha256:ad8130256e83faa8a52aa6be61ac2366c5d7d004d6c9e65cf16c884e7943ad6e -->
@ -288,3 +321,4 @@
verify_csrf(request, csrf_token or "")
settings: Settings = request.app.state.settings
token = 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 secure flag. If cookie_secure is enabled, the browser may ignore the deletion instruction for the secure cookie, rendering logout ineffective. Add secure=settings.cookie_secure to the response.delete_cookie() call.

**code** [HIGH] The session cookie deletion (in the code following this line) does not appear to include the `secure` flag. If `cookie_secure` is enabled, the browser may ignore the deletion instruction for the secure cookie, rendering logout ineffective. Add `secure=settings.cookie_secure` to the `response.delete_cookie()` call. <!-- wuming:sha256:f7cdb9526e854eda0a74ed8991d4b46eb8172037068b239f0ed37bc853f9c0f6 -->
marvin8 marked this conversation as resolved
@ -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.

**code** [LOW] Docstring mentions 'Migration 3' but this table is created by migration 4. <!-- wuming:sha256:818303ad909d503a4d40ced6c438416643d5b6a7be742b6e1d917c68c9cfe81c -->
@ -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.

**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. <!-- wuming:sha256:568ed21707e5ccaf7c171df398bf71db218dc8fa6055775817c3ee1879694828 -->
🐛 fix: atomic throttle recording, secure logout cookie, and hardened defaults
All checks were successful
/ gitleaks (pull_request) Successful in 27s
/ pr-review (pull_request) Successful in 3m17s
/ checks (pull_request) Successful in 3m26s
/ container (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
19220e3d6e
Author
Collaborator

Addressing all 9 findings from this review — fixes in 19220e3, tests pinned red in 5d05da2 (351 tests green, full uv run nox DoD passing).

✅ Fixed — A correct password for a disabled account

src/xique/web/routes/auth.py line 282

Correct password for a disabled account is treated as a failed login and recorded, which may permanently throttle a legi…

The 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.py line 264

Fallback to "unknown" when request.client is missing may group all such requests under a single throttle bucket, ena…

Requests 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.py line 264

security [HIGH]…

New XIQUE_TRUST_PROXY_HEADERS setting, default false. When enabled, the throttle address is the rightmost X-Forwarded-For entry — 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.py line 271

The throttle check is not atomic: concurrent requests can all see a failure count below the limit and proceed to passwor…

Recording is now a single conditional INSERT ... SELECT that counts the window inside the same statement. SQLite serialises write statements, so concurrent recorders each see exactly the failures committed before them; once limit is reached the insert is refused and the route answers 429. Pinned by recording is refused once the pair reaches the limit.


src/xique/web/routes/auth.py line 285

The session cookie deletion (in the code following this line) does not appear to include the secure flag. If `cookie_s…

The 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_limit and login_window_seconds have no validation

src/xique/config.py line 46

login_attempt_limit and login_window_seconds have no validation constraints. A value of 0 or a negative number could…

A field_validator now rejects either setting below 1 with ValueError, parameterised over both fields.


✅ Fixed — A05: Security Misconfiguration

src/xique/config.py line 45

security [CRITICAL]…

cookie_secure now defaults to true; plain-HTTP deployments opt out with XIQUE_COOKIE_SECURE=false, documented in the README config table.


✅ Fixed — Docstring mentions 'Migration 3'

tests/test_login_attempts_db.py line 4

Docstring mentions 'Migration 3' but this table is created by migration 4.…

Docstring corrected to migration 4.


✅ Fixed — SQL update uses string formatting

tests/test_login_attempts_db.py line 68

SQL update uses string formatting instead of parameterized placeholders, which sets a poor example. While safe in this t…

The test UPDATE now uses ? placeholders.

Addressing all 9 findings from this review — fixes in `19220e3`, tests pinned red in `5d05da2` (351 tests green, full `uv run nox` DoD passing). ### ✅ Fixed — A correct password for a disabled account [`src/xique/web/routes/auth.py` line 282](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5487) > Correct password for a disabled account is treated as a failed login and recorded, which may permanently throttle a legi… The 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.py` line 264](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5488) > Fallback to `"unknown"` when `request.client` is missing may group all such requests under a single throttle bucket, ena… Requests 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.py` line 264](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5493) > **security** [HIGH]… New `XIQUE_TRUST_PROXY_HEADERS` setting, default `false`. When enabled, the throttle address is the rightmost `X-Forwarded-For` entry — 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.py` line 271](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5485) > The throttle check is not atomic: concurrent requests can all see a failure count below the limit and proceed to passwor… Recording is now a single conditional `INSERT ... SELECT` that counts the window inside the same statement. SQLite serialises write statements, so concurrent recorders each see exactly the failures committed before them; once `limit` is reached the insert is refused and the route answers 429. Pinned by `recording is refused once the pair reaches the limit`. --- ### ✅ Fixed — The session cookie deletion [`src/xique/web/routes/auth.py` line 285](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5486) > The session cookie deletion (in the code following this line) does not appear to include the `secure` flag. If `cookie_s… The 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_limit` and `login_window_seconds` have no validation [`src/xique/config.py` line 46](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5489) > `login_attempt_limit` and `login_window_seconds` have no validation constraints. A value of 0 or a negative number could… A `field_validator` now rejects either setting below 1 with `ValueError`, parameterised over both fields. --- ### ✅ Fixed — A05: Security Misconfiguration [`src/xique/config.py` line 45](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5492) > **security** [CRITICAL]… `cookie_secure` now defaults to `true`; plain-HTTP deployments opt out with `XIQUE_COOKIE_SECURE=false`, documented in the README config table. --- ### ✅ Fixed — Docstring mentions 'Migration 3' [`tests/test_login_attempts_db.py` line 4](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5490) > Docstring mentions 'Migration 3' but this table is created by migration 4.… Docstring corrected to migration 4. --- ### ✅ Fixed — SQL update uses string formatting [`tests/test_login_attempts_db.py` line 68](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5491) > SQL update uses string formatting instead of parameterized placeholders, which sets a poor example. While safe in this t… The test `UPDATE` now uses `?` placeholders.
forgejo-actions left a comment

WuMing

Found 8 issue(s). See inline comments below.

## 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.

**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. <!-- wuming:sha256:9e05646e3e2e331ee8f447a21ad0809a4c45f55548a9ed5837f36014f7fd2242 -->
marvin8 marked this conversation as resolved
@ -39,6 +46,10 @@ class Settings(BaseSettings):
import_dir: Path = Path(tempfile.gettempdir()) / "xique-imports"
log_level: str = "INFO"
log_file: Path | None = None
cookie_secure: bool = True

code [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'.

**code** [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'. <!-- wuming:sha256:f34b8b0ea472322f79694858f699414ea1837c4b1b59bf09ef0b86d08434c94f -->
marvin8 marked this conversation as resolved
@ -0,0 +1,93 @@
# SPDX-License-Identifier: AGPL-3.0-or-later

code [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).

**code** [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). <!-- wuming:sha256:308fe2b303b9d1890de8c0bfcf592ef4a0ce41266acf9e4be3dde945f674aab5 -->
marvin8 marked this conversation as resolved
@ -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).

**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). <!-- wuming:sha256:2977279ecf132e0130c0c39d6cb86a7ddb86e7190d1ebffc4726d38e285e5906 -->
@ -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.

**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. <!-- wuming:sha256:0acb2b6f5b9f8d7e526d96e07095f007de41ee7ea6abedd8ce9aa93266b4dc00 -->
marvin8 marked this conversation as resolved
@ -0,0 +1,494 @@
# SPDX-License-Identifier: AGPL-3.0-or-later

tests [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.

**tests** [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. <!-- wuming:sha256:bc32565e2691567ed285d82125ecc5c384fb67b859ea94079030ccf9f6522f3f -->
@ -64,0 +85,4 @@
except ValidationError as error:
raised = True
expect(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 but raised remained 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.

**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 but `raised` remained 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. <!-- wuming:sha256:6563348619eef330b530f7c62bff018f94b6e8e84d1fd288390f71c9a8856f90 -->
@ -0,0 +84,4 @@
raised = True
expect(raised).to_be_truthy()
closed_cleanly = False

tests [MEDIUM]

Boolean flags closed_cleanly and raised are 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 but closed_cleanly is never set to True). Consider using context managers like pytest.raises or direct exception assertion patterns to make the tests fail explicitly when expected exceptions do not occur.

**tests** [MEDIUM] Boolean flags `closed_cleanly` and `raised` are 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 but `closed_cleanly` is never set to True). Consider using context managers like `pytest.raises` or direct exception assertion patterns to make the tests fail explicitly when expected exceptions do not occur. <!-- wuming:sha256:0b58d545efb35fcbd9bdef132beac8ba0eccfaffed14affe047ffa33302b7341 -->
🐛 fix: purge stale throttle rows and require trusted proxy peers
All checks were successful
/ gitleaks (pull_request) Successful in 13s
/ checks (pull_request) Successful in 3m29s
/ container (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 4m52s
e49f0b5429
Author
Collaborator

Wave-2 findings — fixes in e49f0b5, tests pinned red in 9c551b0 (353 tests green, full uv run nox DoD passing). The two 🔴 refusals keep the deliberate secure-by-default behaviour from wave 1 and the anti-enumeration ordering; rationale on each.

src/xique/config.py line 49

Default value for 'cookie_secure' is True, but the pull request description states it should be False. This mismatch wil…

Default flipped to true deliberately — 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 with XIQUE_COOKIE_SECURE=false, documented in the README. The stale text was the PR description, which now says true.


✅ Fixed — There is no maintenance logic to purge old rows from the 'lo

src/xique/db/login_attempts.py line 1

There is no maintenance logic to purge old rows from the 'login_attempts' table. Over time the table will grow indefinit…

Recording 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 by recording purges failures older than the window.


🔴 Not actioned — The default value in the table ('true') contradicts the PR d

README.md line 110

The default value in the table ('true') contradicts the PR description, which explicitly states that XIQUE_COOKIE_SECURE…

Same substance as the config finding: true is the deliberate secure-by-default fix for wave-1's A05 CRITICAL. The README and the PR description (now corrected) agree on true with a documented false opt-out.


🔴 Not actioned — A04: The disabled account check occurs after password verifi

src/xique/web/routes/auth.py line 256

A04: The disabled account check occurs after password verification (line 343). This creates a timing side-channel that c…

Declined. 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 password for 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.py line 179

A05: When trust_proxy_headers is enabled, the application accepts X-Forwarded-For headers to determine the client IP for…

XIQUE_TRUST_PROXY_HEADERS (bool) is replaced by XIQUE_TRUSTED_PROXIES — a comma-separated list of peer addresses allowed to assert X-Forwarded-For. The header is honored only when the socket peer is in that list; empty (the default) disables proxy-header trust entirely. Pinned by X-Forwarded-For is ignored when the peer is not a trusted proxy (peer 192.0.2.77 not 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.py line 1

New test file test_auth_hardening.py was added but its contents are truncated in the diff (495 lines). Cannot verify ass…

Informational. 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 Validation

tests/test_config.py line 88

Assertion expect(raised).to_be_truthy() after a ValidationError catch relies on a boolean flag. The test would still p…

Declined — the premise is incorrect. If ValidationError is never raised, raised stays False and the final expect(raised).to_be_truthy() fails, so the test does fail when the exception is missing; a non-ValidationError exception 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_cleanly and raised are used to trac

tests/test_deps.py line 87

Boolean flags closed_cleanly and raised are used to track state within try/except blocks. If the code path does not …

Declined — same premise issue as the test_config comment. closed_cleanly stays False when the expected ValueError never occurs, so the final expect(closed_cleanly).to_be_truthy() fails; a different exception type propagates and fails loudly. The flags cannot produce a false pass.

Wave-2 findings — fixes in `e49f0b5`, tests pinned red in `9c551b0` (353 tests green, full `uv run nox` DoD 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.py` line 49](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5497) > Default value for 'cookie_secure' is True, but the pull request description states it should be False. This mismatch wil… Default flipped to `true` deliberately — 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 with `XIQUE_COOKIE_SECURE=false`, documented in the README. The stale text was the PR description, which now says `true`. --- ### ✅ Fixed — There is no maintenance logic to purge old rows from the 'lo [`src/xique/db/login_attempts.py` line 1](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5498) > There is no maintenance logic to purge old rows from the 'login_attempts' table. Over time the table will grow indefinit… Recording 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 by `recording purges failures older than the window`. --- ### 🔴 Not actioned — The default value in the table ('true') contradicts the PR d [`README.md` line 110](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5499) > The default value in the table ('true') contradicts the PR description, which explicitly states that XIQUE_COOKIE_SECURE… Same substance as the config finding: `true` is the deliberate secure-by-default fix for wave-1's A05 CRITICAL. The README and the PR description (now corrected) agree on `true` with a documented `false` opt-out. --- ### 🔴 Not actioned — A04: The disabled account check occurs after password verifi [`src/xique/web/routes/auth.py` line 256](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5501) > A04: The disabled account check occurs after password verification (line 343). This creates a timing side-channel that c… Declined. 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 password` for 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.py` line 179](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5500) > A05: When trust_proxy_headers is enabled, the application accepts X-Forwarded-For headers to determine the client IP for… `XIQUE_TRUST_PROXY_HEADERS` (bool) is replaced by `XIQUE_TRUSTED_PROXIES` — a comma-separated list of peer addresses allowed to assert `X-Forwarded-For`. The header is honored only when the socket peer is in that list; empty (the default) disables proxy-header trust entirely. Pinned by `X-Forwarded-For is ignored when the peer is not a trusted proxy` (peer `192.0.2.77` not 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.py` line 1](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5502) > New test file test_auth_hardening.py was added but its contents are truncated in the diff (495 lines). Cannot verify ass… Informational. 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 Validation [`tests/test_config.py` line 88](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5503) > Assertion `expect(raised).to_be_truthy()` after a ValidationError catch relies on a boolean flag. The test would still p… Declined — the premise is incorrect. If `ValidationError` is never raised, `raised` stays `False` and the final `expect(raised).to_be_truthy()` fails, so the test does fail when the exception is missing; a non-`ValidationError` exception 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_cleanly` and `raised` are used to trac [`tests/test_deps.py` line 87](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5504) > Boolean flags `closed_cleanly` and `raised` are used to track state within try/except blocks. If the code path does not … Declined — same premise issue as the test_config comment. `closed_cleanly` stays `False` when the expected `ValueError` never occurs, so the final `expect(closed_cleanly).to_be_truthy()` fails; a different exception type propagates and fails loudly. The flags cannot produce a false pass.
forgejo-actions left a comment

WuMing

Found 10 issue(s). See inline comments below.

## 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.

**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. <!-- wuming:sha256:ca6335f17a899ed28a88677045d1ac5dd4269d03f7829a378c1eb13bae303551 -->
marvin8 marked this conversation as resolved
@ -39,6 +46,10 @@ class Settings(BaseSettings):
import_dir: Path = Path(tempfile.gettempdir()) / "xique-imports"
log_level: str = "INFO"
log_file: Path | None = None
cookie_secure: bool = True

code [LOW]

Default cookie_secure = True may 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.

**code** [LOW] Default `cookie_secure = True` may 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. <!-- wuming:sha256:144f67a82d9d54806f8a4453030fc0c681c22e70a1099d38bda76b07c7356246 -->
marvin8 marked this conversation as resolved
@ -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.

**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. <!-- wuming:sha256:187df3485f76a17556fba86cc0cfcec564fa97dfdcd70edd0ca99ab2bb36c6c2 -->
marvin8 marked this conversation as resolved
@ -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.

**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. <!-- wuming:sha256:c7876b005af74f81fb2013cdf100d8e2627ea165287d44950fbbdab087b1c8bf -->
marvin8 marked this conversation as resolved
@ -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 == 1

code [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.

**code** [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. <!-- wuming:sha256:4900899244d5bad8400821bb429c0f5a2e8f732be1b62377a8a6f8ec55bb7dec -->
marvin8 marked this conversation as resolved
@ -0,0 +50,4 @@
) as cursor:
recorded = cursor.rowcount == 1
await 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.

**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. <!-- wuming:sha256:cf93260a8c8108746135379bbde3a7377bb2acbd3a9ac0821e4c9fc2e2ea7f2e -->
marvin8 marked this conversation as resolved
@ -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.

**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. <!-- wuming:sha256:87571b7a1767646b41a066642cf8b7a301ec8e5c5a25a91b0752cd948127ad16 -->
@ -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_credentials is 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.

**code** [LOW] `_validate_credentials` is 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. <!-- wuming:sha256:556d69226cc3d966fb6a7779d94eebdc3cf4250f41736482eba696c97ff37379 -->
marvin8 marked this conversation as resolved
@ -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_login is called after a failed credential check, it will also raise 429 because it checks < limit (meaning at limit, recorded is False). This double reporting is redundant and could cause confusing error handling. Should consolidate throttle enforcement in one place to avoid duplicate exceptions.

**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_login` is called after a failed credential check, it will also raise 429 because it checks `< limit` (meaning at limit, `recorded` is False). This double reporting is redundant and could cause confusing error handling. Should consolidate throttle enforcement in one place to avoid duplicate exceptions. <!-- wuming:sha256:d7f1dcbf7cd5b102feb122b33d3aaffe9266f009e71bc6f66d1edec80f72f213 -->
marvin8 marked this conversation as resolved
@ -64,0 +84,4 @@
Settings()
except ValidationError as error:
raised = True
expect(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., raised is True). The assertion covers this, but the try/except could be simplified with a context manager for clarity.

**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., `raised` is True). The assertion covers this, but the try/except could be simplified with a context manager for clarity. <!-- wuming:sha256:9b90c25d82919dafc7c8b1c2546aa6475b86f456c240d21d303de5d6cd6d1c75 -->
🐛 fix: answer 500 when the login throttle has no source address
All checks were successful
/ gitleaks (pull_request) Successful in 28s
/ pr-review (pull_request) Successful in 2m32s
/ checks (pull_request) Successful in 3m29s
/ container (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
32632eff21
Author
Collaborator

Wave-3 triage — one fix (32632ef, tests pinned red in 2796ea0; 354 tests green, full uv run nox DoD 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.py line 87

The test expects a ValidationError but does not validate the exact error message beyond containing the field name, which…

Duplicate of the wave-2 test_config comment, and your own text concedes the assertion covers the rejection. As before: with no exception, raised stays False and the final expect(raised).to_be_truthy() fails; this is tryke, not pytest.


🔴 Not actioned — The documentation incorrectly lists the default value for XI

README.md line 110

The documentation incorrectly lists the default value for XIQUE_COOKIE_SECURE as true. The pull request description expl…

Duplicate of the wave-2 README finding: the README matches the implementation (true), and the PR description has been corrected to say true as 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.py line 49

rowcount may be -1 for SELECT statements in some aiosqlite configurations or drivers, which would make the throttle perm…

Declined 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 → rowcount 1; same with < 1 → rowcount 0; bare SELECT → -1. Python's sqlite3 derives rowcount from sqlite3_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.py line 25

Commit after every call couples business logic to connection-level transactions and can interfere with a larger request-…

Declined — committing inside write helpers is the db package's documented convention (The write helpers commit, so each mutation is — src/xique/db/__init__.py module 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.py line 53

The DELETE statement is executed but its result is not awaited for a cursor fetch. While aiosqlite can execute write sta…

Declined — 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.py line 46

The datetime('now', ?) function in SQLite accepts modifiers like '-900 seconds' but the string format is brittle with ne…

Declined — the modifier string is itself the bound parameter (datetime('now', ?)); sqlite modifier grammar cannot execute SQL, and window_seconds is 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.py line 271

Throttle check uses >= while the recording logic uses < with the same limit. This means once the limit is reached, t…

Declined — 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.py line 186

Raising 400 when source address is unavailable may confuse API clients expecting a throttling-related response. A 400 st…

Accepted — an unreachable source address is a server/transport misconfiguration, not a client error. The route now answers 500 source address unavailable; pinned by a login with no resolvable source address is rejected with 500 (ASGI scope with no client).


🔴 Not actioned — _validate_credentials is called but the function is not de

src/xique/web/routes/auth.py line 263

_validate_credentials is called but the function is not defined in the diff nor imported. If it is a new validation fu…

False premise: _validate_credentials is 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.


src/xique/config.py line 49

Default cookie_secure = True may break out-of-the-box local development without TLS. The PR description says default i…

Duplicate of the wave-2 config finding (already answered): true is the deliberate secure-by-default fix for wave-1's A05 CRITICAL, and the requested documentation — local development sets XIQUE_COOKIE_SECURE=false — is exactly what the README config table says.

Wave-3 triage — one fix (`32632ef`, tests pinned red in `2796ea0`; 354 tests green, full `uv run nox` DoD 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.py` line 87](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5516) > The test expects a ValidationError but does not validate the exact error message beyond containing the field name, which… Duplicate of the wave-2 test_config comment, and your own text concedes the assertion covers the rejection. As before: with no exception, `raised` stays `False` and the final `expect(raised).to_be_truthy()` fails; this is tryke, not pytest. --- ### 🔴 Not actioned — The documentation incorrectly lists the default value for XI [`README.md` line 110](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5517) > The documentation incorrectly lists the default value for XIQUE_COOKIE_SECURE as true. The pull request description expl… Duplicate of the wave-2 README finding: the README matches the implementation (`true`), and the PR description has been corrected to say `true` as 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.py` line 49](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5508) > rowcount may be -1 for SELECT statements in some aiosqlite configurations or drivers, which would make the throttle perm… Declined 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` → rowcount `1`; same with `< 1` → rowcount `0`; bare `SELECT` → `-1`. Python's sqlite3 derives rowcount from `sqlite3_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.py` line 25](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5509) > Commit after every call couples business logic to connection-level transactions and can interfere with a larger request-… Declined — committing inside write helpers is the db package's documented convention (`The write helpers commit, so each mutation is` — `src/xique/db/__init__.py` module 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.py` line 53](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5510) > The DELETE statement is executed but its result is not awaited for a cursor fetch. While aiosqlite can execute write sta… Declined — 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.py` line 46](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5513) > The datetime('now', ?) function in SQLite accepts modifiers like '-900 seconds' but the string format is brittle with ne… Declined — the modifier string is itself the bound parameter (`datetime('now', ?)`); sqlite modifier grammar cannot execute SQL, and `window_seconds` is 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.py` line 271](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5511) > Throttle check uses `>=` while the recording logic uses `<` with the same limit. This means once the limit is reached, t… Declined — 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.py` line 186](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5512) > Raising 400 when source address is unavailable may confuse API clients expecting a throttling-related response. A 400 st… Accepted — an unreachable source address is a server/transport misconfiguration, not a client error. The route now answers 500 `source address unavailable`; pinned by `a login with no resolvable source address is rejected with 500` (ASGI scope with no client). --- ### 🔴 Not actioned — `_validate_credentials` is called but the function is not de [`src/xique/web/routes/auth.py` line 263](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5515) > `_validate_credentials` is called but the function is not defined in the diff nor imported. If it is a new validation fu… False premise: `_validate_credentials` is 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 = True` may break out-of-the-box loca [`src/xique/config.py` line 49](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5514) > Default `cookie_secure = True` may break out-of-the-box local development without TLS. The PR description says default i… Duplicate of the wave-2 config finding (already answered): `true` is the deliberate secure-by-default fix for wave-1's A05 CRITICAL, and the requested documentation — local development sets `XIQUE_COOKIE_SECURE=false` — is exactly what the README config table says.
forgejo-actions left a comment

WuMing

Found 1 issue(s). See inline comments below.

## WuMing Found **1** issue(s). See inline comments below.
@ -0,0 +89,4 @@
await connection.execute("SELECT 1")
except ValueError:
closed_cleanly = True
expect(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.

**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. <!-- wuming:sha256:1adddce2eba0d58e2c645bc4e81a4a9dd90dfde5646dba8bde8d2be442dcfebe -->
Author
Collaborator

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.py line 92

Asserting that the connection closed by checking for a ValueError on execute('SELECT 1') is fragile; aiosqlite might rai…

🔴 Declined with evidence. The ValueError is not incidental — it is aiosqlite's deliberate closed-state signal: core.py raises ValueError("no active connection") (line 137) and ValueError("Connection closed") (line 153), and executing after close on this project's installed version raises ValueError: no active connection (verified by execution). The suggested closed property does not exist on aiosqlite.Connection (verified: attribute absent), and catching bare Exception would 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.

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.py` line 92](https://forge.marvin8.zone/marvin8/xique/pulls/14#issuecomment-5521) > Asserting that the connection closed by checking for a ValueError on execute('SELECT 1') is fragile; aiosqlite might rai… 🔴 Declined with evidence. The `ValueError` is not incidental — it is aiosqlite's deliberate closed-state signal: `core.py` raises `ValueError("no active connection")` (line 137) and `ValueError("Connection closed")` (line 153), and executing after close on this project's installed version raises `ValueError: no active connection` (verified by execution). The suggested `closed` property does not exist on `aiosqlite.Connection` (verified: attribute absent), and catching bare `Exception` would 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.
marvin8 approved these changes 2026-10-16 08:52:29 +00:00
marvin8 manually merged commit 7164ad6f2d into main 2026-10-16 08:54:50 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
marvin8/xique!14
No description provided.