698 lines
30 KiB
JavaScript
698 lines
30 KiB
JavaScript
|
|
/*
|
||
|
|
* 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
|
|
|
||
|
|
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><img src="${esc(API)}/api/image"></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><img crossorigin="anonymous" …></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á "Poner cookies en la API"">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 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';
|
||
|
|
};
|