Webframe: not all URLs is able to load only a few sites does render. #74

Open
opened 2026-04-23 13:51:10 +00:00 by eslamnawara · 3 comments
Member

Summary

The Webframe element fails to load most URLs. Loading https://github.com
shows a blank frame with "github.com refused to connect." Only a few sites
(e.g. example.com) render successfully.

Steps to reproduce

  1. Add a Webframe element to the whiteboard.
  2. Enter https://github.com in the URL field and click Load URL.

Expected

The page renders inside the frame. If the site cannot be embedded, the
Webframe should show a clear message explaining why, rather than a broken-
looking frame.

Actual

A generic "refused to connect" page is shown inside the frame.

image

## Summary The Webframe element fails to load most URLs. Loading `https://github.com` shows a blank frame with "github.com refused to connect." Only a few sites (e.g. `example.com`) render successfully. ## Steps to reproduce 1. Add a Webframe element to the whiteboard. 2. Enter `https://github.com` in the URL field and click `Load URL`. ## Expected The page renders inside the frame. If the site cannot be embedded, the Webframe should show a clear message explaining why, rather than a broken- looking frame. ## Actual A generic "refused to connect" page is shown inside the frame. ![image](/attachments/0603efae-4e24-4d69-82e0-ddd196414a18)
Member

Implementation Spec for Issue #74

Objective

Stop showing the confusing "site refused to connect" iframe when the user enters a URL the browser blocks via X-Frame-Options or Content-Security-Policy: frame-ancestors. Replace the broken-looking frame with a clear card that explains why and provides a prominent "Open in new tab" action.

Why this can't be a simple client-side fix

Browsers enforce X-Frame-Options / frame-ancestors before the iframe load completes, and JS cannot read those headers cross-origin. So a usable "is this URL embeddable?" check has to run server-side, where we can fetch the URL ourselves and inspect the response headers.

Requirements

  • Detect non-embeddable URLs before assigning iframe.src.
  • Render a friendly inline card with the URL, a short reason, and an "Open in new tab" button when blocked.
  • Time out after a few seconds and fall back to "try the iframe anyway" so a slow site never freezes the UI.
  • No regression for embeddable sites (e.g. example.com still loads inline).

Files to Modify/Create

  • Cargo.toml (workspace) — add reqwest to workspace dependencies (rustls-tls, no default features) so the UI crate can fetch arbitrary HTTPS URLs without pulling OpenSSL.
  • crates/hero_whiteboard_ui/Cargo.toml — depend on reqwest.
  • crates/hero_whiteboard_ui/src/routes.rs — new /api/url-check?url=<url> GET route. Validates the URL is http(s)://, performs a 5-second-timed GET (some sites 405 on HEAD), inspects X-Frame-Options and Content-Security-Policy for frame-ancestors, returns JSON { embeddable: bool, reason?: string }.
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/webframe.js — replace blind iframe.src = url with a _checkEmbeddable(url) pre-flight; render either an iframe or a non-embeddable card based on the result. Also call from applyNewUrl so re-edits behave the same way.

Implementation Plan

Step 1: Server-side pre-flight endpoint

Files: Cargo.toml, crates/hero_whiteboard_ui/Cargo.toml, crates/hero_whiteboard_ui/src/routes.rs

  • Add reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } to workspace deps and reference it from the UI crate.
  • Add an Axum route GET /api/url-check taking url as a query parameter.
  • Reject non-http(s) schemes with { embeddable: false, reason: "unsupported scheme" }.
  • Build a reqwest::Client once (lazy/static or per-request — a per-request client is fine for this low-traffic feature) with a 5 s timeout and redirect::Policy::limited(5).
  • Issue GET. If it errors (DNS, TLS, timeout), return { embeddable: true, reason: "preflight failed" } — let the iframe try anyway, since failure to pre-flight should not block legit sites.
  • On success, read X-Frame-Options (DENY / SAMEORIGIN ⇒ blocked) and Content-Security-Policy (look for frame-ancestors directive that doesn't permit our origin — for a generic check we treat any frame-ancestors clause that isn't * as blocking, since the served origin varies by deployment).
  • Return JSON { embeddable, reason }. Drop the response body without reading it.
  • Add the route to the existing router in create_router().

Dependencies: none.

Step 2: Client-side card + pre-flight wiring

Files: crates/hero_whiteboard_ui/static/web/js/whiteboard/webframe.js

  • Add _checkEmbeddable(url) that fetches ${base_path}/api/url-check?url=..., returns { embeddable, reason }. On any error, default to { embeddable: true }.
  • Refactor createIframeOverlay: still creates wrapper, but now creates both iframe (initially with src='') and a hidden card div. After construction, kicks off the embeddable check; on result, either sets iframe.src or shows the card.
  • The card layout: globe/warning icon, the URL (truncated), a short message ("This site doesn't allow embedding."), and a clearly-styled "Open in new tab" anchor button. Always visible (not hover-gated) when in card mode.
  • Refactor applyNewUrl to re-run the check and toggle iframe ↔ card on update.
  • Keep the existing hover-only "Open in new tab" link in iframe mode for parity, since it's still useful when an iframe loads partially.

Dependencies: Step 1.

Acceptance Criteria

  • Entering https://github.com shows a non-embeddable card with "Open in new tab", not the broken iframe.
  • Entering https://example.com still loads inline as before.
  • Updating an existing webframe to a non-embeddable URL switches it to the card.
  • Updating from a non-embeddable URL back to an embeddable one shows the iframe again.
  • A slow / unreachable URL falls back to "try the iframe" within 5 seconds (no UI freeze).
  • cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings, cargo test -p hero_whiteboard_server all clean.

Notes

  • Why GET not HEAD: many sites (Cloudflare-fronted, GitHub) return 403/405 on HEAD or strip framing headers from HEAD responses. We do a GET but discard the body.
  • Privacy: the pre-flight is made from the server, not the user's browser, so the user's IP is not leaked to the target during the check itself.
  • CSP frame-ancestors heuristic: without knowing our serving origin at runtime we can't be perfectly precise, so the rule is: any frame-ancestors directive not containing * is treated as blocking. False positives here are fine — the user gets a clear message and the working "Open in new tab" button.
  • Caching: none in this iteration. The check runs once per applyNewUrl invocation (typically once at create + once per URL edit) so volume is negligible.
  • Reqwest with rustls-tls keeps the dep tree minimal (no system OpenSSL).
## Implementation Spec for Issue #74 ### Objective Stop showing the confusing "site refused to connect" iframe when the user enters a URL the browser blocks via `X-Frame-Options` or `Content-Security-Policy: frame-ancestors`. Replace the broken-looking frame with a clear card that explains why and provides a prominent "Open in new tab" action. ### Why this can't be a simple client-side fix Browsers enforce `X-Frame-Options` / `frame-ancestors` *before* the iframe load completes, and JS cannot read those headers cross-origin. So a usable "is this URL embeddable?" check has to run server-side, where we can fetch the URL ourselves and inspect the response headers. ### Requirements - Detect non-embeddable URLs before assigning `iframe.src`. - Render a friendly inline card with the URL, a short reason, and an "Open in new tab" button when blocked. - Time out after a few seconds and fall back to "try the iframe anyway" so a slow site never freezes the UI. - No regression for embeddable sites (e.g. `example.com` still loads inline). ### Files to Modify/Create - `Cargo.toml` (workspace) — add `reqwest` to workspace dependencies (rustls-tls, no default features) so the UI crate can fetch arbitrary HTTPS URLs without pulling OpenSSL. - `crates/hero_whiteboard_ui/Cargo.toml` — depend on `reqwest`. - `crates/hero_whiteboard_ui/src/routes.rs` — new `/api/url-check?url=<url>` GET route. Validates the URL is `http(s)://`, performs a 5-second-timed `GET` (some sites 405 on HEAD), inspects `X-Frame-Options` and `Content-Security-Policy` for `frame-ancestors`, returns JSON `{ embeddable: bool, reason?: string }`. - `crates/hero_whiteboard_ui/static/web/js/whiteboard/webframe.js` — replace blind `iframe.src = url` with a `_checkEmbeddable(url)` pre-flight; render either an iframe or a non-embeddable card based on the result. Also call from `applyNewUrl` so re-edits behave the same way. ### Implementation Plan #### Step 1: Server-side pre-flight endpoint Files: `Cargo.toml`, `crates/hero_whiteboard_ui/Cargo.toml`, `crates/hero_whiteboard_ui/src/routes.rs` - Add `reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }` to workspace deps and reference it from the UI crate. - Add an Axum route `GET /api/url-check` taking `url` as a query parameter. - Reject non-`http(s)` schemes with `{ embeddable: false, reason: "unsupported scheme" }`. - Build a `reqwest::Client` once (lazy/static or per-request — a per-request client is fine for this low-traffic feature) with a 5 s `timeout` and `redirect::Policy::limited(5)`. - Issue `GET`. If it errors (DNS, TLS, timeout), return `{ embeddable: true, reason: "preflight failed" }` — let the iframe try anyway, since failure to pre-flight should not block legit sites. - On success, read `X-Frame-Options` (DENY / SAMEORIGIN ⇒ blocked) and `Content-Security-Policy` (look for `frame-ancestors` directive that doesn't permit our origin — for a generic check we treat any `frame-ancestors` clause that isn't `*` as blocking, since the served origin varies by deployment). - Return JSON `{ embeddable, reason }`. Drop the response body without reading it. - Add the route to the existing router in `create_router()`. Dependencies: none. #### Step 2: Client-side card + pre-flight wiring Files: `crates/hero_whiteboard_ui/static/web/js/whiteboard/webframe.js` - Add `_checkEmbeddable(url)` that fetches `${base_path}/api/url-check?url=...`, returns `{ embeddable, reason }`. On any error, default to `{ embeddable: true }`. - Refactor `createIframeOverlay`: still creates `wrapper`, but now creates *both* `iframe` (initially with `src=''`) and a hidden `card` div. After construction, kicks off the embeddable check; on result, either sets `iframe.src` or shows the card. - The card layout: globe/warning icon, the URL (truncated), a short message ("This site doesn't allow embedding."), and a clearly-styled "Open in new tab" anchor button. Always visible (not hover-gated) when in card mode. - Refactor `applyNewUrl` to re-run the check and toggle iframe ↔ card on update. - Keep the existing hover-only "Open in new tab" link in iframe mode for parity, since it's still useful when an iframe loads partially. Dependencies: Step 1. ### Acceptance Criteria - [ ] Entering `https://github.com` shows a non-embeddable card with "Open in new tab", not the broken iframe. - [ ] Entering `https://example.com` still loads inline as before. - [ ] Updating an existing webframe to a non-embeddable URL switches it to the card. - [ ] Updating from a non-embeddable URL back to an embeddable one shows the iframe again. - [ ] A slow / unreachable URL falls back to "try the iframe" within 5 seconds (no UI freeze). - [ ] `cargo fmt --all -- --check`, `cargo clippy --workspace -- -D warnings`, `cargo test -p hero_whiteboard_server` all clean. ### Notes - **Why `GET` not `HEAD`:** many sites (Cloudflare-fronted, GitHub) return 403/405 on HEAD or strip framing headers from HEAD responses. We do a `GET` but discard the body. - **Privacy:** the pre-flight is made from the server, not the user's browser, so the user's IP is not leaked to the target during the check itself. - **CSP frame-ancestors heuristic:** without knowing our serving origin at runtime we can't be perfectly precise, so the rule is: any `frame-ancestors` directive not containing `*` is treated as blocking. False positives here are fine — the user gets a clear message and the working "Open in new tab" button. - **Caching:** none in this iteration. The check runs once per `applyNewUrl` invocation (typically once at create + once per URL edit) so volume is negligible. - **Reqwest with rustls-tls** keeps the dep tree minimal (no system OpenSSL).
Member

Test Results

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo check -p hero_whiteboard_ui — clean
  • cargo test --workspace --lib — 0 passed, 0 failed (no unit tests in any crate; no regressions)
  • node --check webframe.js — clean

Integration tests in hero_whiteboard_examples require a running hero_proc and were not exercised in this run.

## Test Results - `cargo fmt --all -- --check` — clean - `cargo clippy --workspace --all-targets -- -D warnings` — clean - `cargo check -p hero_whiteboard_ui` — clean - `cargo test --workspace --lib` — 0 passed, 0 failed (no unit tests in any crate; no regressions) - `node --check webframe.js` — clean Integration tests in `hero_whiteboard_examples` require a running `hero_proc` and were not exercised in this run.
Member

Implementation Summary

Two-layer fix for un-embeddable URLs (X-Frame-Options / CSP frame-ancestors).

Server-side pre-flight (hero_whiteboard_ui)

  • Added reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } to workspace + UI deps (no OpenSSL system dep).
  • New GET /api/url-check?url=<url> route in crates/hero_whiteboard_ui/src/routes.rs.
    • Validates http(s):// scheme, otherwise {embeddable:false, reason:"unsupported scheme"}.
    • Issues a GET (not HEAD — too many sites strip framing headers from HEAD responses) with a 5 s timeout and up to 5 redirects.
    • On any network error returns {embeddable:true, reason:"preflight failed"} so a flaky pre-flight never blocks a working site.
    • Reads X-Frame-Options: DENY / SAMEORIGIN ⇒ blocked.
    • Walks each Content-Security-Policy header, splits on ;, reads frame-ancestors: any directive that isn't * ⇒ blocked. Heuristic; false positives just route the user to "Open in new tab".
    • Drops the response body without reading it.

Client-side card (webframe.js)

  • createIframeOverlay now creates the iframe with no src and a hidden non-embeddable card. _applyUrl(id, url) runs the pre-flight and toggles which is visible.
  • A monotonic _urlToken per overlay drops stale results when the user changes URLs faster than the network responds.
  • Card layout: globe icon + "This site doesn’t allow embedding" + the URL + a prominent Open in new tab button + the raw header reason in monospace.
  • Existing hover-only "Open in new tab" overlay kept for the iframe path; suppressed when the card is showing.
  • applyNewUrl and updateUrl collapsed to call _applyUrl, so URL edits and remote-sync URL changes both go through the same pre-flight.

Files Changed

  • Cargo.toml — workspace dep
  • crates/hero_whiteboard_ui/Cargo.toml — crate dep
  • crates/hero_whiteboard_ui/src/routes.rs+72/-1 (new route + handler + query struct)
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/webframe.js — refactored URL flow

Test Results

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo check -p hero_whiteboard_ui — clean
  • cargo test --workspace --lib — 0 failures
  • node --check webframe.js — clean

Manual smoke checks (to verify after deploy)

  1. Add a webframe with https://github.com — should show the "doesn’t allow embedding" card with Open-in-new-tab.
  2. Add a webframe with https://example.com — should still load inline.
  3. Edit an existing webframe from example.com to github.com — should switch from iframe to card.
  4. Edit it back to example.com — should switch back to iframe.
  5. Slow / unreachable URL — falls back to the iframe path within ~5 s, no UI freeze.

Notes

  • CSP frame-ancestors heuristic is intentionally conservative: any directive other than * is treated as blocking, since we can't know the deployed origin at request time. Users still get the working "Open in new tab" path.
  • Privacy: the pre-flight is made from the server, not the user's browser, so the user's IP isn't disclosed to the target during the check itself.
  • No caching — runs once per applyNewUrl invocation. Volume is negligible.
## Implementation Summary Two-layer fix for un-embeddable URLs (X-Frame-Options / CSP frame-ancestors). ### Server-side pre-flight (`hero_whiteboard_ui`) - Added `reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }` to workspace + UI deps (no OpenSSL system dep). - New `GET /api/url-check?url=<url>` route in `crates/hero_whiteboard_ui/src/routes.rs`. - Validates `http(s)://` scheme, otherwise `{embeddable:false, reason:"unsupported scheme"}`. - Issues a `GET` (not `HEAD` — too many sites strip framing headers from HEAD responses) with a 5 s timeout and up to 5 redirects. - On any network error returns `{embeddable:true, reason:"preflight failed"}` so a flaky pre-flight never blocks a working site. - Reads `X-Frame-Options`: `DENY` / `SAMEORIGIN` ⇒ blocked. - Walks each `Content-Security-Policy` header, splits on `;`, reads `frame-ancestors`: any directive that isn't `*` ⇒ blocked. Heuristic; false positives just route the user to "Open in new tab". - Drops the response body without reading it. ### Client-side card (`webframe.js`) - `createIframeOverlay` now creates the iframe with no `src` *and* a hidden non-embeddable card. `_applyUrl(id, url)` runs the pre-flight and toggles which is visible. - A monotonic `_urlToken` per overlay drops stale results when the user changes URLs faster than the network responds. - Card layout: globe icon + "This site doesn’t allow embedding" + the URL + a prominent **Open in new tab** button + the raw header reason in monospace. - Existing hover-only "Open in new tab" overlay kept for the iframe path; suppressed when the card is showing. - `applyNewUrl` and `updateUrl` collapsed to call `_applyUrl`, so URL edits and remote-sync URL changes both go through the same pre-flight. ### Files Changed - `Cargo.toml` — workspace dep - `crates/hero_whiteboard_ui/Cargo.toml` — crate dep - `crates/hero_whiteboard_ui/src/routes.rs` — `+72/-1` (new route + handler + query struct) - `crates/hero_whiteboard_ui/static/web/js/whiteboard/webframe.js` — refactored URL flow ### Test Results - `cargo fmt --all -- --check` — clean - `cargo clippy --workspace --all-targets -- -D warnings` — clean - `cargo check -p hero_whiteboard_ui` — clean - `cargo test --workspace --lib` — 0 failures - `node --check webframe.js` — clean ### Manual smoke checks (to verify after deploy) 1. Add a webframe with `https://github.com` — should show the "doesn’t allow embedding" card with Open-in-new-tab. 2. Add a webframe with `https://example.com` — should still load inline. 3. Edit an existing webframe from `example.com` to `github.com` — should switch from iframe to card. 4. Edit it back to `example.com` — should switch back to iframe. 5. Slow / unreachable URL — falls back to the iframe path within ~5 s, no UI freeze. ### Notes - **CSP `frame-ancestors` heuristic** is intentionally conservative: any directive other than `*` is treated as blocking, since we can't know the deployed origin at request time. Users still get the working "Open in new tab" path. - **Privacy:** the pre-flight is made from the server, not the user's browser, so the user's IP isn't disclosed to the target during the check itself. - **No caching** — runs once per `applyNewUrl` invocation. Volume is negligible.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_whiteboard#74
No description provided.