We just shipped the MVP of **Pocket Portfolio** (OSS, privacy-first). This post shows the exact **CSP**, **CORS**, and **Service Worker** setup we used to keep things fast *and* safe on Vercel + Firebase.
> TL;DR: Lock down third-party origins, cache UI not money, and never let your SW hijack `/api/*`.
---
## 1) Content Security Policy (CSP)
Our policy lives in `vercel.json` headers. The key is allowing what Firebase Auth *actually* uses (`apis.google.com`, `accounts.google.com`, `gstatic`) and any CDNs you intentionally rely on.
```
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"
}
]
}
]
}
Why:
- Blocks surprise script loads and XSS fallout.
- Lets Google Sign-in popups/iframes work in prod (no mysterious 400s).
2) CORS for your Serverless/Edge APIs
Expose only what the browser needs and only to your site.
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;
};
Use it:
js
// /api/quote.js
import { cors } from "./_cors.js";
export default async function handler(req, res) {
if (cors(req, res)) return; // preflight handled
const { ticker } = req.query || {};
if (!/^[A-Z.\-]{1,7}$/.test(ticker || "")) {
res.status(400).json({ error: "bad ticker" });
return;
}
// fetch upstream → normalize → respond
res.setHeader("Cache-Control", "public, max-age=5, stale-while-revalidate=25");
res.status(200).json({ price: 123.45, ts: Date.now() });
}
3) A Service Worker that doesn’t break your app
Cache the shell (CSS/JS/icons) and navigations in /app/*
. Never intercept /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; // ignore third-party
if (!url.pathname.startsWith("/app/")) return; // keep scope tight
if (url.pathname.startsWith("/api/")) return; // never cache APIs
// Static assets → 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;
}
// Navigations → 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")))
);
}
});
Register only in prod:
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) Extra hardening (drop-ins)
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
-
Asset caching:
Cache-Control: public, max-age=31536000, immutable` Rate limits for hot endpoints (Edge middleware or util)
What we’re building
Pocket Portfolio is an OSS, broker-free portfolio tracker. Add trades or import a small CSV. Live prices, P/L, clean UI.
- App → https://pocketportfolio.app/app/
- Repo → https://github.com/PocketPortfolio/Financialprofilenetwork
Not investment advice. For research/education only.
Top comments (0)