Hardening a Static App on Vercel: CSP, CORS, and Service Workers that Don’t Bite

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    Hardening a Static App on Vercel: CSP, CORS, and Service Workers that Don’t Bite

    Absolutely—here’s a single, copy-and-paste DEV.to post (complete Markdown with front-matter). Drop this into the DEV editor and it’ll keep all formatting, code blocks, and headings.






    ---
    title: "Hardening a Vercel app: CSP, CORS, and Service Workers that don’t bite"
    published: true
    cover_image: https://pocketportfolio.app/brand/og-base.svg
    tags: security, vercel, firebase, pwa, webdev
    canonical_url: https://pocketportfolio.app/
    ---

    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











    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.

    Not investment advice. For research/education only.







    Want me to convert any of the other planned posts into the same “ready to paste” format?
    ::contentReference[oaicite:0]{index=0}











    More...
Working...