From b4508741e532543e1655524423372e2e5d696c48 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Tue, 21 Apr 2026 09:47:44 +0800 Subject: [PATCH] feat(security): add backend security audit skill and enhance user registration error handling - Introduced a new skill for backend security audits, detailing checks for input validation, authentication, and sensitive data handling. - Enhanced user registration process by adding specific error handling for username conflicts, returning a 409 status code with a user-friendly message. - Improved file upload security by implementing checks for allowed image formats, ensuring only valid raster images are processed. - Updated password hashing to use a stronger algorithm, increasing security for user credentials. - Added tests for image magic byte validation and RSS URL safety checks to ensure robustness in file handling and URL processing. This update significantly strengthens the security posture of the backend and improves user experience during registration. --- .cursor/skills/backend-security-audit/SKILL.md | 85 ++++++++++++++++++++++++++ server/api/auth/register.post.ts | 8 ++- server/api/file/upload.post.ts | 12 ++++ server/service/auth/index.ts | 4 +- server/service/media/index.ts | 7 +++ server/service/rss/index.ts | 7 +-- server/utils/image-magic-bytes.test.ts | 34 +++++++++++ server/utils/image-magic-bytes.ts | 55 +++++++++++++++++ server/utils/rss-url.test.ts | 19 +++++- server/utils/rss-url.ts | 61 +++++++++++++++++- 10 files changed, 283 insertions(+), 9 deletions(-) create mode 100644 .cursor/skills/backend-security-audit/SKILL.md create mode 100644 server/utils/image-magic-bytes.test.ts create mode 100644 server/utils/image-magic-bytes.ts diff --git a/.cursor/skills/backend-security-audit/SKILL.md b/.cursor/skills/backend-security-audit/SKILL.md new file mode 100644 index 0000000..f520d06 --- /dev/null +++ b/.cursor/skills/backend-security-audit/SKILL.md @@ -0,0 +1,85 @@ +--- +name: backend-security-audit +description: Audits backend APIs and services against input validation, SQL/command injection, path traversal, XSS/CSRF, authentication, authorization, sensitive data, logging, and file upload security. Use when reviewing backend security, hardening APIs, pre-release security checks, or when the user mentions 后端安全、安全审计、注入、越权、上传安全. +--- + +# 后端安全审计(清单驱动) + +## 何时使用 + +在审查服务端代码、PR、或回答「这块后端是否安全」时,按下列清单逐项核对,并结合代码证据给出结论。 + +## 工作流 + +1. **划定范围**:列出涉及的 HTTP 路由 / RPC / 定时任务 / 消息消费者、数据库访问、子进程/Shell、文件读写与上传下载。 +2. **对照清单**:对每一项标记 ✅ 已满足 / ⚠️ 部分满足 / ❌ 缺失或风险,并附**文件与行号**或关键代码片段。 +3. **结论**:按严重度汇总(阻断上线 / 建议修复 / 可选加固),避免泛泛而谈。 + +## 输出格式(推荐) + +```markdown +## 后端安全审计摘要 +- 范围:[模块/服务名] +- 总体结论:[通过 / 有条件通过 / 不通过] + +### 阻断项 +- ... + +### 建议项 +- ... + +### 核对清单 +[复制下方对应章节 checklist,逐项填写 ✅⚠️❌ + 证据] +``` + +--- + +## 一、输入校验与注入防护 + +核对时优先看:请求体/Query/Path/Header、ORM 原生 SQL、文件路径、命令行拼接、模板/HTML 输出、跨域与 CSRF 配置。 + +- [ ] **入参校验**:所有用户入参严格校验——长度、类型、格式、枚举范围(含空值与边界)。 +- [ ] **SQL 注入**:杜绝字符串拼接 SQL;全部使用预编译参数化查询或 ORM 的安全 API。 +- [ ] **路径穿越**:校验文件路径;禁止 `../` 等跳转;尽量使用白名单根目录 + `realpath`/规范化后比对前缀。 +- [ ] **命令注入**:不将用户可控字符串直接拼进系统命令;若必须调用外部命令,使用固定参数列表并避免 shell。 +- [ ] **XSS**:用户内容输出时转义;不将不可信数据直接当作 HTML/JS 插入(含邮件、PDF、富文本)。 +- [ ] **CSRF**:关键状态变更接口校验 CSRF Token 或等价机制;CORS 不使用 `*` 搭配 credentials;白名单 Origin。 + +## 二、认证与权限安全 + +- [ ] **会话/JWT**:合理过期与刷新策略;算法与密钥强度符合当前实践(如避免弱算法/弱密钥)。 +- [ ] **密钥管理**:密钥与敏感配置不硬编码;使用环境变量、密钥管理服务或配置中心,并限制访问面。 +- [ ] **鉴权覆盖面**:业务接口默认需登录;不存在误暴露的调试或管理接口。 +- [ ] **接口级权限**:基于角色/权限或资源级策略;拒绝仅依赖前端隐藏按钮的「假权限」。 +- [ ] **水平越权**:校验资源归属(如 `userId` 必须等于当前主体);列表/详情/更新/删除一致校验。 +- [ ] **敏感操作**:改密、提现、删数据等支持二次验证(密码/OTP/设备确认等)。 +- [ ] **暴力破解防护**:登录、短信、验证码等接口限流、锁定或递增延迟;统一错误信息防枚举。 + +## 三、敏感数据安全 + +- [ ] **密码存储**:使用 bcrypt/Argon2 等慢哈希;禁止 MD5/SHA1 等作为密码存储方案。 +- [ ] **PII 脱敏**:身份证、手机、银行卡等脱敏存储与展示;最小化采集与保留。 +- [ ] **日志安全**:日志不打印密码、完整 Token、密钥、证件号等;结构化日志注意字段过滤。 +- [ ] **静态加密**:核心敏感字段加密存储;密钥集中管理与轮换策略可执行。 +- [ ] **传输安全**:对外全程 HTTPS;敏感信息不在 URL/Referer 中明文传递。 + +## 四、文件上传/下载安全 + +- [ ] **类型校验**:后缀 + Magic bytes/MIME;不信任客户端 `Content-Type` 单独字段。 +- [ ] **大小限制**:服务端限制单文件与总大小;防止 DoS。 +- [ ] **文件名**:上传后随机命名;不信任用户原始文件名作为存储名或路径分量。 +- [ ] **存储与执行面**:上传目录无执行权限、与应用代码分离;必要时仅通过受控下载接口访问。 +- [ ] **对象存储**:优先对象存储 + 预签名 URL;避免将用户文件放在应用进程可直接执行的路径。 +- [ ] **恶意文件**:禁止可执行脚本/双后缀绕过;对办公文档等按需杀毒或沙箱策略(按业务定级)。 + +## 审查技巧(精简) + +- **SQL**:搜索字符串拼接 SQL、`fmt`/`format` 拼进查询、`$queryRawUnsafe` 等危险 API。 +- **命令**:搜索 `exec`、`spawn` with `shell: true`、反引号拼接命令。 +- **路径**:搜索 `path.join` 与用户输入、`fs.readFile` 与未校验路径。 +- **鉴权**:从路由层 middleware 追到 handler,确认资源 ID 与主体绑定。 +- **上传**:找 multer/formidable 等配置与存储路径、CDN 回源策略。 + +## 附加资源(按需阅读) + +- 框架专项细节(Express/Fastify/Nest/Spring 等)以官方安全指南为准;本 skill 不绑定单一栈。 diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts index dbde34a..9af48b8 100644 --- a/server/api/auth/register.post.ts +++ b/server/api/auth/register.post.ts @@ -1,5 +1,5 @@ import { getRequestIP } from "h3"; -import { registerUser } from "#server/service/auth"; +import { AuthConflictError, registerUser } from "#server/service/auth"; import { toPublicAuthError } from "#server/service/auth/errors"; import { captchaConsume } from "#server/service/captcha/store"; import { assertLoginRegisterCaptchaFieldsPresent } from "#server/service/captcha/validate-body"; @@ -35,6 +35,12 @@ export default defineWrappedResponseHandler(async (event) => { user, }); } catch (err) { + if (err instanceof AuthConflictError) { + throw createError({ + statusCode: 409, + statusMessage: "注册未成功,请调整用户名或稍后再试", + }); + } throw toPublicAuthError(err); } }); diff --git a/server/api/file/upload.post.ts b/server/api/file/upload.post.ts index b3ebbc2..1a5cd2c 100644 --- a/server/api/file/upload.post.ts +++ b/server/api/file/upload.post.ts @@ -6,6 +6,7 @@ import { callNodeListener } from 'h3'; import { R } from '#server/utils/response'; import { MEDIA_IMAGE_MAX_WIDTH_PX, MEDIA_WEBP_QUALITY } from '#server/constants/media'; import { insertMediaAssetRow } from '#server/service/media'; +import { assertDiskFileIsAllowedRasterImage, IMAGE_MAGIC_MISMATCH_MESSAGE } from '#server/utils/image-magic-bytes'; // 类型定义 interface IFile { @@ -87,6 +88,17 @@ export default defineWrappedResponseHandler(async (event) => { const result: IFile[] = []; for (const file of uploadedFiles) { + try { + assertDiskFileIsAllowedRasterImage(file.path); + } catch { + try { + fs.unlinkSync(file.path); + } catch { + /* ignore */ + } + throw createError({ statusCode: 400, statusMessage: IMAGE_MAGIC_MISMATCH_MESSAGE }); + } + const stem = path.parse(file.filename).name; const finalName = `${stem}.webp`; const finalPath = path.join(uploadDir, finalName); diff --git a/server/service/auth/index.ts b/server/service/auth/index.ts index d25929f..940cac8 100644 --- a/server/service/auth/index.ts +++ b/server/service/auth/index.ts @@ -136,7 +136,7 @@ export async function adminProvisionUser(payload: { email?: string | null; }): Promise { validateCredentials(payload); - const passwordHash = await hash(payload.password, 10); + const passwordHash = await hash(payload.password, 12); const user = await insertUserWithRetry(payload.username, passwordHash, payload.email); logger.info("user provisioned by admin: %s", payload.username); return user; @@ -146,7 +146,7 @@ export async function registerUser(payload: AuthPayload): Promise { validateCredentials(payload); const { username, password } = payload; - const passwordHash = await hash(password, 10); + const passwordHash = await hash(password, 12); const newUser = await insertUserWithRetry(username, passwordHash, null); logger.info("user registered: %s", username); diff --git a/server/service/media/index.ts b/server/service/media/index.ts index 799be6b..6212f32 100644 --- a/server/service/media/index.ts +++ b/server/service/media/index.ts @@ -18,6 +18,7 @@ import { MEDIA_REF_OWNER_POST, MEDIA_REF_OWNER_PROFILE } from "#server/constants import { buildRefContextsForAssets, type MediaRefContextDto } from "#server/service/media/ref-context"; import { mergePostMediaUrls, mergeProfileMediaUrls, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls"; import { allowedOriginsFromSitePublicEnv } from "#server/utils/site-public"; +import { assertDiskFileIsAllowedRasterImage, IMAGE_MAGIC_MISMATCH_MESSAGE } from "#server/utils/image-magic-bytes"; import { nextIntegerId } from "#server/utils/sqlite-id"; const NEVER_REF_MS = MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF * 3600 * 1000; @@ -181,6 +182,12 @@ export async function replaceMediaAssetFileFromTempUpload(params: { } try { + assertDiskFileIsAllowedRasterImage(params.tempInputPath); + } catch { + throw createError({ statusCode: 400, statusMessage: IMAGE_MAGIC_MISMATCH_MESSAGE }); + } + + try { await sharp(params.tempInputPath) .rotate() .resize({ diff --git a/server/service/rss/index.ts b/server/service/rss/index.ts index b03f9ae..94597fe 100644 --- a/server/service/rss/index.ts +++ b/server/service/rss/index.ts @@ -5,8 +5,7 @@ import { PUBLIC_LIST_PAGE_SIZE, PUBLIC_PREVIEW_LIMIT } from "#server/constants/p import { normalizePublicListPage } from "#server/utils/public-pagination"; import { and, count, desc, eq } from "drizzle-orm"; import { XMLParser } from "fast-xml-parser"; -import { assertSafeRssUrl } from "#server/utils/rss-url"; -import { RssUrlUnsafeError } from "#server/utils/rss-url"; +import { assertSafeRssUrlForFetch, RssUrlUnsafeError } from "#server/utils/rss-url"; import { nextIntegerId } from "#server/utils/sqlite-id"; import { visibilityShareToken } from "#server/utils/share-token"; @@ -59,7 +58,7 @@ export async function listFeedsForUser(userId: number) { } export async function addFeed(userId: number, feedUrl: string) { - const url = assertSafeRssUrl(feedUrl.trim()); + const url = await assertSafeRssUrlForFetch(feedUrl.trim()); const [dup] = await dbGlobal .select({ id: rssFeeds.id }) .from(rssFeeds) @@ -344,7 +343,7 @@ export async function syncFeed(feedId: number): Promise<{ ok: boolean; error?: s return { ok: false, error: "feed not found" }; } try { - assertSafeRssUrl(feed.feedUrl); + await assertSafeRssUrlForFetch(feed.feedUrl); } catch (e) { const msg = e instanceof RssUrlUnsafeError ? e.message : "unsafe url"; await dbGlobal diff --git a/server/utils/image-magic-bytes.test.ts b/server/utils/image-magic-bytes.test.ts new file mode 100644 index 0000000..cc40070 --- /dev/null +++ b/server/utils/image-magic-bytes.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assertDiskFileIsAllowedRasterImage, IMAGE_MAGIC_MISMATCH_MESSAGE } from "./image-magic-bytes"; + +describe("assertDiskFileIsAllowedRasterImage", () => { + test("rejects non-image bytes", () => { + const dir = mkdtempSync(join(tmpdir(), "img-magic-")); + try { + const p = join(dir, "x.bin"); + writeFileSync(p, Buffer.from("not an image")); + expect(() => assertDiskFileIsAllowedRasterImage(p)).toThrow(IMAGE_MAGIC_MISMATCH_MESSAGE); + } finally { + rmSync(dir, { recursive: true }); + } + }); + + test("accepts jpeg magic prefix", () => { + const dir = mkdtempSync(join(tmpdir(), "img-magic-")); + try { + const p = join(dir, "x.jpg"); + const buf = Buffer.alloc(20, 0); + buf[0] = 0xff; + buf[1] = 0xd8; + buf[2] = 0xff; + buf[3] = 0xe0; + writeFileSync(p, buf); + expect(() => assertDiskFileIsAllowedRasterImage(p)).not.toThrow(); + } finally { + rmSync(dir, { recursive: true }); + } + }); +}); diff --git a/server/utils/image-magic-bytes.ts b/server/utils/image-magic-bytes.ts new file mode 100644 index 0000000..993bc60 --- /dev/null +++ b/server/utils/image-magic-bytes.ts @@ -0,0 +1,55 @@ +import fs from "node:fs"; + +/** 与 multer 白名单一致:仅 PNG / JPEG / WebP(RIFF…WEBP) */ +export const IMAGE_MAGIC_MISMATCH_MESSAGE = "文件内容与真实格式不符,请上传 PNG、JPEG 或 WebP 图片"; + +function matchesAllowedRasterMagic(head: Uint8Array): boolean { + if (head.length < 12) { + return false; + } + // JPEG + if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) { + return true; + } + // PNG + if ( + head[0] === 0x89 + && head[1] === 0x50 + && head[2] === 0x4e + && head[3] === 0x47 + && head[4] === 0x0d + && head[5] === 0x0a + && head[6] === 0x1a + && head[7] === 0x0a + ) { + return true; + } + // WebP (RIFF .... WEBP) + if ( + head[0] === 0x52 + && head[1] === 0x49 + && head[2] === 0x46 + && head[3] === 0x46 + && head[8] === 0x57 + && head[9] === 0x45 + && head[10] === 0x42 + && head[11] === 0x50 + ) { + return true; + } + return false; +} + +/** 读取文件头并校验魔数(不依赖客户端 Content-Type) */ +export function assertDiskFileIsAllowedRasterImage(filePath: string): void { + const fd = fs.openSync(filePath, "r"); + try { + const buf = Buffer.alloc(12); + const n = fs.readSync(fd, buf, 0, 12, 0); + if (n < 12 || !matchesAllowedRasterMagic(buf)) { + throw new Error(IMAGE_MAGIC_MISMATCH_MESSAGE); + } + } finally { + fs.closeSync(fd); + } +} diff --git a/server/utils/rss-url.test.ts b/server/utils/rss-url.test.ts index ec6c963..27431b0 100644 --- a/server/utils/rss-url.test.ts +++ b/server/utils/rss-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { assertSafeRssUrl, RssUrlUnsafeError } from "./rss-url"; +import { assertSafeRssUrl, assertSafeRssUrlForFetch, RssUrlUnsafeError } from "./rss-url"; describe("assertSafeRssUrl", () => { test("allows https example", () => { @@ -18,3 +18,20 @@ describe("assertSafeRssUrl", () => { expect(() => assertSafeRssUrl("http://192.168.1.1/feed")).toThrow(RssUrlUnsafeError); }); }); + +describe("assertSafeRssUrlForFetch", () => { + test("allows public ipv4 literal without dns", async () => { + const u = await assertSafeRssUrlForFetch("https://1.1.1.1/feed.xml"); + expect(u).toContain("1.1.1.1"); + }); + + test("rejects private ipv4 literal", async () => { + await expect(assertSafeRssUrlForFetch("https://10.0.0.1/feed")).rejects.toThrow(RssUrlUnsafeError); + }); + + test("rejects unresolvable host", async () => { + await expect( + assertSafeRssUrlForFetch("http://this-name-should-not-exist-xyz-12345.invalid/feed"), + ).rejects.toThrow(RssUrlUnsafeError); + }); +}); diff --git a/server/utils/rss-url.ts b/server/utils/rss-url.ts index 27d6ac6..396fd24 100644 --- a/server/utils/rss-url.ts +++ b/server/utils/rss-url.ts @@ -1,3 +1,6 @@ +import { lookup } from "node:dns/promises"; +import net, { BlockList } from "node:net"; + export class RssUrlUnsafeError extends Error { constructor(message: string) { super(message); @@ -7,7 +10,30 @@ export class RssUrlUnsafeError extends Error { const PRIVATE_IPV4 = /^(127\.|10\.|172\.(1[6-9]|2\d|3[0-1])\.|192\.168\.|0\.|169\.254\.)/; -/** 仅允许 http(s),主机名为非内网字面量(粗略)。完整 DNS 解析防 SSRF 可在 fetch 前再加强。 */ +/** 解析得到的 IP 若落在此块内则禁止 RSS 出站拉取(含常见私网与链路本地)。 */ +const rssFetchBlocklist = new BlockList(); +rssFetchBlocklist.addSubnet("127.0.0.0", 8, "ipv4"); +rssFetchBlocklist.addSubnet("10.0.0.0", 8, "ipv4"); +rssFetchBlocklist.addSubnet("172.16.0.0", 12, "ipv4"); +rssFetchBlocklist.addSubnet("192.168.0.0", 16, "ipv4"); +rssFetchBlocklist.addSubnet("169.254.0.0", 16, "ipv4"); +rssFetchBlocklist.addSubnet("0.0.0.0", 8, "ipv4"); +rssFetchBlocklist.addSubnet("::1", 128, "ipv6"); +rssFetchBlocklist.addSubnet("fe80::", 10, "ipv6"); +rssFetchBlocklist.addSubnet("fc00::", 7, "ipv6"); + +function isBlockedResolvedIp(addr: string): boolean { + const fam = net.isIP(addr); + if (fam === 4) { + return rssFetchBlocklist.check(addr, "ipv4"); + } + if (fam === 6) { + return rssFetchBlocklist.check(addr, "ipv6"); + } + return false; +} + +/** 仅允许 http(s),主机名为非内网字面量(粗略)。 */ export function assertSafeRssUrl(raw: string) { let url: URL; try { @@ -30,3 +56,36 @@ export function assertSafeRssUrl(raw: string) { } return url.toString(); } + +/** + * 在 `assertSafeRssUrl` 基础上对域名做 DNS 解析,拒绝解析到私网/本地 IP(减轻 DNS 重绑定类 SSRF)。 + * 小型站点可接受:仍存在解析与建连之间的 TOCTOU,高威胁环境需代理或专用抓取服务。 + */ +export async function assertSafeRssUrlForFetch(raw: string): Promise { + const normalized = assertSafeRssUrl(raw); + const url = new URL(normalized); + const hostname = url.hostname; + + if (net.isIP(hostname)) { + if (isBlockedResolvedIp(hostname)) { + throw new RssUrlUnsafeError("禁止内网或本地地址"); + } + return normalized; + } + + let records: { address: string; family: number }[]; + try { + records = await lookup(hostname, { all: true, verbatim: true }); + } catch { + throw new RssUrlUnsafeError("RSS 地址域名解析失败"); + } + if (records.length === 0) { + throw new RssUrlUnsafeError("RSS 地址域名无解析记录"); + } + for (const r of records) { + if (isBlockedResolvedIp(r.address)) { + throw new RssUrlUnsafeError("RSS 地址解析到内网或本地 IP,已拒绝"); + } + } + return normalized; +}