我們在上篇文章討論如何讓應用程式變快。本文則是關於確保它在有人試圖攻擊時依然能正常運作。這就是安全工作的殘酷真相:當一切做好時沒人會注意到,而當你遺漏任何一個漏洞時,所有人都會注意到。
這是全端 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
人們低估的權衡是刷新權杖輪替。它需要更多的元件:你需要一個權杖家族的概念,以便當被竊取的刷新權杖在輪替後被重複使用時,你可以偵測並終止整個工作階段鏈。這值得在早期就建立一次,而不是在事件發生後才硬加上去。
我最常看到的陷阱是因為對單頁應用程式方便而將 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 中比學習參數化語法更快。在程式碼審查中禁止字串串接的 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
速率限制值得在登入、密碼重設以及任何傳送電子郵件或 SMS 的端點上再看一次。沒有它,你的註冊表單會成為某人免費濫發他人收件匣的工具,而你的登入端點會成為毫無摩擦的憑證填充目標。由 Redis 支援的速率限制可以跨多個執行個體擴展,這在你執行多個容器的那一刻就很重要。
相依性掃描是這個清單上最不吸引人的項目,也是團隊最常跳過的項目。CI 中的 npm audit,或 Snyk 或 GitHub 的 Dependabot 之類的服務,會捕獲你已經依賴的套件中的已知漏洞。權衡是雜訊:並非每個標記的漏洞在你的情境中都是可利用的,而在每次警示時盲目地更新每個相依性會帶來其自身的中斷風險。對警示進行分類;不要盲目自動合併,也不要忽略它們。
記錄、監控與事件應變準備
安全性工作在程式碼發佈時並未完成。大多數團隊都有偵測差距,而不是防護差距:出了問題,直到客戶抱怨之前都沒有人注意到。
具有足夠情境以重建事件的結構化記錄(誰、什麼租戶、什麼動作、何時)決定了調查是五分鐘還是兩天。將其與實際重要訊號的警示配對:來自一個帳戶的重複登入失敗、401/403 回應的尖峰、資料匯出的異常數量。
第一天不需要太複雜。一個記錄聚合器、幾個警示規則,以及一份記錄的「如果這觸發我們該怎麼辦」執行手冊,涵蓋小型 SaaS 團隊將面臨的大多數實際情境。在你有流量證明之前建立完整的 SIEM 設定,是你還沒獲得的複雜度,這與本系列第一篇文章中的堆疊決策精神相同。
重點摘要
- 驗證是基礎:短效存取權杖、輪替的刷新權杖,以及
httpOnlyCookie 封閉了最常見的工作階段劫持路徑。 - 租戶隔離首先屬於應用層,資料列層級安全性作為深度防禦後盾,而非取代。
- 帶有
whitelist和forbidNonWhitelisted的全域 DTO 驗證會在大量指派和格式錯誤的負載錯誤到達服務之前阻止它們。 - 機密進入受管理存放區並在執行時注入;它們永遠不會存在於 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.