我们在上篇文章中实现了应用的性能优化。这篇文章将讨论如何确保应用在受到攻击时依然稳如磐石。关于安全工作的残酷事实是:当一切正常时,没人会注意到它;而一旦出现疏漏,所有人都将察觉。
本文是全栈 SaaS 大师课系列的一部分,该系列将从零构建一个多租户 SaaS 应用。安全是一种贯穿身份验证、数据库、API 层及基础设施的思维方式,而非单篇文章能解决的问题。以下是我在将应用标记为生产就绪前实际使用的检查清单,并附带每项背后的理由,以便您判断哪些适用于自己的应用。
我刻意避免在此罗列四十项内容。OWASP Top Ten 中的大部分内容要么已被框架处理,要么需要达到一定复杂度才可能触发风险。以下是实际在 SaaS 团队中造成痛点的子集。
身份验证与会话处理
如果这里出错,其他一切都无关紧要。如果有人能冒充其他用户,那么限流和输入验证只是在给敞开前门的房子做装饰。
针对 NestJS 后端的几项硬性要求:
- 密码必须使用 bcrypt 或 argon2 进行哈希,绝不能使用自行实现的算法。
- 访问令牌需短生命周期(分钟级而非天级),刷新令牌需在使用时轮换并在服务端存储以便撤销。
- 会话令牌的 Cookie 需设置
httpOnly、secure及sameSite: 'lax'或'strict'(取决于是否需要跨站点请求)。
// auth/cookie.config.ts
export const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000,
};
Enter fullscreen mode Exit fullscreen mode
人们容易低估刷新令牌轮换的权衡:它会增加更多组件——你需要引入令牌家族(token family)概念,以便在轮换后检测到被盗刷新令牌的重复使用,并终止整个会话链。最好在早期就一次性构建,而不是在事件发生后被迫追加。
最常见的陷阱是将 JWT 存储在 localStorage 中,因为这对单页应用而言很方便。在演示中看起来没问题,但一旦前端出现任何 XSS 漏洞、npm 包被攻破或未转义的用户字符串,攻击者就能获取所有活跃会话。httpOnly Cookie 无法被 JavaScript 访问,从而以少量跨源请求的额外开销换取了整个攻击类别的消除。
授权与租户隔离
身份验证回答“您是谁”,授权回答“您可以触碰什么”。在多租户 SaaS 中,后者多了一个维度:您属于哪个租户,以及每个查询是否都已限定在该租户范围内。
造成真正破坏的错误通常是查询忘记按 organizationId 过滤,而不是缺少角色检查。租户 A 的用户最终读取或写入属于租户 B 的行。发现问题通常不是因为权限检查失败,而是支持工单询问为什么某人的仪表板上出现了无关数据。
两种方法可显著降低此风险:
- 永远不要信任来自客户端的租户 ID。从已认证的会话中推导,而不是从请求正文或查询参数中获取。
- 在数据层强制执行,而不仅仅是应用逻辑(只要数据库支持)。
-- Postgres 行级安全作为后备,而非应用层检查的替代
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (organization_id = current_setting('app.current_org_id')::uuid);
Enter fullscreen mode Exit fullscreen mode
行级安全是纵深防御层,而不是银弹。它需要您在每个连接上正确设置会话变量,如果不刻意管理,在连接池中很容易遗忘。尽管如此,我仍然认为值得拥有,因为它意味着 NestJS 服务层的漏洞不会自动变成跨租户数据泄露。应用层检查应是主要防护;RLS 是在检查出现漏洞时的安全带。
输入验证与注入防护
进入系统的每一个外部字符串在被证明安全之前都是不可信的:请求正文、查询参数、请求头、Webhook 负载、文件上传,甚至是您调用的第三方 API 返回的值。
如果您真正使用 NestJS,它会为您提供大部分功能。带有 DTO 的全局验证管道会在请求到达服务之前阻止格式错误和意外负载。
// main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
Enter fullscreen mode Exit fullscreen mode
// dto/create-invoice.dto.ts
import { IsUUID, IsNumber, Min, IsISO8601 } from 'class-validator';
export class CreateInvoiceDto {
@IsUUID()
customerId: string;
@IsNumber()
@Min(0)
amountCents: number;
@IsISO8601()
dueDate: string;
}
Enter fullscreen mode Exit fullscreen mode
whitelist: true 会剥离 DTO 中未声明的任何属性。forbidNonWhitelisted: true 会直接拒绝请求,而不是静默丢弃多余字段。这种组合默默地帮我避免了大量赋值类漏洞——客户端发送 { ...formData, role: 'admin' } 时,未受保护的更新处理器会欣然写入该字段。
如果默认使用带参数化查询的查询构建器或 ORM(Prisma、TypeORM),SQL 注入在很大程度上不是问题。陷阱在于六个月后有人为了绕过 ORM 限制而编写的原始查询——因为拼接字符串比学习参数化语法更快。在代码审查中禁止字符串拼接的 SQL,无一例外,并将其视为阻塞性评论而非小问题。
密钥与配置管理
将密钥提交到源码控制是我见过的最容易避免的生产事故,但由于 .env 文件被提交一次并永远留在 git 历史中,此类事件仍不断发生。
基线要求:从第一次提交起就将 .env 加入 .gitignore,通过部署平台(AWS Secrets Manager、SSM Parameter Store 或 CI/CD 提供商的密钥存储)在运行时注入密钥,并为每个环境使用不同的凭证,以确保被攻破的预发布密钥无法触及生产环境。
# docker-compose.yml(仅限本地开发,生产环境不应采用此方式注入密钥)
services:
api:
env_file:
- .env.local
environment:
- NODE_ENV=development
Enter fullscreen mode Exit fullscreen mode
在生产环境中,不要将明文密钥文件通过 env_file 注入容器镜像或仓库。在部署时从托管存储拉取密钥,并将其作为环境变量注入运行中的进程,这样密钥永远不会触及镜像层或 git 提交。
一个更微妙的陷阱是日志记录:很容易为了调试而记录整个请求对象,却忘记请求中包含 Authorization 头或密码字段。应在日志配置层级而非每个调用点对已知敏感键进行脱敏,因为调用点级的纪律最终会松懈。
// logger/redact.ts
export const REDACTED_KEYS = ['password', 'authorization', 'refreshToken', 'apiKey'];
export function redact(obj: Record<string, unknown>) {
const clone = { ...obj };
for (const key of REDACTED_KEYS) {
if (key in clone) clone[key] = '[REDACTED]';
}
return clone;
}
Enter fullscreen mode Exit fullscreen mode
传输与基础设施加固
全程 HTTPS 是基本要求,而不是需要争论的复选框。较不明显的是“HTTPS 已开启”与“我们已真正加固”之间的差距。
使用 helmet 等工具设置安全响应头,而不是手动编写;不要跳过对任何涉及身份验证或可触发昂贵操作的端点的限流。
// main.ts
import helmet from 'helmet';
import { ThrottlerGuard } from '@nestjs/throttler';
app.use(helmet());
app.useGlobalGuards(new ThrottlerGuard());
Enter fullscreen mode Exit fullscreen mode
限流尤其值得在登录、密码重置以及任何发送邮件或短信的端点上再次审视。没有限流,注册表单会变成免费的垃圾邮件工具,登录端点则会成为毫无摩擦的凭证填充目标。基于 Redis 的限流可在多个实例间扩展,这在运行多个容器时至关重要。
依赖扫描是清单上最不引人注目的项目,也是团队最常跳过的项目。npm audit 在 CI 中运行,或使用 Snyk、GitHub 的 Dependabot 等服务,可以捕获已知依赖包中的漏洞。权衡在于噪音:并非每个标记的漏洞在您的上下文中都可被利用,而盲目地每次警报都升级依赖会引入自身破坏风险。对警报进行分类;不要盲目自动合并,也不要完全忽略。
日志、监控与事件响应准备
安全工作在代码发布后并未结束。大多数团队存在的是检测差距而非预防差距:出了问题,直到客户投诉才有人注意到。
结构化日志(包含足够上下文以重建事件:谁、哪个租户、什么操作、何时)决定了调查是五分钟还是两天。配合针对实际信号的告警:同一账户的重复登录失败、401/403 响应激增、异常的数据导出量。
这些在第一天不需要复杂。一款日志聚合器、几条告警规则和一份记录“如果触发该告警该怎么办”的运行手册,就能覆盖小型 SaaS 团队面临的大多数现实场景。在流量不足以证明其必要性之前构建完整的 SIEM 设置,是您尚未获得的复杂度——这与本系列第一篇文章中的技术栈决策精神一致。
关键要点
- 身份验证是基础:短生命周期访问令牌、轮换刷新令牌及
httpOnlyCookie 可关闭最常见的会话劫持路径。 - 租户隔离应首先在应用层实现,行级安全作为纵深防御后备,而非替代。
- 全局 DTO 验证配合
whitelist和forbidNonWhitelisted可在负载到达服务前阻止大量赋值和格式错误负载漏洞。 - 密钥应进入托管存储并在运行时注入;它们绝不应存在于 git 历史或已部署镜像的明文文件中。
- 安全响应头、限流和依赖扫描添加成本低、遗漏代价高;应在上线前添加,而非在事件后。
- 检测与预防同等重要:结构化日志和几条精心选择的告警可将未被察觉的入侵转变为五分钟响应。
常见问题
对于新 SaaS 产品,最重要的安全控制是什么?
身份验证和租户隔离,按此顺序。在添加其他任何内容之前,先正确实现短生命周期令牌、轮换刷新令牌和服务器端租户范围。
如果应用层已检查租户 ID,是否还需要 Postgres 的行级安全?
它是强大的纵深防御层,而非严格要求。应用层检查仍是主要防护,但 RLS 意味着服务方法中的漏洞不会自动变成跨租户数据泄露。
将 JWT 存储在 localStorage 中真的危险吗?
是的,主要因为 XSS 暴露。任何在页面上运行的脚本——无论是您自己的代码还是被攻破的依赖——都可以读取 localStorage 并外泄令牌。httpOnly Cookie 无法被 JavaScript 访问,从而完全消除了该攻击路径。
刷新令牌应多久轮换一次?
每次使用时轮换,并跟踪令牌家族,以便在轮换后检测到被盗刷新令牌的重复使用并可撤销。使用时轮换比您选择的确切过期窗口更重要。
我需要 WAF,还是限流和验证就够了?
对于大多数中小型 SaaS 产品,完善的输入验证、限流和安全响应头已能覆盖现实威胁面。WAF 在更高流量时有帮助,但不能替代修复您自己代码中的验证漏洞。
早期 SaaS 应用中最常见的单一安全错误是什么?
查询忘记按租户 ID 过滤。它很少来自缺少角色检查;它来自一个看似正确但未考虑租户范围的查询,通常通过支持工单而非安全扫描暴露。
延伸阅读
- 上一篇:SaaS 的缓存策略
- 下一篇:上线前的性能调优
- 系列开篇:为您的 SaaS 选择合适的技术栈
关于作者
您好,我是Aman Singh——专注于可扩展 SaaS 产品、分布式系统、云架构和 AI 驱动应用的高级全栈工程师。
我撰写关于系统设计、全栈工程、分布式系统、Redis、PostgreSQL、AWS、Node.js 和 NestJS 的文章。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.