fix(security): harden deployment configuration boundaries
This commit is contained in:
@@ -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<AuthContext, 'rawToken'> | undefined {
|
||||
if (!actor) return undefined;
|
||||
// 直接过滤掉 rawToken,避免解构产生未使用变量警告。
|
||||
const filtered = Object.entries(actor).filter(([key]) => key !== 'rawToken');
|
||||
return Object.fromEntries(filtered) as Omit<AuthContext, 'rawToken'>;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
action: string;
|
||||
result: AuditResult;
|
||||
@@ -32,6 +40,16 @@ export class AuditService {
|
||||
event: AuditEvent,
|
||||
client: Prisma.TransactionClient | PrismaService = this.prisma,
|
||||
): Promise<void> {
|
||||
// 防御性检查:确保 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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -15,7 +15,19 @@ export class AdminOriginGuard implements CanActivate {
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<RequestContext>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
await this.$connect();
|
||||
await this.logAppliedMigrationStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录数据库已完成的 migration 状态;待部署检查仍由发布流程中的
|
||||
* `prisma migrate deploy` 负责,避免把运行时日志误当作 schema 校验。
|
||||
*/
|
||||
private async logAppliedMigrationStatus(): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -336,6 +336,7 @@ export class OldItemInventoryService {
|
||||
rowCount: result.items.length,
|
||||
status: q.status ?? null,
|
||||
category: q.category ?? null,
|
||||
keyword: q.keyword ?? null,
|
||||
},
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -147,7 +147,8 @@ export class MediaService {
|
||||
file: File,
|
||||
idempotentReplay: boolean,
|
||||
): Promise<CreateUploadIntentResponse> {
|
||||
const expiresIn = 600;
|
||||
// 缩短上传 URL 有效期至 120 秒,降低截获后滥用的风险窗口。
|
||||
const expiresIn = 120;
|
||||
let uploadUrl: string;
|
||||
try {
|
||||
uploadUrl = await this.storage.createUploadUrl(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
App<IAppOption>({
|
||||
globalData: {
|
||||
// 本地开发地址;提审/发布前必须替换为已配置微信合法域名的 HTTPS API 地址。
|
||||
// 本地开发使用回环地址;体验版/正式版会由请求层拒绝非 HTTPS 地址。
|
||||
// 提审前必须替换为已配置微信合法域名的 HTTPS API 地址。
|
||||
apiBaseUrl: 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,6 +7,35 @@ function resolveApiBaseUrl(): string {
|
||||
return stored || getApp<IAppOption>().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<T>(path: string, options: ApiRequestOptions = {}): Pr
|
||||
),
|
||||
);
|
||||
}
|
||||
assertSafeApiUrl(apiBaseUrl);
|
||||
return new Promise((resolve, reject) => {
|
||||
const session = readSession();
|
||||
wx.request<ApiSuccessResponse<T> | ApiErrorResponse>({
|
||||
|
||||
Reference in New Issue
Block a user