从一份渗透报告开始的安全加固

项目:SGCC 智能服务控制台 · 学习记录

起因

攻击方递来一份渗透报告,两个高危:

1
2
3
4
5
6
7
8
9
1. 目录遍历
GET http://x.x.x.x:3000/@fs/server/index.js
GET http://x.x.x.x:3000/@fs/node_modules/pg/lib/client.js
GET http://x.x.x.x:3000/@fs/node_modules/multer/README.md
GET http://x.x.x.x:3000/@fs/node_modules/express/index.js

2. 任意文件读取(CVE-2025-30208)
GET http://x.x.x.x:3000/@fs/etc/passwd?raw
→ 直接读出 /etc/passwd 的内容

我看完第一反应是:完了,源码全裸了。

根因:把开发服务器丢到生产

/@fs/ 是 Vite 开发服务器(dev server)专用的源码读取端点,给热更新(HMR)用的。它的设计前提是「跑在开发者本机」,所以默认对源码、node_modules 一律放行。

我们的 frontend/start.sh 末尾是这一行:

1
npm run dev

npm run dev 实际跑的是:

1
concurrently "node server/index.js" "vite"

vite 这个进程是 dev server。它本来该绑 localhost,但 vite.config.ts 里写的是:

1
2
3
4
server: {
host: '0.0.0.0', // 监听所有网卡
port: 3000,
}

加上 nginx 把 / 反代到 frontend:3000,相当于把 dev server 的所有暴露面原样搬到了公网。

CVE-2025-30208 就是 Vite 6.2 系列对 ?raw 这种 query 后缀的 server.fs 校验有绕过——即使配置了限制路径,加个 ?raw 就能读任意文件。

一句话总结

dev 工具链是为了开发体验设计的,安全模型默认是「内网/本机」。把它当生产服务器用,等于把一栋只装了纱窗的房子直接放在闹市。


修复方案:分两步

第一步:移除 dev server,改成纯静态 + 反向代理

正确的生产方式:

1
2
3
浏览器 ─► nginx:80
├── / → 静态文件 frontend/dist/ (vite build 产出)
└── /api/* → http://api:3001 (Express)

具体动作:

  1. frontend/package.jsonvite build 一次,产出 dist/
  2. nginx 的 root 指向 dist/try_files 兜底走 index.html
  3. dev server 完全不在生产容器里跑

nginx.conf 顺带把所有 dev 工具路径主动 404,做兜底:

1
2
3
location ~ ^/(@fs|@vite|@id|@react-refresh|node_modules|__vite_ping|src/) {
return 404;
}

这样即使将来误操作把 dev server 起来了,公网也访问不到这些路径。

第二步:升级 Vite + 收紧本地 dev

1
"vite": "^6.4.2"   // 修掉 CVE-2025-30208

本地开发的 vite.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
server: {
host: '127.0.0.1', // 不再绑 0.0.0.0
strictPort: true,
fs: {
strict: true, // 启用 fs 沙箱
allow: [path.resolve(__dirname)], // 只允许项目根目录
deny: [
'.env', '.env.*',
'auth.key',
'**/*.{pem,key,crt,p12}',
],
},
}

「即使本地开发也按生产标准防御」——多一道门没坏处。


顺带发现的其他坑

挨个审过一遍代码后,又拎出来不少问题。下面是当时记的清单:

1. CORS 反射 + credentials

1
cors({ origin: true, credentials: true })  // ❌

origin: true 把请求来源原样回写到 Access-Control-Allow-Origin。配合 credentials: true,等于允许任意网站带着用户 cookie 调你的 API。

虽然 cookie 本身有 SameSite=Strict 兜底,但配置本身是反模式。

修法:精确白名单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);

if (allowedOrigins.length > 0) {
app.use(cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true); // 同源
if (allowedOrigins.includes(origin)) return cb(null, true);
cb(new Error(`CORS: origin ${origin} not allowed`));
},
credentials: true,
}));
}

默认根本不启用 CORS(同源部署不需要),只有显式配置 ALLOWED_ORIGINS 才放行。

2. AES 密钥可退化为 scrypt + 固定盐

老代码:

1
2
3
const key = Buffer.from(ENCRYPTION_KEY, 'hex').length === 32
? Buffer.from(ENCRYPTION_KEY, 'hex')
: crypto.scryptSync(ENCRYPTION_KEY, 'salt', 32); // ❌ salt 是字符串 'salt'

如果 ENCRYPTION_KEY 不是 64-hex,就走 scrypt 派生,salt 是字面量 'salt'——等于无盐 KDF,多实例可预测。

修法:直接拒绝。

1
2
3
4
5
6
if (rawEncryptionKey && /^[0-9a-f]{64}$/i.test(rawEncryptionKey)) {
return Buffer.from(rawEncryptionKey, 'hex');
}
if (IS_PRODUCTION) {
throw new Error('[FATAL] 生产环境必须通过 ENCRYPTION_KEY 提供 64 位 hex(32 字节)密钥');
}

部署文档同步给生成命令:openssl rand -hex 32

3. 验证码与登录无任何速率限制

POST /api/auth/send-code 没限流——攻击者刷你的 SMTP 配额,把发件人邮箱送进黑名单。

POST /api/auth/login 也没限流——6 位验证码 100 万种组合,3 分钟有效,理论上能在窗口内穷举。

修法:express-rate-limit + 单码尝试计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const sendCodeLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 3,
keyGenerator: req => `${req.ip}:${(req.body && req.body.email) || ''}`,
});

const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
});

// 验证码尝试计数
stored.attempts = (stored.attempts || 0) + 1;
if (stored.attempts > MAX_CODE_ATTEMPTS) {
verificationCodes.delete(email);
return res.status(429).json({ error: '验证码尝试次数过多,请重新获取' });
}

4. 客户端可任意修改 record_path / transcript

PUT /api/calls/:id 的字段白名单原本包含 record_pathtranscriptali_transcriptconversation_id。一个登录用户能把某条记录的 record_path 改成任意字符串,配合下游的录音流式接口潜在地枚举服务器文件。

修法:把这些字段从客户端可写白名单里删掉——它们只该被后端流水线写入。

1
2
3
4
5
6
7
const allowed = new Set([
'caller', 'unit', 'phone', 'timestamp', 'duration',
'summary', 'category', 'urgency_level', 'sentiment_index', 'status',
'ai_keywords',
'caller_id_last4', 'caller_role', 'caller_history',
// ⛔ 不再允许:transcript / ali_transcript / record_path / conversation_id
]);

做权限设计时永远从「客户端能写哪些字段」反推,而不是「数据库有哪些字段」——这两个集合不是同一个。

5. 错误回显泄露内部信息

老代码到处是:

1
res.status(500).json({ error: '服务器内部错误', details: err.message });

err.message 会带出 SQL、文件路径、第三方库内部信息。生产环境屏蔽:

1
2
3
4
5
6
7
const sendServerError = (res, err, requestId) => {
logEvent('error', 'server_error', { requestId, message: err?.message });
if (IS_PRODUCTION) {
return res.status(500).json({ error: '服务器内部错误', requestId });
}
return res.status(500).json({ error: '服务器内部错误', details: err.message, requestId });
};

详细信息只进日志,响应体里只留 requestId 用于排查。

6. multer 没限大小

1
const upload = multer({ storage: multer.memoryStorage() });   // ❌

文件存内存 + 没限大小。攻击者上传一个 1GB 文件就能 OOM。

1
2
3
4
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 8 * 1024, files: 1 }, // 8KB 足够装 RSA-2048 密文
});

7. helmet 缺失,没有任何安全响应头

加上:

1
2
3
4
app.use(helmet({
contentSecurityPolicy: false, // 静态资源由 nginx 托管,CSP 在 nginx 那层加
crossOriginResourcePolicy: { policy: 'same-site' },
}));

nginx 这边补上:

1
2
3
4
5
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
server_tokens off;

8. 数据库默认弱口令 + 5432 对宿主机暴露

老的 docker-compose.yaml

1
2
3
4
5
6
db:
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: password
ports:
- "5432:5432" # ❌ 公网可达

修法:

  • 凭据全部走 ${VAR:?...} 强制从 .env 注入,缺失即 fail-fast
  • 删掉 ports,DB 只在 docker 内部网络通信
1
2
3
4
5
6
7
8
db:
environment:
POSTGRES_USER: ${DB_USER:?DB_USER 未在根 .env 中提供}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD 未在根 .env 中提供}
POSTGRES_DB: ${DB_NAME:?DB_NAME 未在根 .env 中提供}
# 不再 ports: 5432:5432
networks:
- sgcc-internal

9. 邮箱白名单硬编码到代码

老代码:

1
2
3
4
5
const ALLOWED_EMAILS = [
'2521664384@qq.com',
'461682626@qq.com',
// ...
];

这种东西不是配置而是数据,进了 git 历史就等于对外公开「谁能登录这个系统」。

修法:纯环境变量驱动,仓库里清空。

1
2
3
4
const ALLOWED_EMAILS = (process.env.ALLOWED_EMAILS || '')
.split(',')
.map(e => e.trim().toLowerCase())
.filter(Boolean);

收获与方法论

把上面这些事情摞在一起,能看出几个反复出现的模式:

1. 「最小暴露面」是一道铁律

  • Vite dev server 不该上生产
  • DB 端口不该绑宿主机
  • API 端口不该绑宿主机
  • 错误信息不该原样回客户端
  • 字段白名单不该等于「所有 DB 列」

每加一个对外可见的东西,都问一句:这个真的有人需要从外面访问吗?

2. 「fail closed」优于「fail open」

  • 邮箱白名单为空 → 拒绝所有登录(不是放行所有)
  • ENCRYPTION_KEY 不合规 → 直接拒启动(不是悄悄派生一个弱 key)
  • DB 凭据缺失 → 直接拒启动(不是用 admin/password 兜底)
  • 文件路径越界 → 404(不是尝试解析一下看能不能读)

3. 安全是「分层」的

同一道防御,单层失效不该意味着系统失守:

  • Vite fs.strict + nginx 路径 404 + 生产不跑 dev = 三层都得突破才能目录遍历
  • cookie httpOnly + Secure + SameSite=Strict = 三个独立向量都得绕过才能盗 session
  • 路径校验 path.basename + 允许目录白名单二次校验 = 拼路径攻击得绕过两道

4. 配置 ≠ 代码

  • 邮箱白名单这种「会变的、不该被开发者知道的」放配置
  • 加密算法、流程这种「不该频繁变的」放代码
  • 把数据塞进代码 = 把数据公开

5. 学会读 CVE

CVE-2025-30208 不是天降的——它在 Vite 6.4.2 / 7.3.2 / 8.0.5 / 6.2.3 等版本一发布修复就有对应 CHANGELOG。npm audit 跑一下、docker scan 跑一下,至少把这一类批量发现的漏洞拦在自己的部署之外。


参考索引

修复点 主要文件
移除 dev server / 切静态托管 docker-compose.yaml, nginx/nginx.conf
Vite 升级 + fs.strict frontend/package.json, frontend/vite.config.ts
CORS / helmet / 限流 / 错误回显 frontend/server/index.js
加密密钥强约束 同上
字段白名单收紧 frontend/server/models.js
完整部署清单 docs/DEPLOYMENT.md