cors-demo/public/app.js

698 lines
30 KiB
JavaScript
Raw Normal View History

2026-10-01 13:02:44 -03:00
/*
* The runner. Builds a card per scenario, fires the real request, and lines up
* three things side by side: what the server was told to send, what the browser
* let JavaScript see, and what the server actually logged.
*/
'use strict';
/* ------------------------------------------------------------------ *
* Origins
* ------------------------------------------------------------------ */
const PAGE = location.origin;
const HOST = location.hostname;
const OTHER_HOST = HOST === 'localhost' ? '127.0.0.1' : 'localhost';
const API = `${location.protocol}//${HOST}:3001`; // different origin, same site
const CROSS_SITE = `${location.protocol}//${OTHER_HOST}:3001`; // different origin AND site
const TARGETS = {
api: { base: API + '/api/data', label: 'cross-origin API' },
same: { base: PAGE + '/same-origin/api', label: 'same origin' },
'cross-site': { base: CROSS_SITE + '/api/data', label: 'cross-site API' },
};
/* ------------------------------------------------------------------ *
* Preflight prediction (the same rules the browser applies)
* ------------------------------------------------------------------ */
const SAFELISTED_HEADERS = new Set([
'accept', 'accept-language', 'content-language', 'content-type', 'range',
]);
const SAFELISTED_CONTENT_TYPES = new Set([
'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain',
]);
const SAFELISTED_METHODS = new Set(['GET', 'HEAD', 'POST']);
function preflightReason(client) {
const method = (client.method || 'GET').toUpperCase();
if (client.mode === 'no-cors') return null; // no-cors nunca hace preflight: descarta lo que no puede mandar
if (!SAFELISTED_METHODS.has(method)) return `el método ${method} no está en la safelist`;
for (const [name, value] of Object.entries(client.headers || {})) {
const n = name.toLowerCase();
if (!SAFELISTED_HEADERS.has(n)) return `el header ${name} no está en la safelist`;
if (n === 'content-type') {
const bare = String(value).split(';')[0].trim().toLowerCase();
if (!SAFELISTED_CONTENT_TYPES.has(bare)) return `Content-Type: ${bare} no está en la safelist`;
}
}
return null;
}
/* ------------------------------------------------------------------ *
* Rendering the server's configuration as the headers it will send
* ------------------------------------------------------------------ */
const HEADER_LABELS = [
['acao', 'Access-Control-Allow-Origin'],
['acac', 'Access-Control-Allow-Credentials'],
['aceh', 'Access-Control-Expose-Headers'],
];
const PREFLIGHT_LABELS = [
['acam', 'Access-Control-Allow-Methods'],
['acah', 'Access-Control-Allow-Headers'],
['acma', 'Access-Control-Max-Age'],
];
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
function headerLines(server, phase, client) {
const out = [];
const isPreflight = phase === 'preflight';
if (isPreflight && server.pfredirect === '1') {
return ['<span class="hl">302 Found</span>', '<span class="k">Location</span>: /api/data?…'];
}
const status = isPreflight ? server.pfstatus || '204' : server.status || '200';
const statusText = { 200: 'OK', 204: 'No Content', 401: 'Unauthorized', 500: 'Internal Server Error' }[status] || '';
const bad = Number(status) >= 400;
out.push(`${bad ? '<span class="hl">' : ''}${status} ${statusText}${bad ? '</span>' : ''}`);
const suppressed =
(isPreflight && server.pfcors === '0') ||
(!isPreflight && server.errnocors === '1' && Number(status) >= 400);
if (suppressed) {
out.push('<span class="c">(ningún header CORS)</span>');
return out;
}
for (const [key, label] of HEADER_LABELS) {
if (server[key] === undefined) continue;
if (isPreflight && key === 'acao' && server.pfacao === '0') continue;
if (isPreflight && key === 'acac' && server.pfacac === '0') continue;
if (!isPreflight && key === 'acam') continue;
let value = server[key];
if (value === 'echo') value = key === 'acao' ? `<span class="c">${PAGE} (reflejado)</span>` : value;
out.push(`<span class="k">${label}</span>: ${value}`);
}
if (server.vary === '1') out.push('<span class="k">Vary</span>: Origin');
if (isPreflight) {
for (const [key, label] of PREFLIGHT_LABELS) {
if (server[key] === undefined) continue;
let value = server[key];
if (value === 'echo') {
const echoed = key === 'acah'
? Object.keys(client.headers || {}).map((h) => h.toLowerCase()).join(', ')
: (client.method || 'GET');
value = `<span class="c">${echoed || '(no pidió nada)'} (reflejado)</span>`;
}
out.push(`<span class="k">${label}</span>: ${value}`);
}
} else {
out.push('<span class="c">X-Total-Count: 42</span>');
out.push('<span class="c">X-Request-Id: req_…</span>');
}
if (out.length === 1) out.push('<span class="c">(sin headers CORS)</span>');
return out;
}
function serverPanel(s) {
const server = s.server || {};
const client = s.client || {};
const blocks = [];
if (s.target === 'same') {
return `<span class="c"># endpoint del mismo origen: deliberadamente sin headers CORS</span>\n200 OK\n<span class="c">Content-Type: application/json</span>`;
}
const reason = preflightReason(client);
if (reason) {
blocks.push(`<span class="c"># preflight OPTIONS — ${esc(reason)}</span>\n` + headerLines(server, 'preflight', client).join('\n'));
}
blocks.push(`<span class="c"># respuesta al ${client.method || 'GET'}</span>\n` + headerLines(server, 'actual', client).join('\n'));
return blocks.join('\n\n');
}
function prettyQuery(server) {
return new URLSearchParams(server).toString().replace(/\+/g, ' ').replace(/%[0-9A-F]{2}/gi, (m) => {
try { return decodeURIComponent(m); } catch (e) { return m; }
});
}
function clientPanel(s) {
const c = s.client || {};
const target = TARGETS[s.target || 'api'];
// Shown decoded: percent-encoding is unreadable on a projector, and this is
// exactly how the browser's own address bar and Network tab display it.
const query = prettyQuery(s.server || {});
const url = target.base + (query ? '?' + query : '');
const opts = [];
if (c.method && c.method !== 'GET') opts.push(` method: '${c.method}'`);
if (c.mode) opts.push(` mode: '${c.mode}'`);
if (c.credentials) opts.push(` credentials: '${c.credentials}'`);
if (c.headers) {
const hs = Object.entries(c.headers).map(([k, v]) => ` '${k}': '${v}'`).join(',\n');
opts.push(` headers: {\n${hs}\n }`);
}
if (c.body) opts.push(` body: ${JSON.stringify(c.body)}`);
let code = `<span class="k">await fetch</span>(<span class="s">'${esc(url)}'</span>`;
if (opts.length) code += `, {\n${esc(opts.join(',\n'))}\n}`;
code += ')';
if (c.readHeaders) {
code += '\n\n' + c.readHeaders.map((h) => `res.headers.<span class="k">get</span>(<span class="s">'${h}'</span>)`).join('\n');
}
return code;
}
/* ------------------------------------------------------------------ *
* The live server log
* ------------------------------------------------------------------ */
const logEntries = [];
let lastSeq = 0;
const logBody = document.getElementById('logBody');
const logStatus = document.getElementById('logStatus');
function connectLog() {
const es = new EventSource('/log/stream');
es.onopen = () => { logStatus.textContent = 'en vivo'; logStatus.className = 'chip live'; };
es.onerror = () => { logStatus.textContent = 'reconectando'; logStatus.className = 'chip dead'; };
es.onmessage = (ev) => {
const entry = JSON.parse(ev.data);
logEntries.push(entry);
lastSeq = Math.max(lastSeq, entry.seq);
appendLogEntry(entry);
};
}
function appendLogEntry(e) {
if (logBody.firstElementChild && logBody.firstElementChild.className === 'hint') logBody.innerHTML = '';
const div = document.createElement('div');
div.className = 'entry ' + e.phase;
const time = new Date(e.at).toLocaleTimeString([], { hour12: false });
const sent = (e.corsSent && e.corsSent.length)
? `<div class="sub sent">↳ ${e.corsSent.map(esc).join('<br>↳ ')}</div>`
: `<div class="sub none">↳ no mandó headers CORS</div>`;
const requested = e.requested && e.requested.method
? `<div class="sub">pidió: ${esc(e.requested.method)}${e.requested.headers ? ' + ' + esc(e.requested.headers) : ''}</div>`
: '';
div.innerHTML =
`<div class="line1"><span class="m">${e.method}</span><span class="c">${e.phase === 'preflight' ? 'preflight' : ''}</span>` +
`<span class="s s${String(e.status)[0]}">${e.status}</span></div>` +
`<div class="sub">${esc(decodeURIComponent(e.url))}</div>` +
`<div class="sub">origen: ${esc(e.origin || '(ninguno)')} · cookies: ${e.cookies && e.cookies.length ? esc(e.cookies.join(', ')) : 'ninguna'} · ${time}</div>` +
requested + sent;
logBody.prepend(div);
while (logBody.children.length > 120) logBody.lastElementChild.remove();
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/* ------------------------------------------------------------------ *
* Running a scenario
* ------------------------------------------------------------------ */
async function runScenario(s, resultEl) {
const target = TARGETS[s.target || 'api'];
const params = new URLSearchParams(s.server || {});
// A unique param keeps each run out of the browser's preflight cache, except
// where the scenario is specifically about that cache.
if (!s.stableUrl) params.set('_', Date.now().toString(36));
const url = target.base + (params.toString() ? '?' + params.toString() : '');
const init = {};
if (s.client.method) init.method = s.client.method;
if (s.client.mode) init.mode = s.client.mode;
if (s.client.credentials) init.credentials = s.client.credentials;
if (s.client.headers) init.headers = s.client.headers;
if (s.client.body) init.body = s.client.body;
const seqBefore = lastSeq;
const t0 = performance.now();
const out = { predicted: preflightReason(s.client) };
try {
const res = await fetch(url, init);
out.elapsed = Math.round(performance.now() - t0);
out.outcome = 'ok';
out.status = res.status;
out.ok = res.ok;
out.type = res.type;
out.readable = {};
for (const h of s.client.readHeaders || []) out.readable[h] = res.headers.get(h);
out.visibleHeaders = [...res.headers.keys()];
try { out.body = await res.text(); } catch (e) { out.body = ''; }
} catch (err) {
out.elapsed = Math.round(performance.now() - t0);
out.outcome = 'blocked';
out.error = `${err.name}: ${err.message}`;
}
await sleep(280); // dejar que el log por SSE se ponga al día
out.trace = logEntries.filter((e) => e.seq > seqBefore);
renderResult(s, out, resultEl);
return out;
}
function renderResult(s, out, el) {
const expectedOutcome = s.expect === 'fail' ? 'blocked' : 'ok';
const matched = s.expect === 'any' || out.outcome === expectedOutcome;
const parts = [];
parts.push(
`<div class="verdict">` +
`<span class="big ${out.outcome}">${out.outcome === 'ok' ? 'La respuesta llegó a JavaScript' : 'Bloqueado por el navegador'}</span>` +
(out.status !== undefined ? `<span class="badge neutral">HTTP ${out.status}</span>` : '') +
(out.type ? `<span class="badge neutral">type: ${out.type}</span>` : '') +
`<span class="meta">${out.elapsed} ms</span>` +
(matched ? '' : `<span class="surprise">no es lo que esperábamos</span>`) +
`</div>`
);
if (out.error) parts.push(`<div class="errbox">${esc(out.error)}\n\nEl navegador se niega a decir más que esto a propósito: una razón detallada ya filtraría información de otro origen. El mensaje real está en la consola de DevTools.</div>`);
if (out.readable && Object.keys(out.readable).length) {
parts.push(
`<div class="kv">` +
Object.entries(out.readable).map(([k, v]) =>
`<div>res.headers.get('${esc(k)}') → ${v === null ? '<span class="null">null</span>' : `<span class="val">${esc(v)}</span>`}</div>`
).join('') +
`</div>`
);
}
/* what the browser sent, per the server's own log */
const trace = out.trace || [];
const sawPreflight = trace.some((e) => e.phase === 'preflight');
let note = '';
if (out.predicted && !sawPreflight) note = 'Se esperaba un preflight y no está: el navegador reutilizó uno cacheado.';
if (!out.predicted && !sawPreflight && s.client.mode !== 'no-cors') note = 'Request simple: no hace falta preflight.';
parts.push(
`<div class="trace">` +
(trace.length
? trace.map((e) =>
`<div class="trace-row ${e.phase === 'preflight' ? 'pre' : ''}">` +
`<span class="m">${e.method}</span>` +
`<span>${e.phase === 'preflight' ? 'preflight' : 'request real'}${e.cookies && e.cookies.length ? ' · cookies: ' + esc(e.cookies.join(', ')) : ' · sin cookies'}</span>` +
`<span class="st">${e.status}</span>` +
`</div>`).join('')
: `<div class="trace-empty">El servidor no registró nada en esta corrida.</div>`) +
(note ? `<div class="trace-empty">${esc(note)}</div>` : '') +
`</div>`
);
if (out.body) {
parts.push(
`<details class="more"><summary>Cuerpo de la respuesta (${out.body.length} bytes) y headers legibles</summary>` +
`<pre>${esc(out.body.slice(0, 1400))}</pre>` +
`<pre>headers que JS puede ver: ${esc((out.visibleHeaders || []).join(', ') || '(ninguno)')}</pre>` +
`</details>`
);
} else if (out.outcome === 'ok') {
parts.push(`<details class="more"><summary>Headers legibles</summary><pre>${esc((out.visibleHeaders || []).join(', ') || '(ninguno — respuesta opaca)')}</pre></details>`);
}
el.innerHTML = parts.join('');
el.classList.add('shown');
return matched;
}
/* ------------------------------------------------------------------ *
* Special (non-fetch) demos
* ------------------------------------------------------------------ */
function renderSpecial(s, host) {
if (s.special === 'img') {
host.innerHTML = `
<div class="demo-row">
<div class="demo-box">
<b>&lt;img src="${esc(API)}/api/image"&gt;</b><br>
sin headers CORS, y sin mandar header Origin
<img id="imgPlain" alt="">
<div id="imgPlainState">todavía no cargó</div>
</div>
<div class="demo-box">
<b>&lt;img crossorigin="anonymous" …&gt;</b><br>
ahora SÍ es un request CORS, y falla
<img id="imgCors" crossorigin="anonymous" alt="">
<div id="imgCorsState">todavía no cargó</div>
</div>
</div>
<div class="actions"><button class="run" id="imgGo">Cargar las dos imágenes</button></div>`;
host.querySelector('#imgGo').onclick = () => {
const stamp = Date.now();
const plain = host.querySelector('#imgPlain');
const cors = host.querySelector('#imgCors');
plain.onload = () => (host.querySelector('#imgPlainState').innerHTML = '<span style="color:var(--ok)">cargó — el navegador nunca le pidió permiso a CORS</span>');
plain.onerror = () => (host.querySelector('#imgPlainState').innerHTML = '<span style="color:var(--bad)">falló</span>');
cors.onload = () => (host.querySelector('#imgCorsState').innerHTML = '<span style="color:var(--ok)">cargó</span>');
cors.onerror = () => (host.querySelector('#imgCorsState').innerHTML = '<span style="color:var(--bad)">bloqueada — crossorigin metió a esta imagen dentro de CORS, y el servidor no mandó Allow-Origin</span>');
plain.src = `${API}/api/image?t=${stamp}`;
cors.src = `${API}/api/image?t=${stamp}&nocors=1`;
};
host.classList.add('shown');
return;
}
if (s.special === 'form') {
host.innerHTML = `
<form method="POST" action="${esc(API)}/api/data" target="cors-sink" id="csrfForm">
<div class="actions">
<input type="text" name="amount" value="1000" style="width:7rem">
<input type="text" name="to" value="attacker" style="width:9rem">
<button class="run" type="submit">Enviar formulario a otro origen</button>
<span class="watch">este endpoint no tiene configurado ni un header CORS</span>
</div>
</form>
<iframe name="cors-sink" class="sink"></iframe>
<div class="watch" style="margin-top:.4rem">
La respuesta del servidor se renderiza en ese frame, así que podés ver que ocurrió — pero el JavaScript de esta
página no puede leer ni un byte. La <em>escritura</em> pasó; lo único protegido es la <em>lectura</em>.
</div>`;
host.classList.add('shown');
return;
}
}
/* ------------------------------------------------------------------ *
* Building the page
* ------------------------------------------------------------------ */
const EXPECT_LABEL = {
pass: 'debería funcionar',
fail: 'debería bloquearse',
partial: 'funciona, pero…',
special: 'no es un fetch',
};
function buildCard(s, number) {
const card = document.createElement('article');
card.className = 'card';
card.dataset.expect = s.expect;
card.id = 'sc-' + s.id;
const reason = s.client ? preflightReason(s.client) : null;
const chips = [`<span class="badge ${s.expect}">${EXPECT_LABEL[s.expect]}</span>`];
if (s.client) {
chips.push(reason
? `<span class="badge partial" title="${esc(reason)}">con preflight</span>`
: `<span class="badge neutral">request simple</span>`);
}
if (s.target === 'same') chips.push('<span class="badge neutral">mismo origen</span>');
if (s.target === 'cross-site') chips.push('<span class="badge neutral">otro sitio</span>');
if (s.client && s.client.credentials === 'include') chips.push('<span class="badge neutral">credenciales</span>');
if (s.needsCookie) chips.push('<span class="badge neutral" title="Primero tocá &quot;Poner cookies en la API&quot;">necesita cookies</span>');
card.innerHTML =
`<div class="card-head"><span class="num">${number}</span><h3>${s.title}</h3>${chips.join('')}</div>` +
`<p class="why">${s.why}</p>` +
(s.client
? `<div class="cols">
<div class="panel"><h4>El servidor manda</h4><pre>${serverPanel(s)}</pre></div>
<div class="panel"><h4>La página ejecuta</h4><pre>${clientPanel(s)}</pre></div>
</div>
<div class="actions">
<button class="run">Probar</button>
${s.watch ? `<span class="watch"><b>Observá:</b> ${s.watch}</span>` : ''}
</div>`
: `${s.watch ? `<div class="watch"><b>Observá:</b> ${s.watch}</div>` : ''}`) +
`<div class="result"></div>`;
const result = card.querySelector('.result');
if (s.client) {
const btn = card.querySelector('button.run');
btn.onclick = async () => {
btn.disabled = true;
btn.textContent = 'probando…';
await runScenario(s, result);
btn.disabled = false;
btn.textContent = 'Probar de nuevo';
};
card._run = () => runScenario(s, result);
} else {
renderSpecial(s, result);
}
return card;
}
const scenarioHost = document.getElementById('scenarios');
const nav = document.getElementById('nav');
const cards = [];
window.GROUPS.forEach((group, gi) => {
const section = document.createElement('section');
section.className = 'group';
section.id = 'g-' + group.id;
section.innerHTML = `<h2>${group.title}</h2><p class="blurb">${group.blurb}</p>`;
const list = window.SCENARIOS.filter((s) => s.group === group.id);
list.forEach((s, i) => {
const card = buildCard(s, `${gi + 1}.${i + 1}`);
section.appendChild(card);
if (card._run) cards.push({ s, card });
});
scenarioHost.appendChild(section);
nav.insertAdjacentHTML('beforeend', `<a href="#g-${group.id}">${group.title}</a>`);
});
nav.insertAdjacentHTML('beforeend', `<a href="#g-playground">6 · Laboratorio</a>`);
/* ------------------------------------------------------------------ *
* Header: origin chips, cookies, run-all, theme, font size
* ------------------------------------------------------------------ */
document.getElementById('pageOrigin').textContent = PAGE;
document.getElementById('apiOrigin').textContent = API;
document.getElementById('origins').innerHTML =
`<span class="chip"><b>página</b> ${PAGE}</span>` +
`<span class="chip"><b>API</b> ${API}</span>` +
`<span class="chip"><b>otro sitio</b> ${CROSS_SITE}</span>` +
`<span class="chip" id="cookieChip"><b>cookies</b> ?</span>`;
async function refreshCookieChip() {
const chip = document.getElementById('cookieChip');
try {
const res = await fetch(`${API}/api/data?acao=echo&acac=true&_=${Date.now()}`, { credentials: 'include' });
const data = await res.json();
const n = data.seenByServer.cookies.length;
chip.innerHTML = `<b>cookies</b> ${n ? n + ' en la API' : 'ninguna'}`;
chip.className = 'chip ' + (n ? 'live' : 'dead');
} catch (e) {
chip.innerHTML = '<b>API</b> no responde';
chip.className = 'chip dead';
}
}
document.getElementById('cookieSet').onclick = () => {
window.open(`${API}/api/cookie/set`, '_blank');
setTimeout(refreshCookieChip, 1500);
};
document.getElementById('runAll').onclick = async (ev) => {
const btn = ev.currentTarget;
btn.disabled = true;
let surprises = 0;
for (let i = 0; i < cards.length; i++) {
const { s, card } = cards[i];
btn.textContent = `probando ${i + 1}/${cards.length}…`;
card.scrollIntoView({ block: 'center', behavior: 'smooth' });
const out = await card._run();
const expected = s.expect === 'fail' ? 'blocked' : 'ok';
if (out.outcome !== expected) surprises++;
}
btn.disabled = false;
btn.textContent = 'Probar todo';
alert(
`Se probaron ${cards.length} escenarios.\n` +
`${cards.length - surprises} se comportaron exactamente como dice la ficha.\n` +
(surprises
? `${surprises} no — buscá la etiqueta naranja "no es lo que esperábamos". ` +
`La causa habitual es que todavía no pusiste las cookies, o que el navegador bloquea cookies de terceros.`
: `Ninguna sorpresa. Tu navegador está jugando según el manual.`)
);
};
document.getElementById('clearAll').onclick = () => {
document.querySelectorAll('.result').forEach((r) => {
if (r.querySelector('form') || r.querySelector('img')) return;
r.classList.remove('shown');
r.innerHTML = '';
});
document.querySelectorAll('button.run').forEach((b) => { if (b.textContent === 'Probar de nuevo') b.textContent = 'Probar'; });
};
document.getElementById('logClear').onclick = () => {
logEntries.length = 0;
logBody.innerHTML = '<div class="hint">Limpio.</div>';
};
const root = document.documentElement;
document.getElementById('theme').onclick = () => {
const current = root.getAttribute('data-theme');
root.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
};
let scale = 1;
const setScale = (v) => { scale = Math.min(1.6, Math.max(0.8, v)); root.style.setProperty('--scale', scale); };
document.getElementById('fontUp').onclick = () => setScale(scale + 0.1);
document.getElementById('fontDown').onclick = () => setScale(scale - 0.1);
connectLog();
refreshCookieChip();
/* ------------------------------------------------------------------ *
* The free playground: every knob, wired to the same runner.
* The two preview panels update as you type, so the class can see what
* a toggle changes before anything is sent.
* ------------------------------------------------------------------ */
const pg = document.getElementById('pg');
pg.innerHTML = `
<h2>Armá cualquier configuración de CORS</h2>
<p>Cambiá lo que quieras: los paneles de abajo se actualizan en vivo. Después probalo y mirá el log del servidor.</p>
<div class="pg-grid">
<fieldset>
<legend>Request que hace la página</legend>
<div class="field"><label>destino</label><select id="p_target">
<option value="api">otro origen (:3001)</option>
<option value="cross-site">otro sitio (${OTHER_HOST}:3001)</option>
<option value="same">mismo origen</option>
</select></div>
<div class="field"><label>método</label><select id="p_method">
<option>GET</option><option>HEAD</option><option>POST</option>
<option>PUT</option><option>PATCH</option><option>DELETE</option>
</select></div>
<div class="field"><label>mode</label><select id="p_mode">
<option value="">cors (por defecto)</option><option value="no-cors">no-cors</option><option value="same-origin">same-origin</option>
</select></div>
<div class="field"><label>credentials</label><select id="p_credentials">
<option value="">same-origin (por defecto)</option><option value="omit">omit</option><option value="include">include</option>
</select></div>
<div class="field"><label>headers</label><textarea id="p_headers" placeholder="Content-Type: application/json&#10;X-Api-Key: abc123"></textarea></div>
<div class="field"><label>body</label><textarea id="p_body" placeholder='{"hola":"mundo"}'></textarea></div>
<div class="field"><label>headers a leer</label><input type="text" id="p_read" value="Content-Type, X-Total-Count, X-Request-Id"></div>
</fieldset>
<fieldset>
<legend>Headers de la respuesta real</legend>
<div class="field"><label>Allow-Origin</label><input type="text" id="p_acao" list="acaoPresets" placeholder="(header omitido)">
<datalist id="acaoPresets">
<option value="*"></option><option value="echo"></option>
<option value="${PAGE}"></option><option value="${PAGE}/"></option>
<option value="http://evil.example.com"></option>
<option value="${PAGE}, http://localhost:4000"></option>
</datalist></div>
<div class="field check"><label>Allow-Credentials</label><input type="checkbox" id="p_acac"></div>
<div class="field"><label>Expose-Headers</label><input type="text" id="p_aceh" placeholder="(omitido) probá: * o X-Total-Count"></div>
<div class="field check"><label>Vary: Origin</label><input type="checkbox" id="p_vary" checked></div>
<div class="field"><label>status</label><input type="number" id="p_status" value="200"></div>
<div class="field check"><label>sin CORS si ≥400</label><input type="checkbox" id="p_errnocors"></div>
<div class="field"><label>demora (ms)</label><input type="number" id="p_delay" value="0" step="100"></div>
</fieldset>
<fieldset>
<legend>Headers del preflight</legend>
<div class="field"><label>Allow-Methods</label><input type="text" id="p_acam" placeholder="(omitido) probá: echo o PUT,DELETE"></div>
<div class="field"><label>Allow-Headers</label><input type="text" id="p_acah" placeholder="(omitido) probá: echo o *"></div>
<div class="field"><label>Max-Age (s)</label><input type="text" id="p_acma" placeholder="(omitido)"></div>
<div class="field"><label>status del OPTIONS</label><input type="number" id="p_pfstatus" value="204"></div>
<div class="field check"><label>sin CORS en OPTIONS</label><input type="checkbox" id="p_pfcors"></div>
<div class="field check"><label>omitir Allow-Origin</label><input type="checkbox" id="p_pfacao"></div>
<div class="field check"><label>omitir Allow-Creds</label><input type="checkbox" id="p_pfacac"></div>
<div class="field check"><label>redirigir OPTIONS</label><input type="checkbox" id="p_pfredirect"></div>
<div class="field check"><label>URL estable (caché)</label><input type="checkbox" id="p_stable" title="Reutiliza la misma URL para que el navegador pueda cachear el preflight"></div>
</fieldset>
</div>
<div class="cols" style="margin-top:.9rem">
<div class="panel"><h4>El servidor va a mandar</h4><pre id="pgServer"></pre></div>
<div class="panel"><h4>La página va a ejecutar</h4><pre id="pgClient"></pre></div>
</div>
<div class="pg-actions">
<button class="primary" id="pgRun">Probar</button>
<span class="watch" id="pgVerdict"></span>
</div>
<div class="result" id="pgResult"></div>`;
const P = (id) => document.getElementById('p_' + id);
function parseHeaderLines(text) {
const out = {};
for (const line of text.split('\n')) {
const i = line.indexOf(':');
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
return Object.keys(out).length ? out : undefined;
}
function playgroundScenario() {
const server = {};
const put = (key, value) => { if (value !== '' && value !== undefined && value !== null) server[key] = String(value); };
put('acao', P('acao').value.trim());
if (P('acac').checked) server.acac = 'true';
put('aceh', P('aceh').value.trim());
if (P('vary').checked) server.vary = '1';
if (P('status').value && P('status').value !== '200') server.status = P('status').value;
if (P('errnocors').checked) server.errnocors = '1';
if (Number(P('delay').value) > 0) server.delay = P('delay').value;
put('acam', P('acam').value.trim());
put('acah', P('acah').value.trim());
put('acma', P('acma').value.trim());
if (P('pfstatus').value && P('pfstatus').value !== '204') server.pfstatus = P('pfstatus').value;
if (P('pfcors').checked) server.pfcors = '0';
if (P('pfacao').checked) server.pfacao = '0';
if (P('pfacac').checked) server.pfacac = '0';
if (P('pfredirect').checked) server.pfredirect = '1';
const client = { method: P('method').value };
if (P('mode').value) client.mode = P('mode').value;
if (P('credentials').value) client.credentials = P('credentials').value;
const headers = parseHeaderLines(P('headers').value);
if (headers) client.headers = headers;
const body = P('body').value.trim();
if (body && !['GET', 'HEAD'].includes(client.method)) client.body = body;
const read = P('read').value.split(',').map((h) => h.trim()).filter(Boolean);
if (read.length) client.readHeaders = read;
return {
id: 'playground',
target: P('target').value,
stableUrl: P('stable').checked,
expect: 'any',
server,
client,
};
}
function refreshPlayground() {
const s = playgroundScenario();
document.getElementById('pgServer').innerHTML = serverPanel(s);
document.getElementById('pgClient').innerHTML = clientPanel(s);
const reason = preflightReason(s.client);
document.getElementById('pgVerdict').innerHTML = reason
? `Este request <b>va a tener preflight</b> — ${esc(reason)}.`
: s.client.mode === 'no-cors'
? `Modo no-cors: sin preflight, y los headers fuera de la safelist se descartan.`
: `Esto es un <b>request simple</b> — sin preflight.`;
}
pg.addEventListener('input', refreshPlayground);
pg.addEventListener('change', refreshPlayground);
refreshPlayground();
document.getElementById('pgRun').onclick = async (ev) => {
const btn = ev.currentTarget;
btn.disabled = true;
btn.textContent = 'probando…';
await runScenario(playgroundScenario(), document.getElementById('pgResult'));
btn.disabled = false;
btn.textContent = 'Probar';
};