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 均无问题。
This commit is contained in:
1 parent
5d2fa2d6b4
commit
48e0c89394
1 file changed
+16
-2
@@ -11,8 +11,13 @@ import { logger } from '../lib/logger.js';
|
|||||||
* - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式
|
* - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式
|
||||||
* - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端
|
* - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端
|
||||||
* - 桌面客户端通过 file:// 加载页面,因此不启用 COOP/COEP,资源策略设为 cross-origin
|
* - 桌面客户端通过 file:// 加载页面,因此不启用 COOP/COEP,资源策略设为 cross-origin
|
||||||
|
* - helmet 默认 CSP 自带 upgrade-insecure-requests,它会把页面**子资源**请求全部改写成
|
||||||
|
* https —— 而安装器的默认形态是 http://IP:端口 直连(无 TLS),改写后的请求必然
|
||||||
|
* 连接失败,表现为「管理端一片空白、标签页标题正常」(2026-10-05 实测踩到)。
|
||||||
|
* HSTS 在明文响应里按规范本就会被浏览器忽略,但 CSP 指令没有这层豁免,
|
||||||
|
* 所以这两者都只在 HTTPS 请求下下发(见 buildHelmet 的 httpsRequest 参数)。
|
||||||
*/
|
*/
|
||||||
export function securityHeaders(): RequestHandler {
|
function buildHelmet(httpsRequest: boolean): RequestHandler {
|
||||||
const connectSrc = new Set<string>(["'self'", 'ws:', 'wss:']);
|
const connectSrc = new Set<string>(["'self'", 'ws:', 'wss:']);
|
||||||
if (Array.isArray(env.corsOrigins)) {
|
if (Array.isArray(env.corsOrigins)) {
|
||||||
for (const origin of env.corsOrigins) connectSrc.add(origin);
|
for (const origin of env.corsOrigins) connectSrc.add(origin);
|
||||||
@@ -32,6 +37,7 @@ export function securityHeaders(): RequestHandler {
|
|||||||
'base-uri': ["'self'"],
|
'base-uri': ["'self'"],
|
||||||
'frame-ancestors': ["'none'"],
|
'frame-ancestors': ["'none'"],
|
||||||
'form-action': ["'self'"],
|
'form-action': ["'self'"],
|
||||||
|
'upgrade-insecure-requests': httpsRequest ? [] : null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
crossOriginEmbedderPolicy: false,
|
crossOriginEmbedderPolicy: false,
|
||||||
@@ -39,10 +45,18 @@ export function securityHeaders(): RequestHandler {
|
|||||||
// 允许桌面客户端等跨源场景读取静态资源与接口响应
|
// 允许桌面客户端等跨源场景读取静态资源与接口响应
|
||||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
referrerPolicy: { policy: 'no-referrer' },
|
referrerPolicy: { policy: 'no-referrer' },
|
||||||
hsts: env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
|
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 高频刷接口 */
|
/** 通用接口限流:防止单 IP 高频刷接口 */
|
||||||
export function apiRateLimiter(): RequestHandler {
|
export function apiRateLimiter(): RequestHandler {
|
||||||
const limiter = rateLimit({
|
const limiter = rateLimit({
|
||||||
|
|||||||
Reference in new issue
Block a user