From 124d01443d200e69316b312e86ae4fcf13e08f34 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 20:03:13 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E7=9A=84=E9=99=90=E9=A2=91=E6=B5=8B=E8=AF=95=E5=85=88=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=EF=BC=8C=E8=AE=A1=E6=95=B0=E5=99=A8=E5=92=8C=E4=BF=A1?= =?UTF-8?q?=E4=BB=BB=E4=BB=A3=E7=90=86=E9=85=8D=E7=BD=AE=E5=A4=87=E5=A5=BD?= =?UTF-8?q?=EF=BC=8C=E8=B7=AF=E7=94=B1=E8=BF=98=E6=B2=A1=E6=8E=A5=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- packages/control-plane/src/env.ts | 9 + packages/control-plane/src/http/app.ts | 1 + .../control-plane/src/http/auth-limits.ts | 91 +++++++++ packages/control-plane/src/main.ts | 3 +- packages/control-plane/src/server.ts | 3 + .../control-plane/test/auth-limits.test.ts | 50 +++++ .../test/auth-rate-limit.test.ts | 179 ++++++++++++++++++ packages/control-plane/test/helpers/client.ts | 2 +- 8 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 packages/control-plane/src/http/auth-limits.ts create mode 100644 packages/control-plane/test/auth-limits.test.ts create mode 100644 packages/control-plane/test/auth-rate-limit.test.ts diff --git a/packages/control-plane/src/env.ts b/packages/control-plane/src/env.ts index acf62e65..c6c0a383 100644 --- a/packages/control-plane/src/env.ts +++ b/packages/control-plane/src/env.ts @@ -13,6 +13,15 @@ export function secureCookie(): boolean { return (process.env["COOKIE_SECURE"] ?? "") === "1"; } +/** 控制面前面有几层自己的反向代理。0 只认 socket 地址,见 http/auth-limits.ts 的 clientIp。 */ +export function trustedProxyHops(): number { + const raw = nonEmpty("TRUSTED_PROXY_HOPS"); + if (raw === null) return 0; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`TRUSTED_PROXY_HOPS 不是非负整数: ${raw}`); + return parsed; +} + export function toolSecretKey(): string { const key = nonEmpty("TOOL_SECRET_KEY"); if (key !== null) return key; diff --git a/packages/control-plane/src/http/app.ts b/packages/control-plane/src/http/app.ts index 0667c220..6edfd8fe 100644 --- a/packages/control-plane/src/http/app.ts +++ b/packages/control-plane/src/http/app.ts @@ -191,6 +191,7 @@ export type AppDeps = { readonly groupTurns: GroupTurns; readonly notifications: NoticePublisher; readonly secureCookie: boolean; + readonly trustedProxyHops: number; readonly pokerSeed?: () => string; readonly gateway: Omit; readonly platformAccounts: { diff --git a/packages/control-plane/src/http/auth-limits.ts b/packages/control-plane/src/http/auth-limits.ts new file mode 100644 index 00000000..9072d0f2 --- /dev/null +++ b/packages/control-plane/src/http/auth-limits.ts @@ -0,0 +1,91 @@ +import { getConnInfo } from "@hono/node-server/conninfo"; +import { createHash } from "node:crypto"; +import type { Context } from "hono"; + +export const LOGIN_WINDOW_MS = 15 * 60 * 1000; +export const LOGIN_FAILURES_PER_EMAIL = 5; +/** 一个办公室常常共用一个出口 IP,这一维放得比邮箱宽得多,只挡换着邮箱撞库。 */ +export const LOGIN_FAILURES_PER_IP = 50; +export const REGISTER_WINDOW_MS = 60 * 60 * 1000; +export const REGISTRATIONS_PER_IP = 20; +/** 每个计数器最多记这么多个键,随机邮箱刷不爆内存。 */ +export const MAX_TRACKED_KEYS = 10_000; + +type Window = { readonly resetAt: number; count: number }; + +/** + * 进程内的定窗计数,窗口从第一次计数起算。控制面是单实例(#25),重启清零可以接受。 + * 所有窗口一样长,Map 的插入顺序就是到期顺序,过期的总在最前面。 + */ +export class AttemptCounter { + readonly #windows = new Map(); + + constructor( + private readonly limit: number, + private readonly windowMs: number, + private readonly maxKeys: number = MAX_TRACKED_KEYS, + private readonly now: () => number = Date.now, + ) {} + + /** 到了上限回还要等多少毫秒,没到回 null。 */ + blockedFor(key: string): number | null { + const window = this.#windows.get(digest(key)); + const now = this.now(); + if (window === undefined || window.resetAt <= now || window.count < this.limit) return null; + return window.resetAt - now; + } + + hit(key: string): void { + const now = this.now(); + this.#dropExpired(now); + const k = digest(key); + const window = this.#windows.get(k); + if (window !== undefined) { + window.count += 1; + return; + } + if (this.#windows.size >= this.maxKeys) { + const oldest = this.#windows.keys().next(); + if (oldest.done !== true) this.#windows.delete(oldest.value); + } + this.#windows.set(k, { resetAt: now + this.windowMs, count: 1 }); + } + + /** 退回一次 hit。登录先记上再验密码,验过了就退掉。 */ + unhit(key: string): void { + const window = this.#windows.get(digest(key)); + if (window !== undefined && window.count > 0) window.count -= 1; + } + + clear(key: string): void { + this.#windows.delete(digest(key)); + } + + #dropExpired(now: number): void { + for (const [k, window] of this.#windows) { + if (window.resetAt > now) return; + this.#windows.delete(k); + } + } +} + +// 邮箱是请求体里的任意长字符串,摘要后每个键定长。 +const digest = (key: string): string => createHash("sha256").update(key).digest("base64url"); + +/** + * 请求的来源 IP。trustedProxyHops 是控制面前面有几层自己的反向代理:0 只认 socket 地址, + * 不看 X-Forwarded-For,因为客户端能随便填它;n 取 X-Forwarded-For 从右数第 n 项,那是最外层代理看到的来源。 + */ +export function clientIp(c: Context, trustedProxyHops: number): string { + const socket = getConnInfo(c).remote.address ?? "unknown"; + if (trustedProxyHops === 0) return socket; + const forwarded = (c.req.header("x-forwarded-for") ?? "") + .split(",") + .map((part) => part.trim()) + .filter((part) => part !== ""); + return forwarded[Math.max(0, forwarded.length - trustedProxyHops)] ?? socket; +} + +export function retryAfterSeconds(waitMs: number): number { + return Math.max(1, Math.ceil(waitMs / 1000)); +} diff --git a/packages/control-plane/src/main.ts b/packages/control-plane/src/main.ts index dd43e1a0..4eb75482 100644 --- a/packages/control-plane/src/main.ts +++ b/packages/control-plane/src/main.ts @@ -1,5 +1,5 @@ import { connect } from "./db/client.js"; -import { databaseUrl, port, runtimeConfig, secureCookie } from "./env.js"; +import { databaseUrl, port, runtimeConfig, secureCookie, trustedProxyHops } from "./env.js"; import { acquireInstanceLock } from "./instance-lock.js"; import { startServer, type RunningServer } from "./server.js"; @@ -56,6 +56,7 @@ try { db, port: port(), secureCookie: secureCookie(), + trustedProxyHops: trustedProxyHops(), config: runtimeConfig(), }); } catch (error) { diff --git a/packages/control-plane/src/server.ts b/packages/control-plane/src/server.ts index 57265afb..445a1c21 100644 --- a/packages/control-plane/src/server.ts +++ b/packages/control-plane/src/server.ts @@ -76,6 +76,8 @@ export type StartOptions = { /** 不给就监听通配地址。测试给 127.0.0.1:macOS 分临时端口时不避开别的进程占在 127.0.0.1 上的同号端口,客户端连 127.0.0.1 会连到别人那里。 */ readonly hostname?: string; readonly secureCookie?: boolean; + /** 不给就是 0:只认 socket 地址,不读 X-Forwarded-For。 */ + readonly trustedProxyHops?: number; readonly config?: RuntimeConfig; readonly scheduler?: Pick; readonly notificationScheduler?: NotificationOptions; @@ -157,6 +159,7 @@ export async function startServer(options: StartOptions): Promise groupTurns, notifications, secureCookie: options.secureCookie ?? false, + trustedProxyHops: options.trustedProxyHops ?? 0, ...(options.poker?.seed === undefined ? {} : { pokerSeed: options.poker.seed }), gateway: { models: config.gatewayModels, diff --git a/packages/control-plane/test/auth-limits.test.ts b/packages/control-plane/test/auth-limits.test.ts new file mode 100644 index 00000000..c14dcbc0 --- /dev/null +++ b/packages/control-plane/test/auth-limits.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from "vitest"; +import { AttemptCounter } from "../src/http/auth-limits.js"; + +function clock(start = 0) { + let now = start; + return { now: () => now, advance: (ms: number) => (now += ms) }; +} + +it("计满才挡,回剩下的毫秒数,窗口过了自动放开", () => { + const time = clock(); + const counter = new AttemptCounter(2, 1000, 100, time.now); + + counter.hit("a@baize.dev"); + expect(counter.blockedFor("a@baize.dev")).toBeNull(); + counter.hit("a@baize.dev"); + time.advance(300); + expect(counter.blockedFor("a@baize.dev")).toBe(700); + + time.advance(700); + expect(counter.blockedFor("a@baize.dev"), "窗口到期就放开").toBeNull(); + counter.hit("a@baize.dev"); + expect(counter.blockedFor("a@baize.dev"), "到期后重新起一个窗口").toBeNull(); +}); + +it("退回一次和清零都能把挡住的键放开", () => { + const counter = new AttemptCounter(1, 1000); + counter.hit("a"); + counter.unhit("a"); + expect(counter.blockedFor("a")).toBeNull(); + counter.hit("b"); + counter.clear("b"); + expect(counter.blockedFor("b")).toBeNull(); +}); + +it("键数到上限时先丢过期的,都没过期就丢最早的", () => { + const time = clock(); + const counter = new AttemptCounter(1, 1000, 2, time.now); + + counter.hit("old"); + time.advance(1000); + counter.hit("a"); + counter.hit("b"); + expect(counter.blockedFor("a"), "丢的是过期的 old,a 还在").not.toBeNull(); + expect(counter.blockedFor("b")).not.toBeNull(); + + counter.hit("c"); + expect(counter.blockedFor("a"), "都没过期时丢最早建的 a").toBeNull(); + expect(counter.blockedFor("b")).not.toBeNull(); + expect(counter.blockedFor("c")).not.toBeNull(); +}); diff --git a/packages/control-plane/test/auth-rate-limit.test.ts b/packages/control-plane/test/auth-rate-limit.test.ts new file mode 100644 index 00000000..d0a4b0d4 --- /dev/null +++ b/packages/control-plane/test/auth-rate-limit.test.ts @@ -0,0 +1,179 @@ +import { ApiError } from "@baize/shared"; +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + LOGIN_FAILURES_PER_EMAIL, + LOGIN_FAILURES_PER_IP, + LOGIN_WINDOW_MS, + REGISTER_WINDOW_MS, + REGISTRATIONS_PER_IP, +} from "../src/http/auth-limits.js"; +import { startTestServer, type TestServer } from "./helpers/client.js"; +import { createTestDb, type TestDb } from "./helpers/db.js"; +import { PASSWORD, makeUser } from "./helpers/fixtures.js"; + +let database: TestDb; +// 计数在进程内存里,按 IP 的那一维会被前一条用例吃满,所以每条用例一台新控制面。 +let server: TestServer | null = null; + +beforeAll(async () => { + database = await createTestDb(); +}); + +afterEach(async () => { + await server?.close(); + server = null; +}); + +afterAll(async () => { + await database.close(); +}); + +async function start(trustedProxyHops?: number): Promise { + server = await startTestServer( + database.db, + {}, + undefined, + undefined, + undefined, + trustedProxyHops === undefined ? {} : { trustedProxyHops }, + ); + return server; +} + +function login(running: TestServer, email: string, password: string, forwardedFor?: string): Promise { + return fetch(`http://localhost:${running.server.port}/api/auth/login`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(forwardedFor === undefined ? {} : { "x-forwarded-for": forwardedFor }), + }, + body: JSON.stringify({ email, password }), + }); +} + +function register(running: TestServer): Promise { + return fetch(`http://localhost:${running.server.port}/api/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: `${randomUUID()}@baize.dev`, password: PASSWORD, name: "林静" }), + }); +} + +async function failTimes(running: TestServer, email: string, times: number, forwardedFor?: string): Promise { + for (let i = 0; i < times; i += 1) { + const response = await login(running, email, "错的密码", forwardedFor); + expect(response.status, `第 ${i + 1} 次密码错应当还是 401`).toBe(401); + } +} + +async function expectTooMany(response: Response, windowMs: number): Promise { + expect(response.status).toBe(429); + const retryAfter = Number(response.headers.get("retry-after")); + expect(Number.isInteger(retryAfter), "Retry-After 是整数秒").toBe(true); + expect(retryAfter).toBeGreaterThan(0); + expect(retryAfter).toBeLessThanOrEqual(windowMs / 1000); + expect(ApiError.parse(await response.json()).error, "文案告诉人多久后再试").toMatch(/\d+ 分钟后再试/); + expect(response.headers.getSetCookie(), "被挡下的请求不建会话").toEqual([]); +} + +describe("登录失败计数", () => { + it("同一邮箱连续失败到阈值后回 429 带 Retry-After,锁定期内密码对了也是 429", async () => { + const running = await start(); + const { email } = await makeUser(database.db); + + await failTimes(running, email, LOGIN_FAILURES_PER_EMAIL); + + await expectTooMany(await login(running, email, "错的密码"), LOGIN_WINDOW_MS); + await expectTooMany(await login(running, email, PASSWORD), LOGIN_WINDOW_MS); + }); + + it("一个邮箱被锁不影响同 IP 上别的邮箱登录", async () => { + const running = await start(); + const locked = await makeUser(database.db); + const other = await makeUser(database.db); + + await failTimes(running, locked.email, LOGIN_FAILURES_PER_EMAIL); + + expect((await login(running, locked.email, PASSWORD)).status).toBe(429); + expect((await login(running, other.email, PASSWORD)).status, "别的邮箱照常登录").toBe(200); + }); + + it("同一 IP 换着邮箱失败到 IP 阈值后,任何邮箱都回 429", async () => { + const running = await start(); + const { email } = await makeUser(database.db); + + for (let i = 0; i < LOGIN_FAILURES_PER_IP; i += 1) { + const response = await login(running, `nobody-${i}-${randomUUID()}@baize.dev`, "错的密码"); + expect(response.status, `第 ${i + 1} 次失败还没到 IP 阈值`).toBe(401); + } + + await expectTooMany(await login(running, email, PASSWORD), LOGIN_WINDOW_MS); + }); + + it("成功登录清掉该邮箱的失败计数", async () => { + const running = await start(); + const { email } = await makeUser(database.db); + + await failTimes(running, email, LOGIN_FAILURES_PER_EMAIL - 1); + expect((await login(running, email, PASSWORD)).status).toBe(200); + + await failTimes(running, email, LOGIN_FAILURES_PER_EMAIL - 1); + expect((await login(running, email, PASSWORD)).status, "清零后又失败了不到阈值,照常登录").toBe(200); + }); + + it("并发撞同一个邮箱,密码错的回包不超过阈值", async () => { + const running = await start(); + const { email } = await makeUser(database.db); + + const statuses = await Promise.all( + Array.from( + { length: LOGIN_FAILURES_PER_EMAIL * 4 }, + async () => (await login(running, email, "错的密码")).status, + ), + ); + + expect(statuses.filter((status) => status === 401)).toHaveLength(LOGIN_FAILURES_PER_EMAIL); + expect(statuses.filter((status) => status === 429)).toHaveLength(LOGIN_FAILURES_PER_EMAIL * 3); + }); + + it("默认不信 X-Forwarded-For,换着填它也绕不过 IP 阈值", async () => { + const running = await start(); + const { email } = await makeUser(database.db); + + for (let i = 0; i < LOGIN_FAILURES_PER_IP; i += 1) { + const response = await login(running, `nobody-${i}-${randomUUID()}@baize.dev`, "错的密码", `203.0.113.${i}`); + expect(response.status).toBe(401); + } + + expect((await login(running, email, PASSWORD, "198.51.100.7")).status).toBe(429); + }); + + it("配了信任一层代理,就按 X-Forwarded-For 最右一项分 IP 计数", async () => { + const running = await start(1); + const { email } = await makeUser(database.db); + + for (let i = 0; i < LOGIN_FAILURES_PER_IP; i += 1) { + const response = await login(running, `nobody-${i}-${randomUUID()}@baize.dev`, "错的密码", "203.0.113.9"); + expect(response.status).toBe(401); + } + + expect((await login(running, email, PASSWORD, "203.0.113.9")).status, "撞满的那个来源被挡").toBe(429); + expect( + (await login(running, email, PASSWORD, "203.0.113.9, 198.51.100.7")).status, + "左边是客户端自己填的,最右一项才是代理看到的来源", + ).toBe(200); + }); +}); + +describe("注册限频", () => { + it("同一 IP 注册到阈值后回 429 带 Retry-After", async () => { + const running = await start(); + + for (let i = 0; i < REGISTRATIONS_PER_IP; i += 1) { + expect((await register(running)).status, `第 ${i + 1} 次注册还没到阈值`).toBe(201); + } + + await expectTooMany(await register(running), REGISTER_WINDOW_MS); + }); +}); diff --git a/packages/control-plane/test/helpers/client.ts b/packages/control-plane/test/helpers/client.ts index 88210116..64e3295f 100644 --- a/packages/control-plane/test/helpers/client.ts +++ b/packages/control-plane/test/helpers/client.ts @@ -18,7 +18,7 @@ export async function startTestServer( scheduler?: StartOptions["scheduler"], notificationScheduler?: NotificationOptions, publisher?: StartOptions["publisher"], - agentOptions: Pick = {}, + agentOptions: Pick = {}, ): Promise { const server = await startServer({ db, From c95ada299f77a92678e258f5387bb6437719eb67 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 20:06:25 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=8C=89=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E5=92=8C=E6=9D=A5=E6=BA=90=20IP=20=E8=AE=A1=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=AC=A1=E6=95=B0=EF=BC=8C=E6=B3=A8=E5=86=8C=E6=8C=89?= =?UTF-8?q?=20IP=20=E9=99=90=E9=A2=91=EF=BC=8C=E8=B6=85=E4=BA=86=E5=9B=9E?= =?UTF-8?q?=20429=20=E5=B8=A6=20Retry-After=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- .env.example | 3 +++ deploy/README.md | 1 + deploy/remote/docker-compose.yml | 2 ++ "docs/\345\274\200\345\217\221.md" | 3 +++ packages/control-plane/src/http/app.ts | 35 ++++++++++++++++++++++++- packages/control-plane/test/env.test.ts | 12 +++++++++ 6 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 303767d0..91e55af0 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ PRICE_INPUT_PER_M=0 PRICE_OUTPUT_PER_M=0 # 设成 1 时会话 cookie 带 Secure,上了 HTTPS 再开。 COOKIE_SECURE= +# 控制面前面有几层自己的反向代理。登录失败和注册按来源 IP 限频,留空或 0 只认 socket 地址,不读 X-Forwarded-For, +# 因为客户端能随便填它。前面挂了 Caddy 或 nginx 就填 1,取 X-Forwarded-For 最右一项;没有代理却填了,任何人都能伪造来源。 +TRUSTED_PROXY_HOPS= # 工具凭据的加密钥匙,64 位十六进制,用 openssl rand -hex 32 生成,换了之前存的工具凭据就解不开。 # 不设时用代码里的开发密钥并告警;设了空值会启动失败,所以不用时整行保持注释。 # TOOL_SECRET_KEY= diff --git a/deploy/README.md b/deploy/README.md index cadb10b6..927b01b3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -81,6 +81,7 @@ compose 读 `deploy/remote/.env`(`remote/README.md:41`)。控制面读哪些 | `PRICE_INPUT_PER_M`、`PRICE_OUTPUT_PER_M` | 否 | `0` | 每百万 token 价格,折算 run 的花费(`env.ts:96-97`)。不设用量金额恒为 0 | | `GITHUB_API_BASE_URL`、`X_API_BASE_URL`、`GITHUB_OAUTH_BASE_URL`、`X_OAUTH_BASE_URL` | 否 | 各家官方地址,和 `env.ts:84-87` 一致 | 只在验证时指到假平台(`env.ts:98-99`、`:122-123`),生产不用设 | | `COOKIE_SECURE` | 上了 HTTPS 就设 | `0` | 设成 `1` 会话 cookie 才带 Secure(`env.ts:12-14`) | +| `TRUSTED_PROXY_HOPS` | 否 | `1` | 控制面前面有几层反向代理,登录注册按来源 IP 限频时从 `X-Forwarded-For` 右数第几项取来源(`packages/control-plane/src/http/auth-limits.ts` 的 `clientIp`)。compose 默认 `1` 对应同机一层 Caddy;设成 `0` 所有请求都算成 docker 网关一个来源,几十次失败就锁住全站登录。控制面本身不设时是 `0` | | `GITHUB_APP_CLIENT_ID`、`GITHUB_APP_CLIENT_SECRET` | 否 | 空 | 要一起设,只设一个启动时抛错(`env.ts:113-115`) | | `X_CLIENT_ID`、`X_CLIENT_SECRET` | 否 | 空 | 设了 secret 就要设 id(`env.ts:118`) | diff --git a/deploy/remote/docker-compose.yml b/deploy/remote/docker-compose.yml index ad795728..7cef3c53 100644 --- a/deploy/remote/docker-compose.yml +++ b/deploy/remote/docker-compose.yml @@ -57,6 +57,8 @@ services: # 工具认证信息的加密密钥,64 位十六进制。换了它,已存的凭据全部解不开。 TOOL_SECRET_KEY: ${TOOL_SECRET_KEY:?要设工具凭据的加密密钥} COOKIE_SECURE: ${COOKIE_SECURE:-0} + # 控制面只绑 127.0.0.1,前面必有一层 Caddy 或 nginx。登录注册按来源 IP 限频,要从 X-Forwarded-For 取真实来源。 + TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS:-1} # 平台账号令牌的加密密钥,32 字节 base64。换了它,已存的令牌全部解不开。 CREDENTIALS_KEY: ${CREDENTIALS_KEY:?缺 CREDENTIALS_KEY} # 浏览器打开 Web 的地址,OAuth 回调拼在它上面。控制面自己推不出浏览器到得了的地址,所以必填。 diff --git "a/docs/\345\274\200\345\217\221.md" "b/docs/\345\274\200\345\217\221.md" index 8d307f5e..9a68e4f1 100644 --- "a/docs/\345\274\200\345\217\221.md" +++ "b/docs/\345\274\200\345\217\221.md" @@ -53,6 +53,7 @@ Bot 走到要点头的工具时群里出一张审批卡,点按钮或在群里 | `DATABASE_URL` | `postgres://baize:baize@localhost:5434/baize` | Postgres 连接串 | | `PORT` | `3001` | 监听端口 | | `COOKIE_SECURE` | 空 | 设成 `1` 时会话 cookie 带 Secure | +| `TRUSTED_PROXY_HOPS` | `0` | 控制面前面有几层自己的反向代理,决定登录和注册限频认哪个来源 IP。`0` 只认 socket 地址,不读 `X-Forwarded-For`;前面挂了 Caddy 或 nginx 填 `1`,取 `X-Forwarded-For` 最右一项。没有代理却填了非 0,客户端就能伪造来源绕过 IP 限频 | | `PUBLIC_BASE_URL` | 空,用实际监听的地址拼 | 只在没设 `WEB_BASE_URL` 时给 OAuth 回调当前缀。电脑拿到的数据面地址不看它,看代理自报的 `controlOrigin` | | `GATEWAY_MODEL` | `baize-default` | 兼容旧配置;未设置 `GATEWAY_MODELS` 时同时作为 plan 和 fast 的上游模型名 | | `GATEWAY_MODELS` | 空,回退 `GATEWAY_MODEL` | 按 `plan=模型,fast=模型` 配置两个用途;设置后必须同时包含 plan 和 fast | @@ -70,6 +71,8 @@ Bot 走到要点头的工具时群里出一张审批卡,点按钮或在群里 | `GITHUB_OAUTH_BASE_URL` | `https://github.com` | GitHub 授权页和令牌端点的根地址,验证时指到假 GitHub | | `X_OAUTH_BASE_URL` | `https://x.com` | X 授权页的根地址 | +登录和注册有进程内的限频,阈值是 `packages/control-plane/src/http/auth-limits.ts` 里的常量:同一邮箱 15 分钟内失败 5 次、同一来源 IP 15 分钟内失败 50 次,再登录就回 429 带 `Retry-After`,锁定期内密码对了也一样,登录成功清掉该邮箱的计数;同一来源 IP 一小时内最多注册 20 次。计数在内存里,重启清零,每个计数器最多记一万个键。本地开发经 Vite 代理进来的请求来源都是 127.0.0.1,自己连错几次密码就被锁了的话,换个邮箱或重启控制面。 + GitHub、X 账号在账号页走 OAuth 连。开发时手里有令牌想跳过授权页,也可以用脚本挂上,令牌加密存进库里(没有刷新令牌,过期了只能再跑一次):`pnpm --silent --filter @baize/control-plane account-add -- --owner <邮箱> --platform github --handle <名> --token `,可选 `--tier`。不带 `--silent` 时 pnpm 会把整条命令连令牌一起回显到终端。 ## 跑起来 diff --git a/packages/control-plane/src/http/app.ts b/packages/control-plane/src/http/app.ts index 6edfd8fe..3199be09 100644 --- a/packages/control-plane/src/http/app.ts +++ b/packages/control-plane/src/http/app.ts @@ -163,6 +163,16 @@ import type { PushHub } from "../ws/hub.js"; import type { AccountTokens, CredentialCrypto } from "../credentials.js"; import type { OAuthEndpoints } from "../oauth-providers.js"; import { ACCOUNTS_PROXY_PATH, createAccountsProxy } from "./accounts-proxy.js"; +import { + AttemptCounter, + LOGIN_FAILURES_PER_EMAIL, + LOGIN_FAILURES_PER_IP, + LOGIN_WINDOW_MS, + REGISTER_WINDOW_MS, + REGISTRATIONS_PER_IP, + clientIp, + retryAfterSeconds, +} from "./auth-limits.js"; import { GATEWAY_PATH, createGateway, type GatewayDeps } from "./gateway.js"; import { HUB_PATH, createHub } from "./hub.js"; import { MCP_PROXY_PATH, createMcpProxy } from "./mcp-proxy.js"; @@ -288,9 +298,16 @@ export function createApp(deps: AppDeps): Hono & { closeMcpClients(): Promi }), ); + const loginFailuresByEmail = new AttemptCounter(LOGIN_FAILURES_PER_EMAIL, LOGIN_WINDOW_MS); + const loginFailuresByIp = new AttemptCounter(LOGIN_FAILURES_PER_IP, LOGIN_WINDOW_MS); + const registrationsByIp = new AttemptCounter(REGISTRATIONS_PER_IP, REGISTER_WINDOW_MS); app.post("/api/register", async (c) => { const body = await readJson(c.req.raw, RegisterRequest); if (!body.ok) return c.json({ error: body.error }, 400); + const ip = clientIp(c, deps.trustedProxyHops); + const wait = registrationsByIp.blockedFor(ip); + if (wait !== null) return tooManyAttempts(c, wait); + registrationsByIp.hit(ip); let user: User; try { user = await createUser(db, body.value); @@ -313,8 +330,18 @@ export function createApp(deps: AppDeps): Hono & { closeMcpClients(): Promi app.post("/api/auth/login", async (c) => { const body = await readJson(c.req.raw, LoginRequest); if (!body.ok) return c.json({ error: body.error }, 400); - const user = await authenticate(db, body.value.email, body.value.password); + const { email, password } = body.value; + const ip = clientIp(c, deps.trustedProxyHops); + const byEmail = loginFailuresByEmail.blockedFor(email); + const byIp = loginFailuresByIp.blockedFor(ip); + if (byEmail !== null || byIp !== null) return tooManyAttempts(c, Math.max(byEmail ?? 0, byIp ?? 0)); + // 验密码要等 scrypt,先按失败记上,并发涌进来的请求在这期间就能看到计数。 + loginFailuresByEmail.hit(email); + loginFailuresByIp.hit(ip); + const user = await authenticate(db, email, password); if (user === null) return c.json({ error: "邮箱或密码不对" }, 401); + loginFailuresByEmail.clear(email); + loginFailuresByIp.unhit(ip); const token = await createSession(db, user.id); // 首次登录时给他登记一台电脑,状态先是创建中,代理第一次握手才转在线。 await ensureComputer(db, user.id); @@ -1123,6 +1150,12 @@ const MessageQuery = z.object({ type ReadResult = { ok: true; value: T } | { ok: false; error: string }; +function tooManyAttempts(c: Context, waitMs: number) { + const seconds = retryAfterSeconds(waitMs); + c.header("Retry-After", String(seconds)); + return c.json({ error: `尝试太多次了,请 ${Math.ceil(seconds / 60)} 分钟后再试` }, 429); +} + // 泛型收在 schema 上而不是 z.ZodType:后者的输入类型会把 BotId 这类品牌化 ID 磨回 string。 async function readJson(request: Request, schema: S): Promise>> { let raw: unknown; diff --git a/packages/control-plane/test/env.test.ts b/packages/control-plane/test/env.test.ts index 9fc7a9cf..936601b8 100644 --- a/packages/control-plane/test/env.test.ts +++ b/packages/control-plane/test/env.test.ts @@ -10,6 +10,7 @@ import { credentialsKey, runtimeConfig, toolSecretKey, + trustedProxyHops, } from "../src/env.js"; afterEach(() => { @@ -188,3 +189,14 @@ it(".env 里留空的地址和工具密钥按没设处理,不把空串下发 expect(config.oauth.xOauthBaseUrl).toBe(DEFAULT_X_OAUTH_BASE_URL); expect(toolSecretKey()).not.toBe(""); }); + +it("TRUSTED_PROXY_HOPS 留空是 0,只收非负整数", () => { + vi.stubEnv("TRUSTED_PROXY_HOPS", ""); + expect(trustedProxyHops()).toBe(0); + vi.stubEnv("TRUSTED_PROXY_HOPS", "1"); + expect(trustedProxyHops()).toBe(1); + vi.stubEnv("TRUSTED_PROXY_HOPS", "-1"); + expect(trustedProxyHops).toThrowError("TRUSTED_PROXY_HOPS 不是非负整数: -1"); + vi.stubEnv("TRUSTED_PROXY_HOPS", "yes"); + expect(trustedProxyHops).toThrowError("TRUSTED_PROXY_HOPS 不是非负整数: yes"); +}); From 3088f2674b93c788abe9a2f6eb62ba2d870744fc Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 20:37:07 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E9=99=84=E4=BB=B6=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=BB=BA=E7=94=A8=E6=88=B7=E5=86=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=EF=BC=8C=E4=B8=8D=E8=B5=B0=E6=B3=A8=E5=86=8C=EF=BC=8C?= =?UTF-8?q?=E5=85=8D=E5=BE=97=E6=92=9E=E4=B8=8A=E6=8C=89=20IP=20=E7=9A=84?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E9=99=90=E9=A2=91=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- packages/control-plane/test/attachments.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/control-plane/test/attachments.test.ts b/packages/control-plane/test/attachments.test.ts index 772a04a3..f2d35697 100644 --- a/packages/control-plane/test/attachments.test.ts +++ b/packages/control-plane/test/attachments.test.ts @@ -8,7 +8,6 @@ import { ComputerTokenResponse, CreateBotResponse, CreateGroupResponse, - RegisterResponse, type BotId, type GroupId, } from "@baize/shared"; @@ -16,6 +15,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { helloAgent, type AgentClient } from "./helpers/agent.js"; import { startTestServer, type TestServer } from "./helpers/client.js"; import { createTestDb, type TestDb } from "./helpers/db.js"; +import { PASSWORD, makeUser } from "./helpers/fixtures.js"; let database: TestDb; let server: TestServer; @@ -48,13 +48,9 @@ afterAll(async () => { async function owner() { const client = server.client(); - const registered = await client.post("/api/register", { - name: "附件测试", - email: `${randomUUID()}@baize.dev`, - password: "baize-dev", - }); - expect(registered.status).toBe(201); - const { user } = RegisterResponse.parse(await registered.json()); + // 一个文件建二十几个人,走注册会撞上按 IP 的注册限频。 + const user = await makeUser(database.db, "附件测试"); + expect((await client.login(user.email, PASSWORD)).status).toBe(200); const paired = await client.post("/api/computers/local"); expect(paired.status).toBe(200); const computer = ComputerTokenResponse.parse(await paired.json()); From a5bbdc61c83a838110321e6da24cf86bac673860 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 20:37:07 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A2=AB=E9=99=90=E9=A2=91=E6=97=B6=EF=BC=8C=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=A1=B5=E5=92=8C=E6=B3=A8=E5=86=8C=E9=A1=B5=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=A4=9A=E4=B9=85=E5=90=8E=E5=86=8D=E8=AF=95=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- packages/web/e2e/01-auth.spec.ts | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/web/e2e/01-auth.spec.ts b/packages/web/e2e/01-auth.spec.ts index a611afb7..6c1ffb12 100644 --- a/packages/web/e2e/01-auth.spec.ts +++ b/packages/web/e2e/01-auth.spec.ts @@ -40,6 +40,39 @@ test("密码不对时登录页留在原地并说明原因", async ({ page }) => await page.screenshot({ path: `${SHOT}/01.png` }); }); +test("登录试太多次被挡时,登录页说多久后再试,按钮还能再点", async ({ page }) => { + await page.route("**/api/auth/login", (route) => + route.fulfill({ + status: 429, + headers: { "retry-after": "840" }, + contentType: "application/json", + body: JSON.stringify({ error: "尝试太多次了,请 14 分钟后再试" }), + }), + ); + await fillCredentials(page, "wrong-password"); + await expect(page.getByTestId("login-error")).toHaveText("尝试太多次了,请 14 分钟后再试"); + await expect(page.getByTestId("login-submit")).toBeEnabled(); + expect(new URL(page.url()).pathname).toBe("/login"); +}); + +test("注册太频繁被挡时,注册页说多久后再试", async ({ page }) => { + await page.route("**/api/register", (route) => + route.fulfill({ + status: 429, + headers: { "retry-after": "3600" }, + contentType: "application/json", + body: JSON.stringify({ error: "尝试太多次了,请 60 分钟后再试" }), + }), + ); + await page.goto("/register"); + await page.getByTestId("register-email").fill("new-user@baize.dev"); + await page.getByTestId("register-password").fill("new-user-password"); + await page.getByTestId("register-name").fill("林舟"); + await page.getByTestId("register-submit").click(); + await expect(page.getByTestId("register-error")).toHaveText("尝试太多次了,请 60 分钟后再试"); + expect(new URL(page.url()).pathname).toBe("/register"); +}); + test("登录成功进主窗口,团队页列出预置的三个 Bot", async ({ page }) => { await login(page); await page.getByTestId("team-nav").click(); From 57f89cdfc061fe7d7177383986e76b29632ffb50 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 21:19:04 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=9D=A5=E6=BA=90=20IP?= =?UTF-8?q?=20=E7=99=BB=E5=BD=95=E5=A4=B1=E8=B4=A5=E7=9A=84=E9=98=88?= =?UTF-8?q?=E5=80=BC=E4=BB=8E=2050=20=E6=94=BE=E5=88=B0=20200=EF=BC=8C?= =?UTF-8?q?=E5=8A=9E=E5=85=AC=E5=AE=A4=E5=85=B1=E7=94=A8=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=20IP=20=E4=B8=8D=E5=AE=B9=E6=98=93=E4=B8=80=E8=B5=B7=E8=A2=AB?= =?UTF-8?q?=E9=94=81=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- deploy/README.md | 2 +- "docs/\345\274\200\345\217\221.md" | 2 +- packages/control-plane/src/http/auth-limits.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 927b01b3..d43aaef2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -81,7 +81,7 @@ compose 读 `deploy/remote/.env`(`remote/README.md:41`)。控制面读哪些 | `PRICE_INPUT_PER_M`、`PRICE_OUTPUT_PER_M` | 否 | `0` | 每百万 token 价格,折算 run 的花费(`env.ts:96-97`)。不设用量金额恒为 0 | | `GITHUB_API_BASE_URL`、`X_API_BASE_URL`、`GITHUB_OAUTH_BASE_URL`、`X_OAUTH_BASE_URL` | 否 | 各家官方地址,和 `env.ts:84-87` 一致 | 只在验证时指到假平台(`env.ts:98-99`、`:122-123`),生产不用设 | | `COOKIE_SECURE` | 上了 HTTPS 就设 | `0` | 设成 `1` 会话 cookie 才带 Secure(`env.ts:12-14`) | -| `TRUSTED_PROXY_HOPS` | 否 | `1` | 控制面前面有几层反向代理,登录注册按来源 IP 限频时从 `X-Forwarded-For` 右数第几项取来源(`packages/control-plane/src/http/auth-limits.ts` 的 `clientIp`)。compose 默认 `1` 对应同机一层 Caddy;设成 `0` 所有请求都算成 docker 网关一个来源,几十次失败就锁住全站登录。控制面本身不设时是 `0` | +| `TRUSTED_PROXY_HOPS` | 否 | `1` | 控制面前面有几层反向代理,登录注册按来源 IP 限频时从 `X-Forwarded-For` 右数第几项取来源(`packages/control-plane/src/http/auth-limits.ts` 的 `clientIp`)。compose 默认 `1` 对应同机一层 Caddy;设成 `0` 所有请求都算成 docker 网关一个来源,两百次失败就锁住全站登录。控制面本身不设时是 `0` | | `GITHUB_APP_CLIENT_ID`、`GITHUB_APP_CLIENT_SECRET` | 否 | 空 | 要一起设,只设一个启动时抛错(`env.ts:113-115`) | | `X_CLIENT_ID`、`X_CLIENT_SECRET` | 否 | 空 | 设了 secret 就要设 id(`env.ts:118`) | diff --git "a/docs/\345\274\200\345\217\221.md" "b/docs/\345\274\200\345\217\221.md" index 9a68e4f1..52fe23f2 100644 --- "a/docs/\345\274\200\345\217\221.md" +++ "b/docs/\345\274\200\345\217\221.md" @@ -71,7 +71,7 @@ Bot 走到要点头的工具时群里出一张审批卡,点按钮或在群里 | `GITHUB_OAUTH_BASE_URL` | `https://github.com` | GitHub 授权页和令牌端点的根地址,验证时指到假 GitHub | | `X_OAUTH_BASE_URL` | `https://x.com` | X 授权页的根地址 | -登录和注册有进程内的限频,阈值是 `packages/control-plane/src/http/auth-limits.ts` 里的常量:同一邮箱 15 分钟内失败 5 次、同一来源 IP 15 分钟内失败 50 次,再登录就回 429 带 `Retry-After`,锁定期内密码对了也一样,登录成功清掉该邮箱的计数;同一来源 IP 一小时内最多注册 20 次。计数在内存里,重启清零,每个计数器最多记一万个键。本地开发经 Vite 代理进来的请求来源都是 127.0.0.1,自己连错几次密码就被锁了的话,换个邮箱或重启控制面。 +登录和注册有进程内的限频,阈值是 `packages/control-plane/src/http/auth-limits.ts` 里的常量:同一邮箱 15 分钟内失败 5 次、同一来源 IP 15 分钟内失败 200 次,再登录就回 429 带 `Retry-After`,锁定期内密码对了也一样,登录成功清掉该邮箱的计数;同一来源 IP 一小时内最多注册 20 次。计数在内存里,重启清零,每个计数器最多记一万个键。本地开发经 Vite 代理进来的请求来源都是 127.0.0.1,自己连错几次密码就被锁了的话,换个邮箱或重启控制面。 GitHub、X 账号在账号页走 OAuth 连。开发时手里有令牌想跳过授权页,也可以用脚本挂上,令牌加密存进库里(没有刷新令牌,过期了只能再跑一次):`pnpm --silent --filter @baize/control-plane account-add -- --owner <邮箱> --platform github --handle <名> --token `,可选 `--tier`。不带 `--silent` 时 pnpm 会把整条命令连令牌一起回显到终端。 diff --git a/packages/control-plane/src/http/auth-limits.ts b/packages/control-plane/src/http/auth-limits.ts index 9072d0f2..b791e916 100644 --- a/packages/control-plane/src/http/auth-limits.ts +++ b/packages/control-plane/src/http/auth-limits.ts @@ -5,7 +5,7 @@ import type { Context } from "hono"; export const LOGIN_WINDOW_MS = 15 * 60 * 1000; export const LOGIN_FAILURES_PER_EMAIL = 5; /** 一个办公室常常共用一个出口 IP,这一维放得比邮箱宽得多,只挡换着邮箱撞库。 */ -export const LOGIN_FAILURES_PER_IP = 50; +export const LOGIN_FAILURES_PER_IP = 200; export const REGISTER_WINDOW_MS = 60 * 60 * 1000; export const REGISTRATIONS_PER_IP = 20; /** 每个计数器最多记这么多个键,随机邮箱刷不爆内存。 */