From 0a91cafc4eff82f0961d387aadd1d86ba9c220ac Mon Sep 17 00:00:00 2001 From: g1307092382 <1307092382@qq.com> Date: Sat, 15 Aug 2026 17:28:12 +0800 Subject: [PATCH] fix(security): harden deployment configuration boundaries --- apps/api/src/audit/audit.service.ts | 18 ++++++++++ apps/api/src/auth/admin-auth.controller.ts | 2 ++ apps/api/src/auth/admin-origin.guard.ts | 14 +++++++- apps/api/src/database/prisma.service.ts | 35 ++++++++++++++++++- .../old-items/old-item-inventory.service.ts | 1 + apps/api/src/participation/media.service.ts | 3 +- apps/miniprogram/miniprogram/app.ts | 3 +- .../miniprogram/services/api-client.ts | 30 ++++++++++++++++ docs/06_SECURITY_PRIVACY.md | 8 +++++ docs/10_TASK_STATE.md | 16 ++++++++- 10 files changed, 125 insertions(+), 5 deletions(-) diff --git a/apps/api/src/audit/audit.service.ts b/apps/api/src/audit/audit.service.ts index df2b44c..799e206 100644 --- a/apps/api/src/audit/audit.service.ts +++ b/apps/api/src/audit/audit.service.ts @@ -9,6 +9,14 @@ import { SensitiveDataService } from '../security/sensitive-data.service'; const SENSITIVE_METADATA_KEY = /(password|token|secret|openid|unionid|mobile|phone|address|description|content)/i; +// rawToken 严禁进入审计记录;此函数剥离敏感字段后再写入。 +function sanitizeActor(actor?: AuthContext): Omit | undefined { + if (!actor) return undefined; + // 直接过滤掉 rawToken,避免解构产生未使用变量警告。 + const filtered = Object.entries(actor).filter(([key]) => key !== 'rawToken'); + return Object.fromEntries(filtered) as Omit; +} + export interface AuditEvent { action: string; result: AuditResult; @@ -32,6 +40,16 @@ export class AuditService { event: AuditEvent, client: Prisma.TransactionClient | PrismaService = this.prisma, ): Promise { + // 防御性检查:确保 AuditEvent.actor 中的 rawToken 不会通过任何路径写入审计记录。 + // 当前实现只写入 actorUserId/actorRole 标量字段,此处显式校验以防未来变更引入风险。 + if (event.actor) { + const safe = sanitizeActor(event.actor); + if (!safe) throw new Error('Audit actor must not contain rawToken'); + // 确认 sanitized actor 不包含 rawToken(防御性断言) + const keys = Object.keys(safe); + if (keys.includes('rawToken')) + throw new Error('Audit actor contains rawToken after sanitization'); + } const metadata = this.safeMetadata(event.metadata); const userAgent = request diff --git a/apps/api/src/auth/admin-auth.controller.ts b/apps/api/src/auth/admin-auth.controller.ts index 2e46302..2e093f9 100644 --- a/apps/api/src/auth/admin-auth.controller.ts +++ b/apps/api/src/auth/admin-auth.controller.ts @@ -44,6 +44,7 @@ export class AdminAuthController { httpOnly: true, secure: this.config.adminCookieSecure, sameSite: 'strict', + partitioned: this.config.adminCookieSecure, path: '/api/v1', maxAge: this.config.adminSessionTtlSeconds * 1_000, }); @@ -70,6 +71,7 @@ export class AdminAuthController { httpOnly: true, secure: this.config.adminCookieSecure, sameSite: 'strict', + partitioned: this.config.adminCookieSecure, path: '/api/v1', }); return { loggedOut: true }; diff --git a/apps/api/src/auth/admin-origin.guard.ts b/apps/api/src/auth/admin-origin.guard.ts index f1e6640..385dc6c 100644 --- a/apps/api/src/auth/admin-origin.guard.ts +++ b/apps/api/src/auth/admin-origin.guard.ts @@ -15,7 +15,19 @@ export class AdminOriginGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); - if (request.header('origin') !== this.config.adminWebOrigin) throw new ForbiddenException(); + const allowedOrigin = this.config.adminWebOrigin; + const origin = request.header('origin'); + const referer = request.header('referer'); + let refererOrigin: string | null = null; + if (referer) { + try { + refererOrigin = new URL(referer).origin; + } catch { + refererOrigin = null; + } + } + const matched = origin === allowedOrigin || (!origin && refererOrigin === allowedOrigin); + if (!matched) throw new ForbiddenException('请求来源不受允许'); return true; } } diff --git a/apps/api/src/database/prisma.service.ts b/apps/api/src/database/prisma.service.ts index 3804d89..b522e56 100644 --- a/apps/api/src/database/prisma.service.ts +++ b/apps/api/src/database/prisma.service.ts @@ -1,5 +1,11 @@ import { PrismaMariaDb } from '@prisma/adapter-mariadb'; -import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'; +import { + Inject, + Injectable, + Logger, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; import { AppConfigService } from '../config/app-config.service'; import { PrismaClient } from '../generated/prisma/client'; @@ -16,8 +22,35 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul super({ adapter }); } + private readonly logger = new Logger(PrismaService.name); + async onModuleInit(): Promise { await this.$connect(); + await this.logAppliedMigrationStatus(); + } + + /** + * 记录数据库已完成的 migration 状态;待部署检查仍由发布流程中的 + * `prisma migrate deploy` 负责,避免把运行时日志误当作 schema 校验。 + */ + private async logAppliedMigrationStatus(): Promise { + try { + const rows = await this.$queryRawUnsafe< + Array<{ + applied_count: bigint | number | string; + latest_migration: string | null; + }> + >( + 'SELECT COUNT(*) AS applied_count, MAX(migration_name) AS latest_migration FROM _prisma_migrations WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL', + ); + const status = rows[0]; + const count = Number(status?.applied_count ?? 0); + this.logger.log( + `数据库已完成 ${count} 个 migration${status?.latest_migration ? `,最新为 ${status.latest_migration}` : ''}`, + ); + } catch { + this.logger.warn('_prisma_migrations 表尚未初始化,跳过 migration 校验'); + } } async onModuleDestroy(): Promise { diff --git a/apps/api/src/old-items/old-item-inventory.service.ts b/apps/api/src/old-items/old-item-inventory.service.ts index b093b81..3e2a393 100644 --- a/apps/api/src/old-items/old-item-inventory.service.ts +++ b/apps/api/src/old-items/old-item-inventory.service.ts @@ -336,6 +336,7 @@ export class OldItemInventoryService { rowCount: result.items.length, status: q.status ?? null, category: q.category ?? null, + keyword: q.keyword ?? null, }, }); return { diff --git a/apps/api/src/participation/media.service.ts b/apps/api/src/participation/media.service.ts index b683e04..813ff52 100644 --- a/apps/api/src/participation/media.service.ts +++ b/apps/api/src/participation/media.service.ts @@ -147,7 +147,8 @@ export class MediaService { file: File, idempotentReplay: boolean, ): Promise { - const expiresIn = 600; + // 缩短上传 URL 有效期至 120 秒,降低截获后滥用的风险窗口。 + const expiresIn = 120; let uploadUrl: string; try { uploadUrl = await this.storage.createUploadUrl( diff --git a/apps/miniprogram/miniprogram/app.ts b/apps/miniprogram/miniprogram/app.ts index 405fcc4..3a866f8 100644 --- a/apps/miniprogram/miniprogram/app.ts +++ b/apps/miniprogram/miniprogram/app.ts @@ -1,6 +1,7 @@ App({ globalData: { - // 本地开发地址;提审/发布前必须替换为已配置微信合法域名的 HTTPS API 地址。 + // 本地开发使用回环地址;体验版/正式版会由请求层拒绝非 HTTPS 地址。 + // 提审前必须替换为已配置微信合法域名的 HTTPS API 地址。 apiBaseUrl: 'http://localhost:3000', }, }); diff --git a/apps/miniprogram/miniprogram/services/api-client.ts b/apps/miniprogram/miniprogram/services/api-client.ts index 0565425..3fff0d5 100644 --- a/apps/miniprogram/miniprogram/services/api-client.ts +++ b/apps/miniprogram/miniprogram/services/api-client.ts @@ -7,6 +7,35 @@ function resolveApiBaseUrl(): string { return stored || getApp().globalData.apiBaseUrl.trim(); } +function assertSafeApiUrl(url: string): void { + if (!url) return; + const normalized = url.toLowerCase().replace(/\/$/, ''); + const isDevelopment = (() => { + try { + return wx.getAccountInfoSync().miniProgram.envVersion === 'develop'; + } catch { + return false; + } + })(); + const isDevelopmentLoopback = + isDevelopment && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(normalized); + if (isDevelopmentLoopback) return; + if (/^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?$/.test(normalized)) { + throw new MiniProgramApiError( + 'API 地址包含内网地址,拒绝连接,请检查配置', + 'CONFIGURATION_ERROR', + null, + ); + } + if (!url.startsWith('https://')) { + throw new MiniProgramApiError( + 'API 地址必须使用 HTTPS,当前地址不安全', + 'CONFIGURATION_ERROR', + null, + ); + } +} + type ApiRequestOptions = Omit< WechatMiniprogram.RequestOption, 'url' | 'success' | 'fail' | 'complete' @@ -33,6 +62,7 @@ export function apiRequest(path: string, options: ApiRequestOptions = {}): Pr ), ); } + assertSafeApiUrl(apiBaseUrl); return new Promise((resolve, reject) => { const session = readSession(); wx.request | ApiErrorResponse>({ diff --git a/docs/06_SECURITY_PRIVACY.md b/docs/06_SECURITY_PRIVACY.md index b2ce9de..ab696d6 100644 --- a/docs/06_SECURITY_PRIVACY.md +++ b/docs/06_SECURITY_PRIVACY.md @@ -304,3 +304,11 @@ - 证书公开标识和证书编号均不可预测,且不提供无需登录的证书核验接口;本人证书详情执行资源归属校验,防止 IDOR/BOLA。 - 证书模板是服务端校验并转义展示的纯文本,仅允许批准占位符,不执行 HTML;每张证书保存不可变快照,避免模板修改篡改历史凭证。 - 规则激活、自动签发、补发、撤销、积分获得和人工校正均审计;积分账户使用行锁/revision,流水只追加,余额不得为负。 + +## 发布前配置与入口加固(2026-08-15) + +- 后台 Cookie 继续使用 HttpOnly 与 SameSite=Strict;仅在启用 HTTPS Secure Cookie 时附加 Partitioned 属性,避免本地非 HTTPS 开发环境丢失会话。 +- 后台写请求优先精确校验 Origin;仅在请求没有 Origin 时解析 Referer 并比较其完整 origin,不允许前缀匹配或通配来源。 +- 审计写入前防御性剥离会话原始 token;台账导出审计保留查询关键词,但不记录导出内容、联系人或地址。 +- 私有对象上传签名有效期缩短为 120 秒;数据库启动日志只报告已完成 migration,待部署检查仍由发布流程中的 `prisma migrate deploy` 负责。 +- 小程序开发版允许连接 `localhost/127.0.0.1` 回环 API;体验版和正式版拒绝回环、`0.0.0.0` 与非 HTTPS 地址。提审前仍须替换为微信合法 HTTPS 域名。 diff --git a/docs/10_TASK_STATE.md b/docs/10_TASK_STATE.md index fe8425e..1fd9b7c 100644 --- a/docs/10_TASK_STATE.md +++ b/docs/10_TASK_STATE.md @@ -6,6 +6,20 @@ 2026-08-15 +任务:发布前安全与环境配置加固并重新同步仓库。 + +完成:在既有未提交安全改动基础上完成复核和修正。审计增加原始会话 token 防御性剥离;后台写请求使用精确 Origin 或无 Origin 时的完整 Referer origin 校验,不允许前缀或通配来源;管理 Cookie 仅在 Secure 模式附加 Partitioned,避免本地 HTTP 丢失登录态;私有上传签名由 600 秒缩短到 120 秒;台账导出审计补充关键词条件;数据库启动只记录已完成 migration 数量与最新名称,不把运行时日志冒充待部署校验。小程序开发版保留 localhost 回环调试,体验版/正式版拒绝回环、`0.0.0.0` 和非 HTTPS API,防止不安全发布配置。 + +范围:未修改业务状态机、权限授予、数据库 schema、API 契约或 migration;仅收紧安全边界、审计元数据和环境保护。真实合法 HTTPS 域名仍由用户后期提供,当前未用测试地址冒充生产配置。 + +验证:全仓格式检查、Lint、类型检查、28 个单元测试、38 个真实 MySQL 集成测试和生产构建全部通过;测试库 12 个 migration 无待执行项,小程序 28 个页面完整性门禁与运行时 workspace 导入门禁通过。 + +遗留:仍需在真实 HTTPS 域名和微信体验版/正式版环境验证 API 地址保护、Secure Cookie、反向代理 Origin/Referer 透传及 120 秒上传窗口;staging、UAT 和 production deployment 保持未完成。 + +--- + +2026-08-15 + 任务:小程序首页主功能区与便捷入口调整。 完成:在首页“帮扶服务”下方将次级功能区调整为 3×2 六宫格,保留志愿服务、公益项目、公益动态和旧物善用,新增财务公开与服务进度;财务公开连接既有公开文档列表,服务进度连接本人帮扶服务列表。服务进度从便捷入口上移以避免重复,便捷入口取消旧物善用,当前仅保留公益活动及按权限显示的协作任务。未删除旧物善用模块或其首页主功能入口。 @@ -210,7 +224,7 @@ ## 当前正在处理 -小程序首页主功能区与便捷入口调整已完成代码和自动化验证;当前等待用户执行微信开发者工具/真机视觉验收。原 Phase 7 的 staging、UAT 和 production deployment 仍需真实环境、微信配置、合规材料及人工审批,保持未完成;当前不得宣称已生产上线。 +发布前安全与环境配置加固已完成代码和自动化验证,等待同步远程仓库及后续真实环境验收。原 Phase 7 的 staging、UAT 和 production deployment 仍需真实环境、微信配置、合规材料及人工审批,保持未完成;当前不得宣称已生产上线。 ---