SkateSpots/public/boot-guard.js
shakespeare.diy b4b2375921 Security: fix stored XSS in map popups + add web CSP
Critical: Leaflet marker popups built untrusted Nostr event fields (and
BTC Map API fields) into innerHTML, bypassing React's output encoding. Any
user could publish a spot/tour/crew/event with an <img onerror> payload in a
text field; it executed on /map render with no interaction, letting injected
script read the plaintext nsec from localStorage (nostr:login) and the Cashu
seed (cashu-mnemonic) — permanent, unrotatable account + wallet takeover.

Fixes in src/components/SpotMap.tsx:
- Rebuild all popup DOM with createElement + textContent instead of innerHTML
  for every untrusted field: spot.location; tour.title/location/meetingPoint;
  evt.title/location; crew.name/location; BTC Map name/address/opening_hours/
  phone/description.
- Validate every event/API URL with sanitizeUrl() before it reaches an
  href/src: crew.image (popup + marker icon) and BTC Map website. Drop
  anything that isn't http(s). tel: links reduced to safe characters.
- Replace inline onerror handler on the crew marker image (would be blocked
  by CSP anyway) with a scripted img.onerror fallback.
- Add popupRow() helper so untrusted detail rows can never be innerHTML'd.

Defense in depth (web only, native Capacitor build unaffected):
- Add public/_headers with a strict Content-Security-Policy: default-src
  'none'; script-src 'self' 'wasm-unsafe-eval' (no unsafe-inline/eval);
  object-src 'none'; base-uri 'none'; frame-ancestors 'none'; plus the
  https:/wss: allowances a Nostr client genuinely needs for relays, media,
  and mints. Also X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
  Permissions-Policy.
- Move the inline boot-crash-guard script to public/boot-guard.js so
  script-src can stay 'self' with no 'unsafe-inline'; index.html now loads it
  externally. The guard also now paints errors via textContent, not innerHTML.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-08-16 22:12:26 +07:00

58 lines
2.7 KiB
JavaScript

/*
BOOT CRASH GUARD (native-safe).
Loaded as an external script so the web build can ship a strict
Content-Security-Policy (script-src 'self') with no 'unsafe-inline'.
Paints any uncaught boot error directly to the screen using inline styles
only — so a failure that would otherwise be a silent white screen becomes a
readable message on device. If the app boots normally it clears itself.
*/
(function () {
var shown = false;
function paint(kind, err) {
if (shown) return;
shown = true;
try {
var msg = (err && (err.stack || err.message)) ? (err.stack || err.message) : String(err);
var el = document.createElement('div');
el.setAttribute('style', [
'position:fixed', 'inset:0', 'z-index:2147483647',
'background:#000', 'color:#fff', 'padding:20px',
'font:13px/1.5 -apple-system,system-ui,monospace',
'overflow:auto', '-webkit-overflow-scrolling:touch'
].join(';'));
var head = document.createElement('div');
head.setAttribute('style', 'color:#ff0040;font-weight:700;font-size:16px;margin-bottom:8px');
head.textContent = 'SkateSpots failed to start';
var sub = document.createElement('div');
sub.setAttribute('style', 'color:#9ca3af;margin-bottom:12px');
sub.textContent = kind;
var pre = document.createElement('pre');
pre.setAttribute('style', 'white-space:pre-wrap;word-break:break-word;color:#f3f4f6;margin:0');
pre.textContent = msg || '(no message)';
var btn = document.createElement('button');
btn.setAttribute('style', 'margin-top:16px;padding:10px 16px;background:#ff0040;color:#000;border:0;border-radius:8px;font-weight:700');
btn.textContent = 'Reload';
btn.addEventListener('click', function () { location.reload(); });
el.appendChild(head);
el.appendChild(sub);
el.appendChild(pre);
el.appendChild(btn);
document.body.appendChild(el);
} catch (e) { /* last resort: nothing we can do */ }
}
window.addEventListener('error', function (e) {
paint('Uncaught error', e.error || e.message);
});
window.addEventListener('unhandledrejection', function (e) {
paint('Unhandled promise rejection', e.reason);
});
// If nothing has rendered into #root after 12s, surface a hint.
setTimeout(function () {
var root = document.getElementById('root');
if (root && root.childElementCount === 0) {
paint('App did not render (timeout)', 'The bundle loaded but nothing mounted within 12s. This usually means a boot-time exception was swallowed.');
}
}, 12000);
// Let the app signal a clean boot to cancel the timeout guard.
window.__SKATESPOTS_BOOTED__ = function () { shown = true; };
})();