framing.ts 2.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
  1. /**
  2. * Reading a response's framing headers, for the embedded G-code viewer (#2787).
  3. *
  4. * The viewer is the only part of Bambuddy that embeds a Bambuddy page in a
  5. * frame, so it is the only part a proxy-added framing header can break — and it
  6. * breaks with the browser's own error page, which says nothing about what was
  7. * refused or by whom.
  8. */
  9. /** Why the viewer could not be shown inline, with the evidence that says so. */
  10. export type FrameProblem =
  11. | { kind: 'blocked'; detail: string }
  12. | { kind: 'unavailable'; detail: string };
  13. /**
  14. * Decide whether a response's framing headers allow `origin` to embed it.
  15. *
  16. * Returns the offending header verbatim when embedding is refused, or null when
  17. * it is allowed. Bambuddy's own headers always allow it (`frame-ancestors
  18. * 'self'` plus `X-Frame-Options: SAMEORIGIN`, set in `main.py`), so a refusal
  19. * means something between the browser and Bambuddy — a reverse proxy, a
  20. * security add-on, an auth gateway — added a stricter one.
  21. *
  22. * `frame-ancestors` wins outright when present: per CSP the browser must ignore
  23. * `X-Frame-Options` entirely in that case, so reading both would blame a
  24. * proxy-added `X-Frame-Options: DENY` the browser never consulted. Multiple CSP
  25. * headers are *intersected*, and `fetch` joins them into one comma-separated
  26. * string, so every `frame-ancestors` occurrence has to permit us — not just the
  27. * first one.
  28. */
  29. export function findFramingRefusal(
  30. xFrameOptions: string | null,
  31. contentSecurityPolicy: string | null,
  32. origin: string,
  33. ): string | null {
  34. const csp = contentSecurityPolicy ?? '';
  35. const directives = [...csp.matchAll(/(?:^|[;,])\s*frame-ancestors\s+([^;,]*)/gi)];
  36. if (directives.length > 0) {
  37. const self = origin.toLowerCase();
  38. for (const [, raw] of directives) {
  39. const value = raw.trim();
  40. const sources = value.toLowerCase().split(/\s+/).filter(Boolean);
  41. const permitsUs = sources.some(
  42. (source) =>
  43. source === '*' ||
  44. source === "'self'" ||
  45. source === self ||
  46. source === self.replace(/^https?:\/\//, ''),
  47. );
  48. if (!permitsUs) return `Content-Security-Policy: frame-ancestors ${value}`;
  49. }
  50. return null;
  51. }
  52. // No frame-ancestors anywhere: the legacy header governs. Anything other than
  53. // a single SAMEORIGIN refuses us — DENY, ALLOW-FROM, or the conflicting
  54. // "SAMEORIGIN, DENY" that appears when a proxy appends a second copy.
  55. const legacy = (xFrameOptions ?? '')
  56. .split(',')
  57. .map((value) => value.trim().toLowerCase())
  58. .filter(Boolean);
  59. if (legacy.length === 0) return null;
  60. if (legacy.length === 1 && legacy[0] === 'sameorigin') return null;
  61. return `X-Frame-Options: ${xFrameOptions}`;
  62. }