diff --git a/README.md b/README.md index bd66f9f..5dc165c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A static, browser-based dashboard for testing real CORS behavior from the page o - Preflight detection based on method, headers, and content type. - Result view with status, timing, visible response headers, readable body preview, and browser error classification. - CORS diagnostics for failed preflights, missing readable responses, credentialed CORS pitfalls, mixed content, and opaque `no-cors` behavior. +- Domain policy notes for expected allowed and blocked origins, included in saved cases and copied reports. - Saved test cases in `localStorage`, with JSON import/export. - Copyable diagnostic report for API teams. @@ -20,6 +21,7 @@ This app tests CORS exactly as a browser enforces it. It cannot bypass CORS. - Browser-managed headers such as `Origin`, `Host`, `Cookie`, `Referer`, and `Sec-*` cannot be manually set. - `no-cors` requests can produce opaque responses where status, headers, and body are intentionally hidden. - Testing from GitHub Pages means your API sees the app origin as `https://.github.io`. +- To verify that an API allows domain A but blocks domain B, run the test page from domain A and domain B. The domain policy fields document expected behavior, but they do not spoof origins. ## Local Development diff --git a/src/App.tsx b/src/App.tsx index dc5a42d..c7f0f55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,8 @@ type TestDraft = { body: string; timeoutMs: number; headers: HeaderRow[]; + allowedOrigins: string[]; + blockedOrigins: string[]; }; type SavedCase = TestDraft & { @@ -75,6 +77,8 @@ const defaultDraft: TestDraft = { body: '', timeoutMs: 12000, headers: [{ id: crypto.randomUUID(), name: '', value: '' }], + allowedOrigins: [], + blockedOrigins: [], }; const scenarios: Scenario[] = [ @@ -191,7 +195,11 @@ function buildHeaders(draft: TestDraft) { return; } if (isForbiddenHeader(name)) { - warnings.push(`Browser-managed header "${name}" was skipped because Fetch cannot set it.`); + if (name.toLowerCase() === 'origin') { + warnings.push('The Origin header was skipped. Browser JavaScript cannot spoof Origin; it always uses the page origin.'); + } else { + warnings.push(`Browser-managed header "${name}" was skipped because Fetch cannot set it.`); + } return; } names.add(name.toLowerCase()); @@ -221,6 +229,43 @@ function getOriginLabel() { return window.location.origin; } +function cleanOriginList(origins: unknown) { + if (!Array.isArray(origins)) { + return []; + } + return origins + .filter((origin): origin is string => typeof origin === 'string') + .map((origin) => origin.trim()) + .filter(Boolean); +} + +function originLines(origins: string[]) { + return origins.join('\n'); +} + +function parseOriginLines(value: string) { + return value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +function originKey(value: string) { + try { + return new URL(value).origin; + } catch { + return value.trim(); + } +} + +function uniqueOriginKeys(origins: string[]) { + return new Set(origins.map(originKey).filter(Boolean)); +} + +function formatOriginList(origins: string[]) { + return origins.length ? origins.map((origin) => ` - ${origin}`).join('\n') : ' (none)'; +} + function makeDiagnostics( draft: TestDraft, result: Pick, @@ -229,6 +274,12 @@ function makeDiagnostics( const diagnostics: string[] = []; const target = parseTargetUrl(draft.url); const preflight = expectsPreflight(draft, appliedHeaders); + const currentOrigin = getOriginLabel(); + const currentOriginKey = originKey(window.location.origin); + const allowedOriginKeys = uniqueOriginKeys(draft.allowedOrigins); + const blockedOriginKeys = uniqueOriginKeys(draft.blockedOrigins); + const expectsCurrentAllowed = allowedOriginKeys.has(currentOriginKey); + const expectsCurrentBlocked = blockedOriginKeys.has(currentOriginKey); if (!target) { diagnostics.push('The URL is not valid. Use an absolute http:// or https:// API URL.'); @@ -251,6 +302,18 @@ function makeDiagnostics( diagnostics.push('Credentialed CORS requires a specific Access-Control-Allow-Origin value and Access-Control-Allow-Credentials: true. Wildcard origins are not allowed.'); } + if (draft.allowedOrigins.length || draft.blockedOrigins.length) { + diagnostics.push(`Domain policy expectations are recorded for this case, but this browser run only tests the actual page origin: ${currentOrigin}.`); + } + + if (expectsCurrentAllowed && expectsCurrentBlocked) { + diagnostics.push('The current page origin is listed as both expected allowed and expected blocked. Clean up the policy lists before using this case as evidence.'); + } else if (expectsCurrentAllowed) { + diagnostics.push('The current page origin is listed as expected allowed for this API.'); + } else if (expectsCurrentBlocked) { + diagnostics.push('The current page origin is listed as expected blocked for this API.'); + } + if (preflight) { diagnostics.push('This request should trigger a browser preflight because it uses a non-simple method, header, or content type.'); } else if (draft.mode === 'cors') { @@ -262,11 +325,17 @@ function makeDiagnostics( if (preflight) { diagnostics.push('Check that OPTIONS responds with the requested method and headers in Access-Control-Allow-Methods and Access-Control-Allow-Headers.'); } + if (expectsCurrentAllowed && !expectsCurrentBlocked) { + diagnostics.push('Policy mismatch: this origin is expected allowed, but the browser test failed.'); + } } if (result.status === 'passed') { diagnostics.push('JavaScript could read the response, so the browser accepted the CORS policy for this request.'); diagnostics.push('Only safelisted response headers and headers named in Access-Control-Expose-Headers are visible here.'); + if (expectsCurrentBlocked && !expectsCurrentAllowed) { + diagnostics.push('Policy mismatch: this origin is expected blocked, but the browser test passed.'); + } } if (result.responseType === 'opaque') { @@ -277,6 +346,10 @@ function makeDiagnostics( diagnostics.push('CORS passed, but the API returned an HTTP error status. Debug the application response separately from CORS.'); } + if (draft.allowedOrigins.some((origin) => originKey(origin) !== currentOriginKey) || draft.blockedOrigins.some((origin) => originKey(origin) !== currentOriginKey)) { + diagnostics.push('To truly verify another domain, open this same static app or a small test page from that domain. A browser app cannot set Origin for domain A while running on domain B.'); + } + return diagnostics; } @@ -304,6 +377,13 @@ function makeReport(draft: TestDraft, result: Omit, applie headerLines, draft.body.trim() ? ` Body: ${draft.body}` : ' Body: (empty)', '', + 'Domain policy expectations', + ` Actual browser origin: ${getOriginLabel()}`, + ' Expected allowed origins:', + formatOriginList(draft.allowedOrigins), + ' Expected blocked origins:', + formatOriginList(draft.blockedOrigins), + '', 'Result', ` Status: ${result.status}`, ` Browser result: ${result.browserResult}`, @@ -331,6 +411,8 @@ function normalizedDraft(draft: TestDraft): TestDraft { ...draft, timeoutMs: Number.isFinite(draft.timeoutMs) ? Math.max(1000, draft.timeoutMs) : 12000, headers: draft.headers.length ? draft.headers : [emptyHeader()], + allowedOrigins: cleanOriginList(draft.allowedOrigins), + blockedOrigins: cleanOriginList(draft.blockedOrigins), }; } @@ -350,7 +432,14 @@ export default function App() { try { const parsed = JSON.parse(stored) as SavedCase[]; if (Array.isArray(parsed)) { - setSavedCases(parsed); + setSavedCases( + parsed.map((item) => ({ + ...normalizedDraft(item), + id: item.id || crypto.randomUUID(), + name: item.name || `${item.method} ${item.url}`, + savedAt: item.savedAt || new Date().toISOString(), + })), + ); } } catch { window.localStorage.removeItem(STORAGE_KEY); @@ -695,6 +784,43 @@ export default function App() { ))} +
+
+
+

Domain policy

+

Expected origins

+
+
+
+ Actual browser origin + {getOriginLabel()} +
+

+ This static page cannot spoof Origin. Use these lists to record expected allow/block rules; this run + only proves behavior for the actual browser origin above. +

+