Add static domain policy checks
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
130
src/App.tsx
130
src/App.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user