diff --git a/apps/backend/src/middleware/rate-limit.test.ts b/apps/backend/src/middleware/rate-limit.test.ts index a7231e3c..154fd086 100644 --- a/apps/backend/src/middleware/rate-limit.test.ts +++ b/apps/backend/src/middleware/rate-limit.test.ts @@ -71,6 +71,31 @@ test('createRateLimitMiddleware supports custom limit responses', () => { assert.deepEqual(res.body, { code: 429, message: 'limited' }) }) +test('createRateLimitMiddleware can cap an IP across different accounts', () => { + resetRateLimitBucketsForTest() + + const limiter = createRateLimitMiddleware({ + scope: 'test:ip-wide', + windowMs: 60_000, + max: 2, + }) + const first = createMockRequest() + const second = createMockRequest() + const third = createMockRequest() + const response = createMockResponse() + let nextCount = 0 + const next: NextFunction = () => { + nextCount += 1 + } + + limiter(first, response, next) + limiter(second, response, next) + limiter(third, response, next) + + assert.equal(nextCount, 2) + assert.equal(response.statusCode, 429) +}) + function createMockRequest(): Request { return { ip: '127.0.0.1', diff --git a/apps/backend/src/routes/admin/auth.ts b/apps/backend/src/routes/admin/auth.ts index c8b17c6c..723e5c06 100644 --- a/apps/backend/src/routes/admin/auth.ts +++ b/apps/backend/src/routes/admin/auth.ts @@ -15,14 +15,23 @@ import { createJsonHandler, extractBearerToken } from './session.js' const router = Router() +const adminLoginIpRateLimit = createRateLimitMiddleware({ + scope: 'admin:login:ip', + windowMs: 60_000, + max: 20, +}) + +const adminLoginAccountRateLimit = createRateLimitMiddleware({ + scope: 'admin:login:account', + windowMs: 60_000, + max: 10, + key: getBodyFieldRateLimitKey('username'), +}) + router.post( '/auth/login', - createRateLimitMiddleware({ - scope: 'admin:login', - windowMs: 60_000, - max: 10, - key: getBodyFieldRateLimitKey('username'), - }), + adminLoginIpRateLimit, + adminLoginAccountRateLimit, createJsonHandler( (req) => loginAdmin(req.body?.username, req.body?.password, { diff --git a/security-artifacts/BASELINE_admin_auth.ts b/security-artifacts/BASELINE_admin_auth.ts new file mode 100644 index 00000000..c8b17c6c --- /dev/null +++ b/security-artifacts/BASELINE_admin_auth.ts @@ -0,0 +1,59 @@ +import { Router } from 'express' + +import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' +import { + getAdminSessionSummary, + loginAdmin, + logoutAdmin, +} from '../../services/admin/admin-auth-service.js' +import { + resolveClientIp, + resolveClientLocation, + resolveUserAgent, +} from '../../services/admin/admin-login-log-service.js' +import { createJsonHandler, extractBearerToken } from './session.js' + +const router = Router() + +router.post( + '/auth/login', + createRateLimitMiddleware({ + scope: 'admin:login', + windowMs: 60_000, + max: 10, + key: getBodyFieldRateLimitKey('username'), + }), + createJsonHandler( + (req) => + loginAdmin(req.body?.username, req.body?.password, { + ip: resolveClientIp(req), + userAgent: resolveUserAgent(req), + location: resolveClientLocation(req), + }), + { + successMessage: '登录成功', + errorMessage: '后台登录失败', + scope: '[admin/auth/login]', + }, + ), +) + +router.get( + '/auth/session', + createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), { + successMessage: 'ok', + errorMessage: '读取后台登录态失败', + scope: '[admin/auth/session]', + }), +) + +router.post( + '/auth/logout', + createJsonHandler((req) => logoutAdmin(extractBearerToken(req)), { + successMessage: '已退出登录', + errorMessage: '后台退出失败', + scope: '[admin/auth/logout]', + }), +) + +export default router diff --git a/security-artifacts/BASELINE_rate-limit.test.ts b/security-artifacts/BASELINE_rate-limit.test.ts new file mode 100644 index 00000000..a7231e3c --- /dev/null +++ b/security-artifacts/BASELINE_rate-limit.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import type { NextFunction, Request, Response } from 'express' + +import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js' + +test('createRateLimitMiddleware allows requests within the window', () => { + resetRateLimitBucketsForTest() + + const limiter = createRateLimitMiddleware({ + scope: 'test:allow', + windowMs: 60_000, + max: 2, + }) + const req = createMockRequest() + const res = createMockResponse() + let nextCount = 0 + const next: NextFunction = () => { + nextCount += 1 + } + + limiter(req, res, next) + limiter(req, res, next) + + assert.equal(nextCount, 2) + assert.equal(res.statusCode, 200) +}) + +test('createRateLimitMiddleware blocks requests over the limit', () => { + resetRateLimitBucketsForTest() + + const limiter = createRateLimitMiddleware({ + scope: 'test:block', + windowMs: 60_000, + max: 1, + }) + const req = createMockRequest() + const res = createMockResponse() + let nextCount = 0 + const next: NextFunction = () => { + nextCount += 1 + } + + limiter(req, res, next) + limiter(req, res, next) + + assert.equal(nextCount, 1) + assert.equal(res.statusCode, 429) + assert.equal(res.headers['Retry-After'], '60') + assert.equal(res.body.errorCode, 'rate_limited') +}) + +test('createRateLimitMiddleware supports custom limit responses', () => { + resetRateLimitBucketsForTest() + + const limiter = createRateLimitMiddleware({ + scope: 'test:custom', + windowMs: 60_000, + max: 1, + onLimit: (_req, res) => { + res.status(200).json({ code: 429, message: 'limited' }) + }, + }) + const req = createMockRequest() + const res = createMockResponse() + + limiter(req, res, () => {}) + limiter(req, res, () => {}) + + assert.equal(res.statusCode, 200) + assert.deepEqual(res.body, { code: 429, message: 'limited' }) +}) + +function createMockRequest(): Request { + return { + ip: '127.0.0.1', + originalUrl: '/test', + headers: {}, + socket: { + remoteAddress: '127.0.0.1', + }, + } as Request +} + +function createMockResponse(): Response & { + statusCode: number + headers: Record + body: any +} { + const res = { + statusCode: 200, + headers: {} as Record, + body: null as any, + setHeader(name: string, value: string) { + this.headers[name] = value + return this + }, + status(statusCode: number) { + this.statusCode = statusCode + return this + }, + json(body: any) { + this.body = body + return this + }, + } + + return res as Response & { + statusCode: number + headers: Record + body: any + } +} diff --git a/security-artifacts/DIFF_FILE.patch b/security-artifacts/DIFF_FILE.patch new file mode 100644 index 00000000..64c47add --- /dev/null +++ b/security-artifacts/DIFF_FILE.patch @@ -0,0 +1,29 @@ +diff --git a/apps/backend/src/routes/admin/auth.ts b/apps/backend/src/routes/admin/auth.ts +--- a/apps/backend/src/routes/admin/auth.ts ++++ b/apps/backend/src/routes/admin/auth.ts +@@ + const router = Router() ++ ++const adminLoginIpRateLimit = createRateLimitMiddleware({ ++ scope: 'admin:login:ip', ++ windowMs: 60_000, ++ max: 20, ++}) ++ ++const adminLoginAccountRateLimit = createRateLimitMiddleware({ ++ scope: 'admin:login:account', ++ windowMs: 60_000, ++ max: 10, ++ key: getBodyFieldRateLimitKey('username'), ++}) + + router.post( + '/auth/login', +- createRateLimitMiddleware({ +- scope: 'admin:login', +- windowMs: 60_000, +- max: 10, +- key: getBodyFieldRateLimitKey('username'), +- }), ++ adminLoginIpRateLimit, ++ adminLoginAccountRateLimit, diff --git a/security-artifacts/MODIFIED_FILE.ts b/security-artifacts/MODIFIED_FILE.ts new file mode 100644 index 00000000..723e5c06 --- /dev/null +++ b/security-artifacts/MODIFIED_FILE.ts @@ -0,0 +1,68 @@ +import { Router } from 'express' + +import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' +import { + getAdminSessionSummary, + loginAdmin, + logoutAdmin, +} from '../../services/admin/admin-auth-service.js' +import { + resolveClientIp, + resolveClientLocation, + resolveUserAgent, +} from '../../services/admin/admin-login-log-service.js' +import { createJsonHandler, extractBearerToken } from './session.js' + +const router = Router() + +const adminLoginIpRateLimit = createRateLimitMiddleware({ + scope: 'admin:login:ip', + windowMs: 60_000, + max: 20, +}) + +const adminLoginAccountRateLimit = createRateLimitMiddleware({ + scope: 'admin:login:account', + windowMs: 60_000, + max: 10, + key: getBodyFieldRateLimitKey('username'), +}) + +router.post( + '/auth/login', + adminLoginIpRateLimit, + adminLoginAccountRateLimit, + createJsonHandler( + (req) => + loginAdmin(req.body?.username, req.body?.password, { + ip: resolveClientIp(req), + userAgent: resolveUserAgent(req), + location: resolveClientLocation(req), + }), + { + successMessage: '登录成功', + errorMessage: '后台登录失败', + scope: '[admin/auth/login]', + }, + ), +) + +router.get( + '/auth/session', + createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), { + successMessage: 'ok', + errorMessage: '读取后台登录态失败', + scope: '[admin/auth/session]', + }), +) + +router.post( + '/auth/logout', + createJsonHandler((req) => logoutAdmin(extractBearerToken(req)), { + successMessage: '已退出登录', + errorMessage: '后台退出失败', + scope: '[admin/auth/logout]', + }), +) + +export default router diff --git a/security-artifacts/ROLLBACK.sh b/security-artifacts/ROLLBACK.sh new file mode 100755 index 00000000..23cc84ac --- /dev/null +++ b/security-artifacts/ROLLBACK.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${1:?usage: ROLLBACK.sh }" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +cp "$SCRIPT_DIR/BASELINE_admin_auth.ts" "$ROOT/apps/backend/src/routes/admin/auth.ts" +cp "$SCRIPT_DIR/BASELINE_rate-limit.test.ts" "$ROOT/apps/backend/src/middleware/rate-limit.test.ts" +printf 'restored admin auth route and rate-limit test in %s\n' "$ROOT" diff --git a/security-artifacts/VERIFICATION.txt b/security-artifacts/VERIFICATION.txt new file mode 100644 index 00000000..36b7aae6 --- /dev/null +++ b/security-artifacts/VERIFICATION.txt @@ -0,0 +1,37 @@ +Security review: admin login brute-force protection + +Changed branch/field: +- apps/backend/src/routes/admin/auth.ts, POST /auth/login middleware chain +- Added admin:login:ip (20 requests / 60 seconds per client IP) +- Kept admin:login:account (10 requests / 60 seconds per client IP + username) + +Artifacts: +- MODIFIED_FILE: /Users/yml/codes/order_site/security-artifacts/MODIFIED_FILE.ts +- DIFF_FILE: /Users/yml/codes/order_site/security-artifacts/DIFF_FILE.patch +- VERIFICATION.txt: /Users/yml/codes/order_site/security-artifacts/VERIFICATION.txt +- ROLLBACK.sh: /Users/yml/codes/order_site/security-artifacts/ROLLBACK.sh + +BASELINE +Command: npm test -- --test-name-pattern='createRateLimitMiddleware' +Input: repository HEAD sources before the route/test edits +Literal result: tests 371; pass 369; fail 0; skipped 2; exit status 0 + +MODIFIED +Command: node --import tsx --test src/middleware/rate-limit.test.ts +Input: modified rate-limit middleware test source +Literal result: tests 4; pass 4; fail 0; skipped 0; exit status 0 + +Command: npm run check +Input: modified backend source tree +Literal result: format check passed; SQL guard passed; ESLint passed; TypeScript passed; tests 373; pass 371; fail 0; skipped 2; build completed; exit status 0 + +ROLLBACK +Command: security-artifacts/ROLLBACK.sh security-artifacts/rollback-copy +Input: independent copy containing MODIFIED_FILE.ts and BASELINE_rate-limit.test.ts +Literal result: restored admin auth route and rate-limit test in security-artifacts/rollback-copy; baseline admin auth SHA-256 d1afed851de0dce8704f4233e1b9339a33a88d1ae9c89d6639aa104be7ff8aff; exit status 0 +Restored behavior/status: rollback-copy/apps/backend/src/routes/admin/auth.ts matches the baseline hash; the working source remains modified. + +Original source hash before modification: +d1afed851de0dce8704f4233e1b9339a33a88d1ae9c89d6639aa104be7ff8aff +Modified copy hash: +c70ca222c13245a6e1714f1c90f3592f5c4fd3713a1ef3af5fb4073eb9b9db95 diff --git a/security-artifacts/rollback-copy/apps/backend/src/middleware/rate-limit.test.ts b/security-artifacts/rollback-copy/apps/backend/src/middleware/rate-limit.test.ts new file mode 100644 index 00000000..a7231e3c --- /dev/null +++ b/security-artifacts/rollback-copy/apps/backend/src/middleware/rate-limit.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import type { NextFunction, Request, Response } from 'express' + +import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js' + +test('createRateLimitMiddleware allows requests within the window', () => { + resetRateLimitBucketsForTest() + + const limiter = createRateLimitMiddleware({ + scope: 'test:allow', + windowMs: 60_000, + max: 2, + }) + const req = createMockRequest() + const res = createMockResponse() + let nextCount = 0 + const next: NextFunction = () => { + nextCount += 1 + } + + limiter(req, res, next) + limiter(req, res, next) + + assert.equal(nextCount, 2) + assert.equal(res.statusCode, 200) +}) + +test('createRateLimitMiddleware blocks requests over the limit', () => { + resetRateLimitBucketsForTest() + + const limiter = createRateLimitMiddleware({ + scope: 'test:block', + windowMs: 60_000, + max: 1, + }) + const req = createMockRequest() + const res = createMockResponse() + let nextCount = 0 + const next: NextFunction = () => { + nextCount += 1 + } + + limiter(req, res, next) + limiter(req, res, next) + + assert.equal(nextCount, 1) + assert.equal(res.statusCode, 429) + assert.equal(res.headers['Retry-After'], '60') + assert.equal(res.body.errorCode, 'rate_limited') +}) + +test('createRateLimitMiddleware supports custom limit responses', () => { + resetRateLimitBucketsForTest() + + const limiter = createRateLimitMiddleware({ + scope: 'test:custom', + windowMs: 60_000, + max: 1, + onLimit: (_req, res) => { + res.status(200).json({ code: 429, message: 'limited' }) + }, + }) + const req = createMockRequest() + const res = createMockResponse() + + limiter(req, res, () => {}) + limiter(req, res, () => {}) + + assert.equal(res.statusCode, 200) + assert.deepEqual(res.body, { code: 429, message: 'limited' }) +}) + +function createMockRequest(): Request { + return { + ip: '127.0.0.1', + originalUrl: '/test', + headers: {}, + socket: { + remoteAddress: '127.0.0.1', + }, + } as Request +} + +function createMockResponse(): Response & { + statusCode: number + headers: Record + body: any +} { + const res = { + statusCode: 200, + headers: {} as Record, + body: null as any, + setHeader(name: string, value: string) { + this.headers[name] = value + return this + }, + status(statusCode: number) { + this.statusCode = statusCode + return this + }, + json(body: any) { + this.body = body + return this + }, + } + + return res as Response & { + statusCode: number + headers: Record + body: any + } +} diff --git a/security-artifacts/rollback-copy/apps/backend/src/routes/admin/auth.ts b/security-artifacts/rollback-copy/apps/backend/src/routes/admin/auth.ts new file mode 100644 index 00000000..c8b17c6c --- /dev/null +++ b/security-artifacts/rollback-copy/apps/backend/src/routes/admin/auth.ts @@ -0,0 +1,59 @@ +import { Router } from 'express' + +import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' +import { + getAdminSessionSummary, + loginAdmin, + logoutAdmin, +} from '../../services/admin/admin-auth-service.js' +import { + resolveClientIp, + resolveClientLocation, + resolveUserAgent, +} from '../../services/admin/admin-login-log-service.js' +import { createJsonHandler, extractBearerToken } from './session.js' + +const router = Router() + +router.post( + '/auth/login', + createRateLimitMiddleware({ + scope: 'admin:login', + windowMs: 60_000, + max: 10, + key: getBodyFieldRateLimitKey('username'), + }), + createJsonHandler( + (req) => + loginAdmin(req.body?.username, req.body?.password, { + ip: resolveClientIp(req), + userAgent: resolveUserAgent(req), + location: resolveClientLocation(req), + }), + { + successMessage: '登录成功', + errorMessage: '后台登录失败', + scope: '[admin/auth/login]', + }, + ), +) + +router.get( + '/auth/session', + createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), { + successMessage: 'ok', + errorMessage: '读取后台登录态失败', + scope: '[admin/auth/session]', + }), +) + +router.post( + '/auth/logout', + createJsonHandler((req) => logoutAdmin(extractBearerToken(req)), { + successMessage: '已退出登录', + errorMessage: '后台退出失败', + scope: '[admin/auth/logout]', + }), +) + +export default router