這一課完成後:你會使用 environment variables 管理環境設定與 secret、建立可重用的 validation、讓 4xx / 5xx error 不洩漏內部細節,並能說明 same-origin、CORS、preflight、credentials 與 CSRF 彼此到底是什麼關係。

先把「邊界」想成不可信資料進入系統的地方

到目前為止,你的 Web App 已經有很多入口:

Environment → server 設定與 secret
HTTP Request → method / path / headers / body
Cookie → session identity
Database → persisted data
Browser Origin → 哪個網站正在呼叫你的 API

真正容易出事的地方,通常不是某一行語法,而是「你把哪個輸入當成可信」、「什麼資訊可以被送出去」、「誰能跨過這個邊界」。

Secret 和一般 config 不完全一樣

先分兩類:

Config → PORT、APP_ORIGIN、NODE_ENV
Secret → 第三方 API key、database password、private signing key

兩者都適合從 environment 注入,但 secret 還多一個要求:不能因為方便就進 Git、前端 bundle、log 或錯誤訊息。

上一課的 opaque session token 是每個登入 session 的秘密值;它由程式隨機產生,不需要你手動寫一個固定 session secret。未來接第三方服務時,才會更常出現長期 API key。

不要把環境差異硬寫在 server.js

這種寫法會讓 source code 同時綁死設定與程式:

const port = 3000;
const appOrigin = "http://localhost:5500";
const apiKey = "super-secret-key";

改成從 process.env 讀:

const port = Number(process.env.PORT ?? 3000);
const appOrigin = process.env.APP_ORIGIN ?? "http://localhost:5500";
const apiKey = process.env.THIRD_PARTY_API_KEY;

Node.js 的 environment variables 都會以字串形式出現在 process.env,所以像 port、boolean 這類型別要自己轉換。

Node.js Environment Variables

本機可以用 .env,但 .env 本身不是保險箱

建立:

.env

例如:

PORT=3000
APP_ORIGIN=http://localhost:5500
THIRD_PARTY_API_KEY=replace-me

目前 Node.js 可以直接:

node --env-file=.env server.js

它會把檔案中的值載入 process.env。

.env 只是檔案。如果你把它 commit 到公開 repository、傳到群組、放進 log,它一樣會洩漏。它的價值是把環境設定移出 source code,不是提供加密。

.gitignore 要先擋住真正的 secret file

.env
.env.*
!.env.example

data.sqlite
data.sqlite-shm
data.sqlite-wal

另外建立可以 commit 的:

.env.example

內容只留欄位與假值:

PORT=3000
APP_ORIGIN=http://localhost:5500
THIRD_PARTY_API_KEY=your-key-here
.env → 真實值 → 不進 Git
.env.example → 欄位說明 → 可以進 Git

如果 secret 已經 commit,刪檔不等於沒洩漏

假設 API key 已經出現在 Git history,之後新增 .gitignore 並刪掉檔案,不代表舊 commit 裡的值消失。

處理原則是:

視為已洩漏 → 到 provider revoke / rotate → 換新 secret → 再處理 Git history

不要只做「把畫面上的字刪掉」就繼續使用同一把 key。

必要設定缺少時,server 應該 fail fast

如果某個 production 功能沒有 secret 就不能安全運作,不要默默用空字串繼續。

function requireEnv(name) {
  const value = process.env[name];

  if (!value) {
    throw new Error(`Missing environment variable: ${name}`);
  }

  return value;
}

const appOrigin = requireEnv("APP_ORIGIN");

這種錯誤應該在 server 啟動時就被看見,而不是等第一個使用者撞到才發現。

Validation:不要讓 route 自己各寫一套猜測

上一課已經做過:

if (
  typeof body.title !== "string" ||
  body.title.trim() === ""
) {
  // 400
}

當 route 變多,這種判斷如果散落在每個地方會很快失控。

先集中成函式:

function validateTaskInput(body) {
  if (!body || typeof body !== "object" || Array.isArray(body)) {
    return { ok: false, error: "Body must be an object" };
  }

  if (typeof body.title !== "string") {
    return { ok: false, error: "title must be a string" };
  }

  const title = body.title.trim();

  if (title.length === 0 || title.length > 200) {
    return {
      ok: false,
      error: "title must contain 1 to 200 characters"
    };
  }

  return {
    ok: true,
    value: { title }
  };
}

Route 只需要:

const validation = validateTaskInput(body);

if (!validation.ok) {
  sendError(response, 400, "INVALID_INPUT", validation.error);
  return;
}

const { title } = validation.value;

Validation、Normalization、Authorization 不要混成同一件事

Validation → 型別 / 長度 / 格式對不對
Normalization → trim / lowercase 等一致化
Authentication → 你是誰
Authorization → 你能不能做這件事

例如註冊 email:

const email = body.email.trim().toLowerCase();

這是 normalization。至於「這個 user 能不能刪 task 38」,則是 authorization。

只接受你打算使用的欄位

Client 可能送:

{
  "title": "My task",
  "user_id": 1,
  "is_admin": true
}

不要把整個 body 直接 spread 進 database operation:

// 不要這樣思考
const task = { ...body };

應該由 backend 明確挑出允許欄位:

const { title } = validation.value;

user_id 仍然必須由 authenticated session 決定,不由 client 指定。

Database constraint 仍然要保留

Request validation 很重要,但 database schema 也應該守住基本 invariants:

title TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
HTTP validation → 早一點拒絕不合理輸入
Database constraint → 最後一道資料完整性保護

兩層不是互相取代。

Error response 應該有穩定形狀

與其每個 route 都回不同格式:

{ "error": "bad" }
{ "message": "wrong" }
{ "reason": "failed" }

可以統一:

function sendError(response, statusCode, code, message) {
  sendJson(response, statusCode, {
    error: {
      code,
      message
    }
  });
}

例如:

sendError(
  response,
  400,
  "INVALID_INPUT",
  "title must contain 1 to 200 characters"
);

Frontend 就能穩定地讀 error.code / error.message。

500 不要把 stack trace、SQL、secret 回給使用者

內部可能真的發生:

SQLITE_CONSTRAINT...
/path/to/server.js:123...
THIRD_PARTY_API_KEY=...

這些可以出現在受控的 server log,但不要原封不動送進 HTTP response。

try {
  // backend work
} catch (error) {
  console.error(error);

  sendError(
    response,
    500,
    "INTERNAL_ERROR",
    "Something went wrong"
  );
}
Internal log → 給開發者除錯
Public error response → 給 client 足夠但有限的資訊

Same-origin:Browser 會看 scheme、host、port

這兩個不是同一個 origin:

http://localhost:5500
http://localhost:3000

雖然 host 都是 localhost,但 port 不同。

當 frontend JavaScript 嘗試從 5500 fetch 3000,browser 的 same-origin policy 會介入。這時如果你真的需要跨來源讀取 response,就要由 backend 明確允許 CORS。

CORS 是 Browser 的跨來源讀取規則,不是登入系統

CORS 全名是 Cross-Origin Resource Sharing。

最重要的心理模型:

Browser 看見 cross-origin request
↓
Backend 用 CORS response headers 表達允許範圍
↓
Browser 決定 frontend JavaScript 能不能讀 response

CORS 不是 authorization。非 browser client,例如 curl 或另一台 server,不會因為你沒給 CORS header 就自動被擋住。

MDN CORS

最簡單的開發情境:只允許一個 frontend origin

從 environment 取得:

const appOrigin = requireEnv("APP_ORIGIN");

建立 helper:

function applyCors(request, response) {
  const origin = request.headers.origin;

  if (!origin) {
    return true;
  }

  if (origin !== appOrigin) {
    return false;
  }

  response.setHeader(
    "Access-Control-Allow-Origin",
    origin
  );
  response.setHeader(
    "Access-Control-Allow-Credentials",
    "true"
  );
  response.setHeader(
    "Access-Control-Allow-Headers",
    "Content-Type"
  );
  response.setHeader(
    "Access-Control-Allow-Methods",
    "GET, POST, PATCH, DELETE, OPTIONS"
  );
  response.setHeader("Vary", "Origin");

  return true;
}

如果你使用 cookie session,而且 frontend / API 是 cross-origin,不能把 credentialed response 的 Access-Control-Allow-Origin 寫成 *;必須是明確 origin。

Preflight:有些 request 會先來一個 OPTIONS

Browser 在真正的 cross-origin request 前,某些情況會先問 server:

OPTIONS preflight → 你允許這個 origin / method / headers 嗎?
允許 → Browser 再送真正 request

在 route matching 前處理:

if (!applyCors(request, response)) {
  sendError(response, 403, "ORIGIN_NOT_ALLOWED", "Origin not allowed");
  return;
}

if (request.method === "OPTIONS") {
  response.writeHead(204);
  response.end();
  return;
}

如果忘記 OPTIONS,你可能會看到「POST route 明明存在,browser 卻根本沒有打到它」。

Cross-origin cookie request 還要在 frontend 明確 include credentials

const response = await fetch(
  "http://localhost:3000/api/me",
  {
    credentials: "include"
  }
);

對 cross-origin fetch,credentials: "include" 告訴 browser 這個 request 要包含符合條件的 credentials,例如 cookies。

Backend 同時要回:

Access-Control-Allow-Origin: http://localhost:5500
Access-Control-Allow-Credentials: true

只做其中一邊不夠。

最簡單的架構其實常常是 same-origin

如果正式環境可以讓:

https://app.example.com/
https://app.example.com/api/...

由同一個 origin 提供 frontend 與 API,很多 CORS 複雜度會直接消失。

不要因為看過微服務架構,就故意把 beginner project 拆成五個 domain。

CORS 不是 CSRF 防護

上一課使用 cookie session。Cookie 的特性之一是 browser 可能會自動把它附在符合條件的 request 上。

CSRF(Cross-Site Request Forgery)問的是另一個問題:

攻擊者能不能誘導使用者的 browser
帶著既有登入狀態
對你的網站做一個使用者沒打算做的 state-changing request?

CORS 主要限制 frontend JavaScript 是否能讀 cross-origin response;不能把它當成完整 CSRF 防線。

MDN CSRF

SameSite 是 defense in depth,不是「設了就結束」

上一課的 session cookie 使用:

SameSite=Lax

它可以降低很多 cross-site cookie request 的風險,但不要把它理解成萬用 CSRF solution。

至少維持兩個基本原則:

GET / HEAD 這類 safe methods 不應該改變重要 server state。

POST / PATCH / DELETE 等 state-changing browser request 要有明確的來源與 CSRF 策略。

正式產品常再搭配 Origin / Referer 驗證、CSRF token 或 framework 內建機制。不同部署模型會影響你選哪種。

這個課程先加入最小 Origin boundary

針對 browser 的 state-changing request,可以先檢查:

function isStateChangingMethod(method) {
  return ["POST", "PUT", "PATCH", "DELETE"].includes(method);
}

function isAllowedRequestOrigin(request) {
  const origin = request.headers.origin;

  if (!origin) {
    return true;
  }

  return origin === appOrigin;
}

在 route logic 前:

if (
  isStateChangingMethod(request.method) &&
  !isAllowedRequestOrigin(request)
) {
  sendError(response, 403, "ORIGIN_NOT_ALLOWED", "Origin not allowed");
  return;
}

這是教學用的最小 boundary,不是宣稱完整 CSRF framework。正式系統如果支援更多 browser origin、跨站嵌入、第三方 client 或較高風險操作,要選擇完整 CSRF 策略,而不是只複製這幾行。

Security headers:先加幾個低風險基本值

可以在共用 response helper 中加入:

function applySecurityHeaders(response) {
  response.setHeader(
    "X-Content-Type-Options",
    "nosniff"
  );
  response.setHeader(
    "Referrer-Policy",
    "no-referrer"
  );
}

Auth / account 相關 response 也可以考慮:

Cache-Control: no-store

但不要把「貼很多 security headers」當成完成安全設計。像 Content-Security-Policy 這類 policy 必須依你的 frontend 資源與部署方式設計,會在真正部署時再看。

Logging 也有邊界

這些東西不要直接 log:

原始 password。

完整 session token。

API key。

完整 Cookie header。

你可以記錄 request method、path、status、耗時、內部 request id;但 log 本身也要被視為敏感資料來源。

做一個最小 request id,讓錯誤比較好追

const { randomUUID } = require("node:crypto");

const requestId = randomUUID();
response.setHeader("X-Request-Id", requestId);

內部 log:

console.error({
  requestId,
  method: request.method,
  path: url.pathname,
  error
});

公開 response 不需要把 stack trace 送出去,但可以附 request id 方便查 log。

把 route handler 的順序整理清楚

現在你的 backend 可以依照這個順序思考:

建立 request context / request id
↓
套用基本 response headers
↓
檢查 Origin / CORS / OPTIONS
↓
Route matching
↓
Parse body
↓
Validate / normalize
↓
Authenticate
↓
Authorize
↓
Database / business logic
↓
Stable response / error

不是每個 route 都要重複完整流程,但你的架構應該能清楚表達這些責任。

小挑戰:把 frontend 和 API 故意拆成兩個 origin

讓 backend 保持:

http://localhost:3000

再用另一個 server 開 frontend,例如:

python -m http.server 5500

設定:

APP_ORIGIN=http://localhost:5500

完成以下驗收:

需求 1:frontend 可以用 credentials: "include" 呼叫 /api/me。

需求 2:允許的 origin 可以正常 GET / POST。

需求 3:錯誤 origin 不可以被你的 CORS allowlist 接受。

需求 4:OPTIONS preflight 能回 204。

需求 5:task title 超過 200 字會得到穩定 400 error。

需求 6:故意讓 database operation throw,client 只能看到 generic 500,不會看到 stack / SQL。

需求 7:.env 已在 .gitignore,.env.example 可以 commit。

上 Git 前做一次 secret scan 思維檢查

不需要先裝工具,也可以人工先問:

source code 裡有沒有 sk-...、token、password、database URL?

git diff 裡有沒有不該出去的值?

.env 是否真的未被 Git tracking?

錯誤 response 是否包含內部 path、SQL 或 secret?

frontend JavaScript 是否拿得到本應 server-only 的值?

如果 secret 已經進過 commit,要 rotate,而不是只刪掉目前檔案。

最後收斂:安全不是一個 middleware 名字

Secrets → 不進 source / frontend / logs
Validation → 不信任 client input
Auth → identity / permission 在 backend 驗證
Errors → 對外最小資訊、對內可除錯
CORS → 明確限制 browser cross-origin access
CSRF → cookie auth 的 state-changing request 另外防護
Database → constraints + prepared statements

你現在做的不是「把網站變成絕對安全」,而是把幾個最重要的 trust boundaries 明確化。

下一課會把這些 boundary 帶進 production:GitHub、部署環境、production secrets、database migration、HTTPS 與正式 URL。

完成條件:你能把 secret 與 config 從 source code 拆出來,集中驗證 request input、回傳穩定且不洩漏內部資訊的 errors,並能正確解釋 CORS、credentials、SameSite 與 CSRF 不是同一件事。