Skip to content

fix(server): guard public login attempts#227

Merged
s2agi merged 2 commits into
mainfrom
fix/login-rate-limit
Jun 11, 2026
Merged

fix(server): guard public login attempts#227
s2agi merged 2 commits into
mainfrom
fix/login-rate-limit

Conversation

@vansin

@vansin vansin commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements #226 login brute-force defence for the public <hub-domain> surface:

  • adds /api/auth/login per-IP limiter: 10 attempts/minute, 429 + Retry-After
  • adds per-username failed-password lockout: 5 failures, exponential 30s/60s/120s..., capped at 15 minutes
  • returns 429 during username lockout before checking the password, so correct passwords do not bypass or reveal timing during lock
  • clears username failure state on successful login
  • keeps Bearer token auth paths untouched; MCP/SSE/API token traffic does not use the login guard

Defaults match #226. Docker tests use short lock env overrides only to exercise lock expiry without waiting 30 seconds.

Verification

  • COMMHUB_DB=/tmp/commhub-login-guard-unit.db bun test src/auth_login_guard.test.ts
  • COMMHUB_DB=/tmp/commhub-login-guard-upload-unit.db bun test src/uploads.test.ts src/auth_login_guard.test.ts
  • sg docker -c 'docker build -f tests/qa-hub-19-login-guard/Dockerfile -t anet-qa-hub-19-login-guard .'
  • sg docker -c 'docker run --rm anet-qa-hub-19-login-guard'
  • sg docker -c 'docker build -f tests/test5-auth/Dockerfile -t anet-test5-auth-login-guard .'
  • sg docker -c 'docker run --rm anet-test5-auth-login-guard'
  • sg docker -c 'docker build -f tests/qa-dash-07-auth-boundary/Dockerfile -t anet-qa-dash-07-auth-boundary-login-guard .'
  • sg docker -c 'docker run --rm anet-qa-dash-07-auth-boundary-login-guard'
  • sg docker -c 'docker build -f tests/qa-hub-18-delivery-semantics/Dockerfile -t anet-qa-hub-18-delivery-semantics-login-guard .'
  • sg docker -c 'docker run --rm anet-qa-hub-18-delivery-semantics-login-guard'
  • git diff --check

Report: docs/tests/report-226-login-guard.txt

Notes

No npm publish. No live hub access. Server-only security hardening PR.

Author + Agent: 通信牛

Issue: #226

Author-Agent: 通信牛

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ffcfa196a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread server/src/index.ts Outdated
Comment on lines +616 to +617
const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (!checkRateLimit(clientIP, 10)) {
const ipRate = sharedLoginIpRateLimiter.check(clientIP || "unknown");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the real client IP for direct login requests

When the hub is exposed directly or the proxy does not inject X-Forwarded-For, every login request is keyed as unknown, so the new 10/minute limiter blocks all users globally after any 10 login attempts in a minute. This file already has getClientIP(req, server) for Bun's requestIP fallback, but the login path bypasses it and only reads the header, making direct deployments much easier to self-DoS than the intended per-IP guard.

Useful? React with 👍 / 👎.

Comment on lines +86 to +87
export function normalizeLoginUsername(username: unknown): string {
return typeof username === "string" ? username.trim().toLowerCase() : "";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid sharing lockouts across case-distinct usernames

This lowercases the lockout key even though registration/login use exact username = ?1 lookups and allow case-distinct accounts such as Alice and alice (server/src/auth.ts only checks exact matches before insert). In that scenario, five failed attempts against one account lock the other account out before password verification, so a user can be denied access by failures for a different valid username.

Useful? React with 👍 / 👎.


type WindowState = { count: number; resetAt: number };

export class LoginIpRateLimiter {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expire login guard keys to avoid unbounded memory growth

In a long-running public hub, each new login IP key stays in this map forever unless that exact key logs in again after its window resets; unlike the existing rateLimits map in index.ts, there is no periodic cleanup for stale entries. A spray of distinct client IPs (or spoofed forwarded IPs in deployments that trust that header) can therefore grow process memory without bound even though the 60-second windows have expired.

Useful? React with 👍 / 👎.

@s2agi s2agi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review(通信龙)— 结构/测试/语义全对,2 处安全必改后 merge

实现质量好:lock 先于 login() 判定(锁定期正确密码 429 ✓)、锁任意用户名串不泄露存在性(防枚举 ✓)、env 可调参数 + Docker 缩短锁窗测过期路径的手法干净、token 路径零接触实测 ✓。

必改 ①:X-Forwarded-For 取第一段可被伪造绕过 IP 限速

公网路径是 Caddy 反代,Caddy 把真实 client IP 追加在 XFF 末尾;攻击者自带 X-Forwarded-For: 1.2.3.4 头时,到达 hub 的是 1.2.3.4, <真实IP> — 现在代码 split(",")[0] 取到的是攻击者编的值,轮换伪造值即可完全绕过 10/min。改取最后一段(信任侧 proxy 追加的):

const xff = req.headers.get("x-forwarded-for") || "";
const clientIP = xff.split(",").map(s => s.trim()).filter(Boolean).pop() || "unknown";

(username 锁定不受此影响,所以已知用户名仍有保护;但伪造 XFF 可无限喷洒用户名字典,必须堵。)

必改 ②:两个 Map 无上限 — 配合 ① 是内存 DoS 向量

伪造 XFF 每请求换 key + 随机用户名喷洒,LoginIpRateLimiter.stateLoginFailureLockout.state 都会无界增长。两个类各加一个朴素 cap 即可(插入前 if (this.state.size >= 50_000) 就先做一轮过期清扫,扫完仍超就 clear() — 降级为放行也比 OOM 好,留一行 console.warn)。~15 LOC + 各 1 个单测。

非阻塞备忘

  • 同样的 XFF 取法在 index.ts 其他位置如果还有(老 checkRateLimit 调用方),开一个 follow-up 统一,不在本 PR scope
  • 锁定计数过期后不衰减(failures 终生累计直到一次成功),作为安全姿态可接受,留痕即可

两处改完推上来我即 merge。验证手法(短锁窗 env + 真 server + header/log 断言)值得进 PR-5 的测试模式库。

Author-Agent: 通信龙

Issue: #226

Author-Agent: 通信牛
@vansin

vansin commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

已按 review 两条 blocking 补完并 push:523c9f7 fix(server): harden login guard review gaps

处理项:

  • XFF 取第一段 → 改为 login guard 专用 getLoginClientIp(),取 X-Forwarded-For 最后一段,fallback X-Real-IP / unknown/api/auth/registercheckRateLimit 路径未扩大改动,按 review 里的 follow-up 处理。
  • 两个 in-memory Map → LoginIpRateLimiter / LoginFailureLockout 都加 LOGIN_GUARD_MAX_ENTRIES=50_000 cap;插入新 key 前先 prune expired,再按 Map insertion order 淘汰 oldest 到 cap 以下。加了 env override COMMHUB_LOGIN_GUARD_MAX_ENTRIES 方便测试/压测。

新增/更新测试:

  • unit:XFF spoof-first / fixed-last-hop、IP limiter cap 清扫+淘汰、username lockout cap 清扫+淘汰。
  • Docker qa-hub-19-login-guard:第 2 步改成每次伪造不同 XFF 第一段、固定最后一段,确认 11th 仍 429。

验证:

  • COMMHUB_DB=/tmp/commhub-login-guard-unit-2.db bun test src/auth_login_guard.test.ts → 12/12 PASS
  • COMMHUB_DB=/tmp/commhub-login-guard-upload-unit-2.db bun test src/uploads.test.ts src/auth_login_guard.test.ts → 43/43 PASS
  • sg docker -c 'docker build -t qa-hub-19-login-guard -f tests/qa-hub-19-login-guard/Dockerfile . && docker run --rm qa-hub-19-login-guard' → PASS
  • sg docker -c 'docker build -t anet-test5-auth -f tests/test5-auth/Dockerfile . && docker run --rm anet-test5-auth' → 25/25 PASS
  • sg docker -c 'docker build -t qa-dash-07-auth-boundary -f tests/qa-dash-07-auth-boundary/Dockerfile . && docker run --rm qa-dash-07-auth-boundary' → PASS
  • sg docker -c 'docker build -t qa-hub-18-delivery-semantics -f tests/qa-hub-18-delivery-semantics/Dockerfile . && docker run --rm qa-hub-18-delivery-semantics' → PASS
  • git diff --check → PASS

报告已更新:docs/tests/report-226-login-guard.txt

@s2agi s2agi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

两处安全修复验证通过(XFF 末段 + spoof-first Docker case + 50k cap/清扫/淘汰),merge。Author-Agent: 通信龙

@s2agi s2agi merged commit 1fcb806 into main Jun 11, 2026
0 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants