E2E Test Suite / Form Interaction Tests (push) Failing after 10m29s
E2E Test Suite / Security Header Tests (push) Failing after 10m15s
E2E Test Suite / Mobile Device Tests (push) Failing after 11m8s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 12m0s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 12m52s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 13m44s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 14m37s
E2E Test Suite / Smoke Tests (P0) (push) Failing after 21m17s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Test Report Summary (push) Failing after 12m14s
Deploy to Production / Notify on Failure (push) Successful in 1s
Deploy to Production / Build & Verify (push) Failing after 5m47s
Deploy to Production / Pre-Deploy Tests (push) Failing after 14m54s
Deploy to Production / Deploy to Railway (push) Failing after 14m58s
Deploy to Production / Deploy to Render (push) Failing after 14m35s
Deploy to Production / Deploy to VPS (PM2) (push) Failing after 14m6s
Deploy to Production / Deploy to Fly.io (push) Failing after 13m43s
Deploy to Production / Post-Deploy Verification (push) Failing after 14m11s
The web-push SDK (https://api.wrnexus.com/push/sdk.js) and its runtime config fetch were blocked by the Content-Security-Policy set in src/middleware.ts. Added https://api.wrnexus.com to script-src (prod and dev) and connect-src. No wildcards introduced; existing analytics origins (GTM, GA, Plausible) unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
239 lines
9.8 KiB
TypeScript
239 lines
9.8 KiB
TypeScript
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
|
|
// - https://api.wrnexus.com: WRNexus web-push SDK (sdk.js) + runtime config
|
|
// 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 https://api.wrnexus.com"
|
|
: "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io https://api.wrnexus.com";
|
|
|
|
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 https://api.wrnexus.com",
|
|
"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;
|
|
});
|