Add static domain policy checks

This commit is contained in:
Meghdad
2026-06-21 04:16:24 +03:30
parent 851a8aad89
commit 6ff15abb8f
3 changed files with 177 additions and 2 deletions

View File

@@ -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. - Preflight detection based on method, headers, and content type.
- Result view with status, timing, visible response headers, readable body preview, and browser error classification. - 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. - 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. - Saved test cases in `localStorage`, with JSON import/export.
- Copyable diagnostic report for API teams. - 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. - 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. - `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://<user>.github.io`. - Testing from GitHub Pages means your API sees the app origin as `https://<user>.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 ## Local Development

View File

@@ -19,6 +19,8 @@ type TestDraft = {
body: string; body: string;
timeoutMs: number; timeoutMs: number;
headers: HeaderRow[]; headers: HeaderRow[];
allowedOrigins: string[];
blockedOrigins: string[];
}; };
type SavedCase = TestDraft & { type SavedCase = TestDraft & {
@@ -75,6 +77,8 @@ const defaultDraft: TestDraft = {
body: '', body: '',
timeoutMs: 12000, timeoutMs: 12000,
headers: [{ id: crypto.randomUUID(), name: '', value: '' }], headers: [{ id: crypto.randomUUID(), name: '', value: '' }],
allowedOrigins: [],
blockedOrigins: [],
}; };
const scenarios: Scenario[] = [ const scenarios: Scenario[] = [
@@ -191,7 +195,11 @@ function buildHeaders(draft: TestDraft) {
return; return;
} }
if (isForbiddenHeader(name)) { 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; return;
} }
names.add(name.toLowerCase()); names.add(name.toLowerCase());
@@ -221,6 +229,43 @@ function getOriginLabel() {
return window.location.origin; 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( function makeDiagnostics(
draft: TestDraft, draft: TestDraft,
result: Pick<TestResult, 'status' | 'responseType' | 'statusCode' | 'error'>, result: Pick<TestResult, 'status' | 'responseType' | 'statusCode' | 'error'>,
@@ -229,6 +274,12 @@ function makeDiagnostics(
const diagnostics: string[] = []; const diagnostics: string[] = [];
const target = parseTargetUrl(draft.url); const target = parseTargetUrl(draft.url);
const preflight = expectsPreflight(draft, appliedHeaders); 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) { if (!target) {
diagnostics.push('The URL is not valid. Use an absolute http:// or https:// API URL.'); 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.'); 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) { if (preflight) {
diagnostics.push('This request should trigger a browser preflight because it uses a non-simple method, header, or content type.'); 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') { } else if (draft.mode === 'cors') {
@@ -262,11 +325,17 @@ function makeDiagnostics(
if (preflight) { if (preflight) {
diagnostics.push('Check that OPTIONS responds with the requested method and headers in Access-Control-Allow-Methods and Access-Control-Allow-Headers.'); 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') { if (result.status === 'passed') {
diagnostics.push('JavaScript could read the response, so the browser accepted the CORS policy for this request.'); 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.'); 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') { 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.'); 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; return diagnostics;
} }
@@ -304,6 +377,13 @@ function makeReport(draft: TestDraft, result: Omit<TestResult, 'report'>, applie
headerLines, headerLines,
draft.body.trim() ? ` Body: ${draft.body}` : ' Body: (empty)', 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', 'Result',
` Status: ${result.status}`, ` Status: ${result.status}`,
` Browser result: ${result.browserResult}`, ` Browser result: ${result.browserResult}`,
@@ -331,6 +411,8 @@ function normalizedDraft(draft: TestDraft): TestDraft {
...draft, ...draft,
timeoutMs: Number.isFinite(draft.timeoutMs) ? Math.max(1000, draft.timeoutMs) : 12000, timeoutMs: Number.isFinite(draft.timeoutMs) ? Math.max(1000, draft.timeoutMs) : 12000,
headers: draft.headers.length ? draft.headers : [emptyHeader()], headers: draft.headers.length ? draft.headers : [emptyHeader()],
allowedOrigins: cleanOriginList(draft.allowedOrigins),
blockedOrigins: cleanOriginList(draft.blockedOrigins),
}; };
} }
@@ -350,7 +432,14 @@ export default function App() {
try { try {
const parsed = JSON.parse(stored) as SavedCase[]; const parsed = JSON.parse(stored) as SavedCase[];
if (Array.isArray(parsed)) { 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 { } catch {
window.localStorage.removeItem(STORAGE_KEY); window.localStorage.removeItem(STORAGE_KEY);
@@ -695,6 +784,43 @@ export default function App() {
))} ))}
</div> </div>
<div className="policy-panel">
<div className="section-heading compact">
<div>
<p className="eyebrow">Domain policy</p>
<h2>Expected origins</h2>
</div>
</div>
<div className="policy-origin">
<span>Actual browser origin</span>
<strong>{getOriginLabel()}</strong>
</div>
<p className="policy-note">
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.
</p>
<label className="field policy-field">
<span>Expected allowed origins</span>
<textarea
className="origin-textarea"
value={originLines(draft.allowedOrigins)}
onChange={(event) => patchDraft({ allowedOrigins: parseOriginLines(event.target.value) })}
placeholder={'https://app.example.com\nhttps://admin.example.com'}
spellCheck={false}
/>
</label>
<label className="field policy-field">
<span>Expected blocked origins</span>
<textarea
className="origin-textarea"
value={originLines(draft.blockedOrigins)}
onChange={(event) => patchDraft({ blockedOrigins: parseOriginLines(event.target.value) })}
placeholder={'https://unknown.example.com\nhttps://staging.example.net'}
spellCheck={false}
/>
</label>
</div>
<div className="saved-panel"> <div className="saved-panel">
<div className="section-subhead"> <div className="section-subhead">
<h3>Saved cases</h3> <h3>Saved cases</h3>

View File

@@ -352,11 +352,58 @@ textarea:focus {
.scenario-card span, .scenario-card span,
.saved-item span, .saved-item span,
.history-row span, .history-row span,
.policy-note,
.muted { .muted {
color: #64748b; color: #64748b;
font-size: 0.9rem; font-size: 0.9rem;
} }
.policy-panel {
margin-top: 20px;
border-top: 1px solid #e2e8f0;
padding-top: 18px;
}
.policy-origin {
border: 1px solid #dbeafe;
border-radius: 8px;
padding: 12px;
background: #eff6ff;
}
.policy-origin span,
.policy-origin strong {
display: block;
}
.policy-origin span {
margin-bottom: 4px;
color: #2563eb;
font-size: 0.76rem;
font-weight: 900;
text-transform: uppercase;
}
.policy-origin strong {
overflow-wrap: anywhere;
color: #1e3a8a;
}
.policy-note {
margin: 10px 0 14px;
line-height: 1.5;
}
.policy-field {
margin-top: 12px;
}
.origin-textarea {
min-height: 94px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.84rem;
}
.saved-panel { .saved-panel {
margin-top: 20px; margin-top: 20px;
} }