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; +}