/* * 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, '>'); function headerLines(server, phase, client) { const out = []; const isPreflight = phase === 'preflight'; if (isPreflight && server.pfredirect === '1') { return ['302 Found', 'Location: /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 ? '' : ''}${status} ${statusText}${bad ? '' : ''}`); const suppressed = (isPreflight && server.pfcors === '0') || (!isPreflight && server.errnocors === '1' && Number(status) >= 400); if (suppressed) { out.push('(ningún header CORS)'); 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' ? `${PAGE} (reflejado)` : value; out.push(`${label}: ${value}`); } if (server.vary === '1') out.push('Vary: 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 = `${echoed || '(no pidió nada)'} (reflejado)`; } out.push(`${label}: ${value}`); } } else { out.push('X-Total-Count: 42'); out.push('X-Request-Id: req_…'); } if (out.length === 1) out.push('(sin headers CORS)'); return out; } function serverPanel(s) { const server = s.server || {}; const client = s.client || {}; const blocks = []; if (s.target === 'same') { return `# endpoint del mismo origen: deliberadamente sin headers CORS\n200 OK\nContent-Type: application/json`; } const reason = preflightReason(client); if (reason) { blocks.push(`# preflight OPTIONS — ${esc(reason)}\n` + headerLines(server, 'preflight', client).join('\n')); } blocks.push(`# respuesta al ${client.method || 'GET'}\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 = `await fetch('${esc(url)}'`; if (opts.length) code += `, {\n${esc(opts.join(',\n'))}\n}`; code += ')'; if (c.readHeaders) { code += '\n\n' + c.readHeaders.map((h) => `res.headers.get('${h}')`).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) ? `
↳ ${e.corsSent.map(esc).join('
↳ ')}
` : `
↳ no mandó headers CORS
`; const requested = e.requested && e.requested.method ? `
pidió: ${esc(e.requested.method)}${e.requested.headers ? ' + ' + esc(e.requested.headers) : ''}
` : ''; div.innerHTML = `
${e.method}${e.phase === 'preflight' ? 'preflight' : ''}` + `${e.status}
` + `
${esc(decodeURIComponent(e.url))}
` + `
origen: ${esc(e.origin || '(ninguno)')} · cookies: ${e.cookies && e.cookies.length ? esc(e.cookies.join(', ')) : 'ninguna'} · ${time}
` + 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( `
` + `${out.outcome === 'ok' ? 'La respuesta llegó a JavaScript' : 'Bloqueado por el navegador'}` + (out.status !== undefined ? `HTTP ${out.status}` : '') + (out.type ? `type: ${out.type}` : '') + `${out.elapsed} ms` + (matched ? '' : `no es lo que esperábamos`) + `
` ); if (out.error) parts.push(`
${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.
`); if (out.readable && Object.keys(out.readable).length) { parts.push( `
` + Object.entries(out.readable).map(([k, v]) => `
res.headers.get('${esc(k)}') → ${v === null ? 'null' : `${esc(v)}`}
` ).join('') + `
` ); } /* 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( `
` + (trace.length ? trace.map((e) => `
` + `${e.method}` + `${e.phase === 'preflight' ? 'preflight' : 'request real'}${e.cookies && e.cookies.length ? ' · cookies: ' + esc(e.cookies.join(', ')) : ' · sin cookies'}` + `${e.status}` + `
`).join('') : `
El servidor no registró nada en esta corrida.
`) + (note ? `
${esc(note)}
` : '') + `
` ); if (out.body) { parts.push( `
Cuerpo de la respuesta (${out.body.length} bytes) y headers legibles` + `
${esc(out.body.slice(0, 1400))}
` + `
headers que JS puede ver: ${esc((out.visibleHeaders || []).join(', ') || '(ninguno)')}
` + `
` ); } else if (out.outcome === 'ok') { parts.push(`
Headers legibles
${esc((out.visibleHeaders || []).join(', ') || '(ninguno — respuesta opaca)')}
`); } el.innerHTML = parts.join(''); el.classList.add('shown'); return matched; } /* ------------------------------------------------------------------ * * Special (non-fetch) demos * ------------------------------------------------------------------ */ function renderSpecial(s, host) { if (s.special === 'img') { host.innerHTML = `
<img src="${esc(API)}/api/image">
sin headers CORS, y sin mandar header Origin
todavía no cargó
<img crossorigin="anonymous" …>
ahora SÍ es un request CORS, y falla
todavía no cargó
`; host.querySelector('#imgGo').onclick = () => { const stamp = Date.now(); const plain = host.querySelector('#imgPlain'); const cors = host.querySelector('#imgCors'); plain.onload = () => (host.querySelector('#imgPlainState').innerHTML = 'cargó — el navegador nunca le pidió permiso a CORS'); plain.onerror = () => (host.querySelector('#imgPlainState').innerHTML = 'falló'); cors.onload = () => (host.querySelector('#imgCorsState').innerHTML = 'cargó'); cors.onerror = () => (host.querySelector('#imgCorsState').innerHTML = 'bloqueada — crossorigin metió a esta imagen dentro de CORS, y el servidor no mandó Allow-Origin'); 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 = `
este endpoint no tiene configurado ni un header CORS
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 escritura pasó; lo único protegido es la lectura.
`; 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 = [`${EXPECT_LABEL[s.expect]}`]; if (s.client) { chips.push(reason ? `con preflight` : `request simple`); } if (s.target === 'same') chips.push('mismo origen'); if (s.target === 'cross-site') chips.push('otro sitio'); if (s.client && s.client.credentials === 'include') chips.push('credenciales'); if (s.needsCookie) chips.push('necesita cookies'); card.innerHTML = `
${number}

${s.title}

${chips.join('')}
` + `

${s.why}

` + (s.client ? `

El servidor manda

${serverPanel(s)}

La página ejecuta

${clientPanel(s)}
${s.watch ? `Observá: ${s.watch}` : ''}
` : `${s.watch ? `
Observá: ${s.watch}
` : ''}`) + `
`; 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 = `

${group.title}

${group.blurb}

`; 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', `${group.title}`); }); nav.insertAdjacentHTML('beforeend', `6 · Laboratorio`); /* ------------------------------------------------------------------ * * Header: origin chips, cookies, run-all, theme, font size * ------------------------------------------------------------------ */ document.getElementById('pageOrigin').textContent = PAGE; document.getElementById('apiOrigin').textContent = API; document.getElementById('origins').innerHTML = `página ${PAGE}` + `API ${API}` + `otro sitio ${CROSS_SITE}` + `cookies ?`; 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 = `cookies ${n ? n + ' en la API' : 'ninguna'}`; chip.className = 'chip ' + (n ? 'live' : 'dead'); } catch (e) { chip.innerHTML = 'API 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 = '
Limpio.
'; }; 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 = `

Armá cualquier configuración de CORS

Cambiá lo que quieras: los paneles de abajo se actualizan en vivo. Después probalo y mirá el log del servidor.

Request que hace la página
Headers de la respuesta real
Headers del preflight

El servidor va a mandar

La página va a ejecutar

`; 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 va a tener preflight — ${esc(reason)}.` : s.client.mode === 'no-cors' ? `Modo no-cors: sin preflight, y los headers fuera de la safelist se descartan.` : `Esto es un request simple — 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'; };