Files
ClassHelper/packages/server/src/middleware/security.ts
T
LoveChengke 48e0c89394 fix(server): 纯 HTTP 部署下 CSP 的 upgrade-insecure-requests 会让管理端白屏
helmet 的**默认** CSP 自带 upgrade-insecure-requests,它让浏览器把页面**子资源**(JS/CSS)
请求全部改写成 https。而安装器的默认形态是 http://IP:端口 直连(没有 TLS),改写后的请求
必然连接失败 —— 表现是「HTML 外壳加载出来了(标签页标题正常)、脚本没加载(页面一片空白)」。
2026-10-05 用一行安装脚本在 Ubuntu 24.04 上装完后实测踩到。

改法:securityHeaders() 按请求挑策略 —— req.secure(直连 TLS,或反代下由
x-forwarded-proto 决定)走 buildHelmet(true),否则 buildHelmet(false):

- 明文请求不再下发 upgrade-insecure-requests(置 null 去掉 helmet 的默认指令);
- 明文请求也不再下发 HSTS(按规范浏览器本就忽略,没必要发);
- HTTPS 请求的行为与改动前完全一致,安全性没有被削弱。

本地实测(另起一份服务在 4100、TRUST_PROXY=1,避免扰动开发机上已有的 4000 实例):
明文请求的 CSP 里 upgrade-insecure-requests 出现 0 次、带 X-Forwarded-Proto: https 的
请求里出现 1 次;healthz 与首页均 200;tsc 与 ESLint 均无问题。
2026-10-05 20:02:30 +08:00

126 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { RequestHandler } from 'express';
import helmet from 'helmet';
import { rateLimit } from 'express-rate-limit';
import { env } from '../config/env.js';
import { logger } from '../lib/logger.js';
/**
* 安全响应头(helmet)。
*
* CSP 说明:
* - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式
* - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端
* - 桌面客户端通过 file:// 加载页面,因此不启用 COOP/COEP,资源策略设为 cross-origin
* - helmet 默认 CSP 自带 upgrade-insecure-requests,它会把页面**子资源**请求全部改写成
* https —— 而安装器的默认形态是 http://IP:端口 直连(无 TLS),改写后的请求必然
* 连接失败,表现为「管理端一片空白、标签页标题正常」(2026-10-05 实测踩到)。
* HSTS 在明文响应里按规范本就会被浏览器忽略,但 CSP 指令没有这层豁免,
* 所以这两者都只在 HTTPS 请求下下发(见 buildHelmet 的 httpsRequest 参数)。
*/
function buildHelmet(httpsRequest: boolean): RequestHandler {
const connectSrc = new Set<string>(["'self'", 'ws:', 'wss:']);
if (Array.isArray(env.corsOrigins)) {
for (const origin of env.corsOrigins) connectSrc.add(origin);
}
return helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
'default-src': ["'self'"],
'script-src': ["'self'"],
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:', 'blob:'],
'font-src': ["'self'", 'data:'],
'connect-src': [...connectSrc],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'frame-ancestors': ["'none'"],
'form-action': ["'self'"],
'upgrade-insecure-requests': httpsRequest ? [] : null,
},
},
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
// 允许桌面客户端等跨源场景读取静态资源与接口响应
crossOriginResourcePolicy: { policy: 'cross-origin' },
referrerPolicy: { policy: 'no-referrer' },
hsts: httpsRequest && env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
});
}
export function securityHeaders(): RequestHandler {
const secure = buildHelmet(true);
const plain = buildHelmet(false);
// req.secure:直连 TLS 时为 true;前面有反代时由 x-forwarded-proto 决定
// (app.ts 里 app.set('trust proxy', env.trustProxy) 已生效)
return (req, res, next) => (req.secure ? secure : plain)(req, res, next);
}
/** 通用接口限流:防止单 IP 高频刷接口 */
export function apiRateLimiter(): RequestHandler {
const limiter = rateLimit({
windowMs: 5 * 60 * 1000,
limit: env.isProduction ? 600 : 3000,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: () => !env.rateLimitEnabled,
handler: (req, res) => {
logger.warn(`触发接口限流:${req.ip ?? '-'} ${req.method} ${req.originalUrl}`);
res.status(429).json({
success: false,
data: null,
message: '请求过于频繁,请稍后再试',
code: 'TOO_MANY_REQUESTS',
});
},
});
return limiter;
}
/** 登录接口专用限流:防暴力破解 */
export function loginRateLimiter(): RequestHandler {
return rateLimit({
windowMs: 10 * 60 * 1000,
limit: env.isProduction ? 20 : 200,
standardHeaders: 'draft-7',
legacyHeaders: false,
skipSuccessfulRequests: true,
skip: () => !env.rateLimitEnabled,
handler: (req, res) => {
logger.warn(`登录尝试过于频繁:${req.ip ?? '-'}`);
res.status(429).json({
success: false,
data: null,
message: '登录尝试过于频繁,请 10 分钟后再试',
code: 'TOO_MANY_REQUESTS',
});
},
});
}
/**
* 请求耗时日志(生产 info,开发 debug)。
*
* 静态构建产物(`/assets/*`,带内容哈希、长缓存)**成功的请求不打点**:
* Web 管理端首屏一次就有十几个资源请求,逐条刷 info 会把真正有用的接口日志淹掉,
* 而且每条都要走一次 stdout 写入。失败的静态请求(404 / 500)照常记录 ——
* 那种情况恰恰是最需要看见的(产物缺失、路径不对)。
*/
export function requestLogger(): RequestHandler {
return (req, res, next) => {
const isStaticAsset = req.path.startsWith('/assets/');
const startedAt = process.hrtime.bigint();
res.on('finish', () => {
if (isStaticAsset && res.statusCode < 400) return;
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const message = `${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs.toFixed(1)}ms`;
if (res.statusCode >= 500) logger.error(message);
else if (res.statusCode >= 400) logger.warn(message);
else if (env.isProduction) logger.info(message);
else logger.debug(message);
});
next();
};
}