Tăng cường bảo mật cho ứng dụng Vercel: CSP, CORS và Service Workers
Chúng tôi vừa phát hành MVP của Pocket Portfolio (OSS, bảo mật đầu tiên). Bài viết này sẽ trình bày cấu hình CSP, CORS, và Service Worker mà chúng tôi đã sử dụng để giữ cho ứng dụng chạy nhanh và an toàn trên Vercel + Firebase.
Tóm tắt: Giới hạn các nguồn bên thứ ba, cache giao diện người dùng chứ không phải tiền, và đừng để Service Worker chiếm quyền
/api/*.
1) Chính sách bảo mật nội dung (CSP)
Chính sách của chúng tôi được định nghĩa trong phần headers của vercel.json. Điểm mấu chốt là chỉ cho phép những gì Firebase Auth thực sự sử dụng (apis.google.com, accounts.google.com, gstatic) và bất kỳ CDN nào mà bạn có ý định sử dụng.
json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://www.gstatic.com https://*.googleapis.com https://apis.google.com https://accounts.google.com; script-src-elem 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://www.gstatic.com https://*.googleapis.com https://apis.google.com https://accounts.google.com; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data: https:; connect-src 'self' https://www.googleapis.com https://*.googleapis.com https://securetoken.google.com https://identitytoolkit.googleapis.com https://firestore.googleapis.com https://*.firebaseio.com https://firebasestorage.googleapis.com https://apis.google.com https://accounts.google.com; frame-src 'self' https://*.google.com https://accounts.google.com https://*.firebaseapp.com https://*.web.app; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests"
}
]
}
]
}
Lý do:
- Ngăn chặn tải script không mong muốn và fallout XSS.
- Cho phép popups/iframes của Google Sign-in hoạt động trong môi trường sản xuất (không có lỗi 400 bí ẩn).
2) CORS cho API Serverless/Edge của bạn
Chỉ công khai những gì trình duyệt cần và chỉ cho trang của bạn.
js
// /api/_cors.js
export const cors = (req, res, { methods = ["GET"], origin = "https://pocketportfolio.app" } = {}) => {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", methods.join(","));
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") { res.status(204).end(); return true; }
return false;
};
Sử dụng như sau:
js
// /api/quote.js
import { cors } from "./_cors.js";
export default async function handler(req, res) {
if (cors(req, res)) return; // preflight đã được xử lý
const { ticker } = req.query || {};
if (!/^[A-Z.\-]{1,7}$/.test(ticker || "")) {
res.status(400).json({ error: "ticker không hợp lệ" });
return;
}
// lấy dữ liệu từ upstream → chuẩn hóa → phản hồi
res.setHeader("Cache-Control", "public, max-age=5, stale-while-revalidate=25");
res.status(200).json({ price: 123.45, ts: Date.now() });
}
3) Một Service Worker không làm hỏng ứng dụng của bạn
Cache các shell (CSS/JS/icons) và các điều hướng trong /app/*. Không bao giờ chặn /api/*.
js
/* /app/service-worker.js */
const SW_VERSION = "pp-v9";
const SHELL = [
"/app/", "/app/index.html", "/app/style.css", "/app/app.js",
"/app/manifest.webmanifest", "/brand/pp-monogram.svg"
];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(SW_VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== SW_VERSION).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (e) => {
const url = new URL(e.request.url);
if (url.origin !== location.origin) return; // bỏ qua bên thứ ba
if (!url.pathname.startsWith("/app/")) return; // giữ phạm vi chặt chẽ
if (url.pathname.startsWith("/api/")) return; // không bao giờ cache APIs
// Tài nguyên tĩnh → cache-first
if (/\.(css|js|mjs|map|svg|png|jpg|jpeg|webp|ico|woff2?)$/i.test(url.pathname)) {
e.respondWith(
caches.match(e.request).then((hit) =>
hit ||
fetch(e.request).then((res) => {
caches.open(SW_VERSION).then((c) => c.put(e.request, res.clone()));
return res;
})
)
);
return;
}
// Điều hướng → network-first, cache fallback
if (e.request.method === "GET") {
e.respondWith(
fetch(e.request).then((res) => {
caches.open(SW_VERSION).then((c) => c.put(e.request, res.clone()));
return res;
}).catch(() => caches.match(e.request).then((hit) => hit || caches.match("/app/index.html")))
);
}
});
Đăng ký chỉ trong môi trường sản xuất:
html
<script>
if ("serviceWorker" in navigator && !/localhost|127\.0\.0\.1/.test(location.hostname)) {
navigator.serviceWorker.register("/app/service-worker.js?sw=9").catch(()=>{});
}
</script>
4) Củng cố thêm (drop-ins)
X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: geolocation=(), microphone=(), camera=()Cache tài sản: Cache-Control: public, max-age=31536000, immutableGiới hạn tỷ lệ cho các điểm nóng (Edge middleware hoặc util)
Chúng tôi đang xây dựng gì
Pocket Portfolio là một trình theo dõi danh mục đầu tư OSS, không có môi giới. Thêm giao dịch hoặc nhập một CSV nhỏ. Giá trực tiếp, P/L, giao diện sạch sẽ.
- Ứng dụng → Pocket Portfolio
- Repo → GitHub Repository
Không phải là lời khuyên đầu tư. Chỉ dành cho nghiên cứu/giáo dục.