Webframe: not all URLs is able to load only a few sites does render. #74
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#74
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The Webframe element fails to load most URLs. Loading
https://github.comshows a blank frame with "github.com refused to connect." Only a few sites
(e.g.
example.com) render successfully.Steps to reproduce
https://github.comin the URL field and clickLoad 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.
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-OptionsorContent-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-ancestorsbefore 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
iframe.src.example.comstill loads inline).Files to Modify/Create
Cargo.toml(workspace) — addreqwestto 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 onreqwest.crates/hero_whiteboard_ui/src/routes.rs— new/api/url-check?url=<url>GET route. Validates the URL ishttp(s)://, performs a 5-second-timedGET(some sites 405 on HEAD), inspectsX-Frame-OptionsandContent-Security-Policyforframe-ancestors, returns JSON{ embeddable: bool, reason?: string }.crates/hero_whiteboard_ui/static/web/js/whiteboard/webframe.js— replace blindiframe.src = urlwith a_checkEmbeddable(url)pre-flight; render either an iframe or a non-embeddable card based on the result. Also call fromapplyNewUrlso 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.rsreqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }to workspace deps and reference it from the UI crate.GET /api/url-checktakingurlas a query parameter.http(s)schemes with{ embeddable: false, reason: "unsupported scheme" }.reqwest::Clientonce (lazy/static or per-request — a per-request client is fine for this low-traffic feature) with a 5 stimeoutandredirect::Policy::limited(5).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.X-Frame-Options(DENY / SAMEORIGIN ⇒ blocked) andContent-Security-Policy(look forframe-ancestorsdirective that doesn't permit our origin — for a generic check we treat anyframe-ancestorsclause that isn't*as blocking, since the served origin varies by deployment).{ embeddable, reason }. Drop the response body without reading it.create_router().Dependencies: none.
Step 2: Client-side card + pre-flight wiring
Files:
crates/hero_whiteboard_ui/static/web/js/whiteboard/webframe.js_checkEmbeddable(url)that fetches${base_path}/api/url-check?url=..., returns{ embeddable, reason }. On any error, default to{ embeddable: true }.createIframeOverlay: still createswrapper, but now creates bothiframe(initially withsrc='') and a hiddencarddiv. After construction, kicks off the embeddable check; on result, either setsiframe.srcor shows the card.applyNewUrlto re-run the check and toggle iframe ↔ card on update.Dependencies: Step 1.
Acceptance Criteria
https://github.comshows a non-embeddable card with "Open in new tab", not the broken iframe.https://example.comstill loads inline as before.cargo fmt --all -- --check,cargo clippy --workspace -- -D warnings,cargo test -p hero_whiteboard_serverall clean.Notes
GETnotHEAD: many sites (Cloudflare-fronted, GitHub) return 403/405 on HEAD or strip framing headers from HEAD responses. We do aGETbut discard the body.frame-ancestorsdirective 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.applyNewUrlinvocation (typically once at create + once per URL edit) so volume is negligible.Test Results
cargo fmt --all -- --check— cleancargo clippy --workspace --all-targets -- -D warnings— cleancargo check -p hero_whiteboard_ui— cleancargo test --workspace --lib— 0 passed, 0 failed (no unit tests in any crate; no regressions)node --check webframe.js— cleanIntegration tests in
hero_whiteboard_examplesrequire a runninghero_procand were not exercised in this run.Implementation Summary
Two-layer fix for un-embeddable URLs (X-Frame-Options / CSP frame-ancestors).
Server-side pre-flight (
hero_whiteboard_ui)reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }to workspace + UI deps (no OpenSSL system dep).GET /api/url-check?url=<url>route incrates/hero_whiteboard_ui/src/routes.rs.http(s)://scheme, otherwise{embeddable:false, reason:"unsupported scheme"}.GET(notHEAD— too many sites strip framing headers from HEAD responses) with a 5 s timeout and up to 5 redirects.{embeddable:true, reason:"preflight failed"}so a flaky pre-flight never blocks a working site.X-Frame-Options:DENY/SAMEORIGIN⇒ blocked.Content-Security-Policyheader, splits on;, readsframe-ancestors: any directive that isn't*⇒ blocked. Heuristic; false positives just route the user to "Open in new tab".Client-side card (
webframe.js)createIframeOverlaynow creates the iframe with nosrcand a hidden non-embeddable card._applyUrl(id, url)runs the pre-flight and toggles which is visible._urlTokenper overlay drops stale results when the user changes URLs faster than the network responds.applyNewUrlandupdateUrlcollapsed to call_applyUrl, so URL edits and remote-sync URL changes both go through the same pre-flight.Files Changed
Cargo.toml— workspace depcrates/hero_whiteboard_ui/Cargo.toml— crate depcrates/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 flowTest Results
cargo fmt --all -- --check— cleancargo clippy --workspace --all-targets -- -D warnings— cleancargo check -p hero_whiteboard_ui— cleancargo test --workspace --lib— 0 failuresnode --check webframe.js— cleanManual smoke checks (to verify after deploy)
https://github.com— should show the "doesn’t allow embedding" card with Open-in-new-tab.https://example.com— should still load inline.example.comtogithub.com— should switch from iframe to card.example.com— should switch back to iframe.Notes
frame-ancestorsheuristic 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.applyNewUrlinvocation. Volume is negligible.