import { defineMiddleware } from 'astro:middleware'; import { logger } from './utils/logger'; import { captureException } from './utils/sentry'; /** * Security Middleware * Implements security headers and domain validation for workroot.in * * Security Headers Implemented: * - Content-Security-Policy (CSP): Controls resource loading * - X-Frame-Options: Prevents clickjacking * - X-Content-Type-Options: Prevents MIME sniffing * - Referrer-Policy: Controls referrer information * - Permissions-Policy: Controls browser features * - Strict-Transport-Security (HSTS): Enforces HTTPS */ // CSRF protection: validate Origin/Referer for state-changing API requests function validateCsrfOrigin(request: Request, url: URL): boolean { const method = request.method; const path = url.pathname; // Only enforce on state-changing API methods if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return true; if (!path.startsWith('/api/')) return true; // Runtime check: allow localhost/127.0.0.1 (testing, local dev, staging on same host) const requestHost = url.hostname; if (requestHost === 'localhost' || requestHost === '127.0.0.1' || requestHost === '0.0.0.0') { return true; } // In development build, allow any origin if (!import.meta.env.PROD) return true; const origin = request.headers.get('origin'); const referer = request.headers.get('referer'); if (origin) { // Allow same-host origin (handles any port on same hostname) try { const originHost = new URL(origin).hostname; if (originHost === requestHost) return true; } catch {} return origin === 'https://workroot.in' || origin === 'https://www.workroot.in'; } if (referer) { try { const refererHost = new URL(referer).hostname; if (refererHost === requestHost) return true; } catch {} return referer.startsWith('https://workroot.in') || referer.startsWith('https://www.workroot.in'); } // If neither origin nor referer, reject (suspicious for non-localhost) return false; } export const onRequest = defineMiddleware(async (context, next) => { const startTime = Date.now(); const method = context.request.method; const path = context.url.pathname; // Normalize paths corrupted by a trailing stray ")" before routing. // These come from monitors/links that captured a URL together with a // closing parenthesis — e.g. "https://workroot.in/)" copied out of the // markdown/shell form "(https://workroot.in/)". Without this, every such // probe hits a non-existent route and 404s on a loop (recurring "GET /)"). // Stripping the trailing paren(s) lets the request resolve to the intended // route (e.g. "/)" -> "/"), so the monitor gets a 301 -> 200 instead. const normalizedPath = path.replace(/\)+$/, ''); if (normalizedPath !== path && normalizedPath.length > 0) { return context.redirect(`${normalizedPath}${context.url.search}`, 301); } // CSRF origin validation for API mutations if (!validateCsrfOrigin(context.request, context.url)) { logger.warn('middleware', 'CSRF origin check failed', { method, path, origin: context.request.headers.get('origin'), referer: context.request.headers.get('referer'), }); const requestOrigin = context.request.headers.get('origin') ?? ''; const allowedOrigins = ['https://workroot.in', 'https://www.workroot.in']; const corsOrigin = allowedOrigins.includes(requestOrigin) ? requestOrigin : 'https://workroot.in'; return new Response( JSON.stringify({ success: false, error: 'Forbidden: invalid origin.' }), { status: 403, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': corsOrigin, 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }, } ); } let response: Response; try { response = await next(); } catch (err) { const durationMs = Date.now() - startTime; await captureException(err, { scope: 'middleware', extra: { method, path, durationMs }, }); // Re-throw so Astro can render its 500 page throw err; } const durationMs = Date.now() - startTime; const status = response.status; // Log all SSR errors (5xx) and not-found (404) responses if (status >= 500) { logger.error('middleware', `SSR error ${status}`, { method, path, status, durationMs }); } else if (status === 404) { // 404s are dominated by bots/scanners probing malformed or garbage paths // (e.g. "/)"), which would otherwise flood warn-level logs and trip alerts. // Treat a 404 as a warning only when it likely originated from our own site // (same-origin referer) — that indicates a real broken internal link worth // fixing. Everything else (no referer / external referer) is logged at info. const referer = context.request.headers.get('referer'); let internalNavigation = false; if (referer) { try { internalNavigation = new URL(referer).hostname === context.url.hostname; } catch { // Malformed referer header → treat as external/untrusted } } const notFoundMeta = { method, path, status, durationMs, referer: referer ?? undefined }; if (internalNavigation) { logger.warn('middleware', `Not found (broken internal link)`, notFoundMeta); } else { logger.info('middleware', `Not found`, notFoundMeta); } } else if (status >= 400) { logger.warn('middleware', `Client error ${status}`, { method, path, status, durationMs }); } else { logger.debug('middleware', `Request`, { method, path, status, durationMs }); } // Clone the response to add headers const modifiedResponse = new Response(response.body, response); // Content Security Policy (CSP) // Allows: // - self: Same-origin resources // - https://images.unsplash.com: External images (explicit, not wildcard https:) // - https://fonts.googleapis.com & https://fonts.gstatic.com: Google Fonts // - inline scripts/styles: Required for Astro framework // - https://www.googletagmanager.com & https://www.google-analytics.com: GA4 // - https://plausible.io: Plausible Analytics // Note: unsafe-eval is removed in production (only needed during Astro HMR dev mode) const isProduction = import.meta.env.PROD; const scriptSrc = isProduction ? "script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io" : "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io"; const csp = [ "default-src 'self'", scriptSrc, "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "img-src 'self' data: https://images.unsplash.com https://www.google-analytics.com https://www.googletagmanager.com https://maps.googleapis.com https://maps.gstatic.com", "font-src 'self' data: https://fonts.gstatic.com", "connect-src 'self' https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://plausible.io", "worker-src 'self'", "manifest-src 'self'", "object-src 'none'", // Block Flash/plugins (defense-in-depth) "frame-src https://www.google.com", // Allow Google Maps iframe embed "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", "upgrade-insecure-requests", ].join('; '); modifiedResponse.headers.set('Content-Security-Policy', csp); // X-Frame-Options: Prevent clickjacking // DENY prevents any domain from framing the content modifiedResponse.headers.set('X-Frame-Options', 'DENY'); // X-Content-Type-Options: Prevent MIME sniffing // nosniff prevents browsers from interpreting files as different MIME type modifiedResponse.headers.set('X-Content-Type-Options', 'nosniff'); // Referrer-Policy: Control referrer information // strict-origin-when-cross-origin sends full URL for same-origin, origin only for cross-origin modifiedResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); // Permissions-Policy: Disable unnecessary browser features // Restricts access to sensitive APIs to prevent abuse modifiedResponse.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()' ); // Strict-Transport-Security (HSTS): Enforce HTTPS // max-age=31536000: 1 year // includeSubDomains: Apply to all subdomains // preload: Allow inclusion in HSTS preload list // Only set HSTS in production and when using HTTPS const isHttps = context.url.protocol === 'https:'; if (isProduction && isHttps) { modifiedResponse.headers.set( 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload' ); } // X-Permitted-Cross-Domain-Policies: Restrict cross-domain access modifiedResponse.headers.set('X-Permitted-Cross-Domain-Policies', 'none'); // X-DNS-Prefetch-Control: Control DNS prefetching modifiedResponse.headers.set('X-DNS-Prefetch-Control', 'on'); // Domain validation: Ensure requests are for workroot.in const allowedHosts = ['workroot.in', 'www.workroot.in', 'localhost', '127.0.0.1', '0.0.0.0']; const requestHost = context.url.hostname; // In development, allow any host if (!isProduction || allowedHosts.includes(requestHost)) { return modifiedResponse; } // In production, if host doesn't match, redirect to canonical domain if (isProduction && !allowedHosts.includes(requestHost)) { const canonicalUrl = `https://workroot.in${context.url.pathname}${context.url.search}`; return Response.redirect(canonicalUrl, 301); } return modifiedResponse; });