diff --git a/functions/api/check.ts b/functions/api/check.ts new file mode 100644 index 00000000..01badc3a --- /dev/null +++ b/functions/api/check.ts @@ -0,0 +1,733 @@ +/** + * Cloudflare Pages Function — checklist auditor API endpoint. + * + * Receives a domain parameter, performs concurrent fetches and DNS-over-HTTPS queries, + * and returns a JSON report outlining compliance with automatable checklist items. + */ + +type CheckResult = { + slug: string; + result: 'pass' | 'fail' | 'warning' | 'manual'; + message: string; + details?: string; +}; + +const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 SpecAuditor/1.0'; + +export const onRequest: PagesFunction = async (context) => { + const corsHeaders = new Headers({ + 'Content-Type': 'application/json; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }); + + if (context.request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }); + } + + const requestUrl = new URL(context.request.url); + let domain = requestUrl.searchParams.get('domain') ?? ''; + domain = domain.trim().toLowerCase(); + + if (!domain) { + return new Response(JSON.stringify({ error: 'Missing domain parameter' }), { + status: 400, + headers: corsHeaders, + }); + } + + // Clean the domain input + domain = domain.replace(/^(https?:\/\/)?(www\.)?/, ''); + // Remove any trailing path/slashes + domain = domain.split('/')[0]; + + // Validate hostname structure + const hostRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/; + if (!hostRegex.test(domain) || !domain.includes('.')) { + return new Response(JSON.stringify({ error: 'Invalid domain format' }), { + status: 400, + headers: corsHeaders, + }); + } + + try { + const results: CheckResult[] = []; + + // Helper for timeout-capped fetches + const fetchWithTimeout = async (url: string, init: RequestInit = {}, timeoutMs = 6000) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + headers: { + 'User-Agent': USER_AGENT, + ...(init.headers || {}), + }, + }); + clearTimeout(timeoutId); + return response; + } catch (err) { + clearTimeout(timeoutId); + throw err; + } + }; + + // 1. Run HTTP -> HTTPS redirect check + let httpRedirectPass = false; + let redirectMessage = 'HTTP does not redirect to HTTPS.'; + try { + const httpRes = await fetchWithTimeout(`http://${domain}`, { redirect: 'manual' }); + const status = httpRes.status; + const location = httpRes.headers.get('location') || ''; + if (status >= 300 && status < 400 && location.startsWith('https://')) { + httpRedirectPass = true; + redirectMessage = `Redirects HTTP to HTTPS (${status} to ${location}).`; + } else { + redirectMessage = `HTTP returned status ${status} but did not redirect to HTTPS. Location: ${location || 'none'}`; + } + } catch (e: any) { + redirectMessage = `Could not connect to HTTP version: ${e.message || e}`; + } + + results.push({ + slug: 'https-tls', + result: httpRedirectPass ? 'pass' : 'fail', + message: redirectMessage, + }); + + // 2. Fetch primary page (HTTPS) and read headers/HTML + const rootUrl = `https://${domain}`; + let mainPageHtml = ''; + let mainHeaders = new Headers(); + let rootFetchSuccess = false; + let rootFetchErrorMsg = ''; + + try { + const res = await fetchWithTimeout(rootUrl, { redirect: 'follow' }); + rootFetchSuccess = true; + mainHeaders = res.headers; + mainPageHtml = await res.text(); + } catch (e: any) { + rootFetchErrorMsg = e.message || String(e); + } + + if (!rootFetchSuccess) { + return new Response( + JSON.stringify({ + domain, + error: `Could not connect to ${rootUrl}: ${rootFetchErrorMsg}`, + results: [ + { + slug: 'https-tls', + result: 'fail', + message: `Could not connect to HTTPS version of site: ${rootFetchErrorMsg}`, + }, + ], + }), + { headers: corsHeaders } + ); + } + + // --- SECURITY HEADER CHECKS --- + // HSTS + const hsts = mainHeaders.get('strict-transport-security'); + results.push({ + slug: 'hsts', + result: hsts ? 'pass' : 'fail', + message: hsts ? `HSTS header set: ${hsts}` : 'Strict-Transport-Security header is missing.', + }); + + // X-Content-Type-Options + const xcto = mainHeaders.get('x-content-type-options'); + const xctoPass = xcto && xcto.toLowerCase().includes('nosniff'); + results.push({ + slug: 'x-content-type-options', + result: xctoPass ? 'pass' : 'fail', + message: xctoPass ? 'X-Content-Type-Options set to nosniff.' : `Header value is: ${xcto || 'missing'}.`, + }); + + // Content Security Policy + const cspHeader = mainHeaders.get('content-security-policy') || mainHeaders.get('content-security-policy-report-only'); + const hasCspMeta = /.' + : 'No CSP header or meta tag found.', + }); + + // Referrer Policy + const refHeader = mainHeaders.get('referrer-policy'); + const hasRefMeta = /.' + : 'Referrer policy not specified.', + }); + + // Permissions Policy + const permHeader = mainHeaders.get('permissions-policy'); + results.push({ + slug: 'permissions-policy', + result: permHeader ? 'pass' : 'warning', + message: permHeader ? `Permissions-Policy present: ${permHeader.substring(0, 60)}...` : 'Permissions-Policy header is missing (recommended).', + }); + + // Frame Ancestors / X-Frame-Options + const xfo = mainHeaders.get('x-frame-options'); + const hasCspFrameAncestors = cspHeader && cspHeader.includes('frame-ancestors'); + results.push({ + slug: 'frame-ancestors', + result: (xfo || hasCspFrameAncestors) ? 'pass' : 'fail', + message: hasCspFrameAncestors + ? 'CSP frame-ancestors directive present.' + : xfo + ? `X-Frame-Options header present: ${xfo}` + : 'Frame protection header (X-Frame-Options or CSP frame-ancestors) is missing.', + }); + + // --- HTML FOUNDATIONS CHECKS --- + // Doctype + const doctypeRegex = /^\s*) is missing or malformed.', + }); + + // HTML Lang + const langMatch = mainPageHtml.match(/]*lang=["']([a-zA-Z-]+)["']/i); + results.push({ + slug: 'html-lang', + result: langMatch ? 'pass' : 'fail', + message: langMatch ? `Language attribute found: lang="${langMatch[1]}"` : 'lang attribute is missing on the tag.', + }); + + // Character Encoding + const charsetMatch = mainPageHtml.match(/]*charset=["']?utf-8["']?/i) || mainPageHtml.match(/]+http-equiv=["']content-type["'][^>]*content=["'][^"']*charset=utf-8["']/i); + results.push({ + slug: 'character-encoding', + result: charsetMatch ? 'pass' : 'fail', + message: charsetMatch ? 'UTF-8 character encoding declared.' : 'UTF-8 character encoding meta tag not found.', + }); + + // Viewport + const viewportMatch = mainPageHtml.match(/]+name=["']viewport["']/i); + results.push({ + slug: 'viewport', + result: viewportMatch ? 'pass' : 'fail', + message: viewportMatch ? 'Viewport meta tag found for responsive layouts.' : 'Viewport meta tag is missing.', + }); + + // Canonical URLs + const canonicalMatch = mainPageHtml.match(/]+rel=["']canonical["'][^>]*href=["']([^"']+)["']/i); + results.push({ + slug: 'canonical-urls', + result: canonicalMatch ? 'pass' : 'fail', + message: canonicalMatch ? `Canonical URL link tag found pointing to: ${canonicalMatch[1]}` : 'Canonical link tag is missing.', + }); + + // Favicon Link Tag Check + const faviconMatch = mainPageHtml.match(/]+rel=["'](?:shortcut )?icon["']/i); + let favIconPass = !!faviconMatch; + let faviconMessage = favIconPass ? 'Favicon reference found in HTML.' : ''; + + if (!favIconPass) { + try { + const favRes = await fetchWithTimeout(`${rootUrl}/favicon.ico`, { method: 'HEAD' }); + if (favRes.status === 200) { + favIconPass = true; + faviconMessage = 'Favicon found at root /favicon.ico (200 OK).'; + } else { + faviconMessage = 'No favicon link tag in HTML and /favicon.ico returned status ' + favRes.status; + } + } catch (e) { + faviconMessage = 'No favicon link tag in HTML and check on /favicon.ico failed.'; + } + } + + results.push({ + slug: 'favicons', + result: favIconPass ? 'pass' : 'fail', + message: faviconMessage, + }); + + // --- SEO CHECKS --- + // Title Tags + const titleMatch = mainPageHtml.match(/