Sandbox website tiles by default; opt into same-origin per widget

A website tile now runs in an opaque origin (allow-scripts, popups, forms).
Its new "Trusted site" setting adds allow-same-origin for sites that need
their own cookies and storage, and a trusted tile pointing at the origin the
app runs on (its own, or the hub's) shows a notice instead of the frame.
This commit is contained in:
2026-10-07 09:31:49 +02:00
parent ed8aee2387
commit 8f3e229c8f
4 changed files with 89 additions and 24 deletions
@@ -6,13 +6,14 @@
* cd frontend && bun run src/components/Dashboard/embed.check.ts
*
* The guard is the point. An `<iframe src>` of `javascript:` or `data:` runs
* in this app's own origin, so anything but `http(s)` has to come out empty.
* in this app's own origin, so anything but `http(s)` has to come out empty,
* and a trusted embed may not point at that origin either.
*/
import assert from "node:assert/strict"
import type { WidgetDef } from "@/client"
import { embedUrl } from "./widgets"
import { embedUrl, onOrigin } from "./widgets"
const widget = (url: unknown): WidgetDef =>
({ id: "e", type: "embed", config: { url } }) as WidgetDef
@@ -36,4 +37,12 @@ for (const url of [
assert.equal(embedUrl(widget(url)), "", String(url))
}
// A trusted embed keeps its own origin, so this app's own is the one refused.
const APP = "https://fluksio.com"
assert.equal(onOrigin("https://fluksio.com/i/abc/panel/main", APP), true)
assert.equal(onOrigin("https://FLUKSIO.com:443/x", APP), true)
assert.equal(onOrigin("http://fluksio.com/x", APP), false)
assert.equal(onOrigin("https://embed.ventusky.com/", APP), false)
assert.equal(onOrigin("http://", APP), true, "unparsable is refused")
console.log("embed: ok")
+28 -15
View File
@@ -555,21 +555,34 @@ export function WidgetPanel({
</div>
{widget.type === "embed" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal" htmlFor="embed-url">
Address
</Label>
<InfoTip label="Address">
Any page that allows being framed. A site that refuses shows its
own refusal in the tile.
</InfoTip>
<Input
id="embed-url"
value={str(cfg.url)}
placeholder="https://embed.ventusky.com/?p=49.41;8.93;9"
onChange={(event) => set({ url: event.target.value })}
/>
</div>
<>
<div className="grid gap-1.5">
<Label className="text-sm font-normal" htmlFor="embed-url">
Address
</Label>
<InfoTip label="Address">
Any page that allows being framed. A site that refuses shows
its own refusal in the tile.
</InfoTip>
<Input
id="embed-url"
value={str(cfg.url)}
placeholder="https://embed.ventusky.com/?p=49.41;8.93;9"
onChange={(event) => set({ url: event.target.value })}
/>
</div>
<div className="flex items-center justify-between gap-2 text-sm">
<FieldLabel help="Lets the page keep its own cookies and storage, which many interactive sites need to load. Only for a site you trust; this app's own address is refused.">
Trusted site
</FieldLabel>
<Switch
checked={cfg.trusted === true}
aria-label="Trusted site"
data-testid="embed-trusted"
onCheckedChange={(trusted) => set({ trusted })}
/>
</div>
</>
) : widget.type === "markdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Text</Label>
+34 -7
View File
@@ -459,6 +459,23 @@ export function embedUrl(widget: WidgetDef): string {
return /^https?:\/\//i.test(url) ? url : ""
}
/**
* Whether *url* is on *origin*: the one a trusted embed must not load.
*
* `allow-same-origin` lets a page keep its own origin, which for another site
* is only what visiting it would do. For a page on the origin this app runs on
* — its own, or the hub's when it is served through one — it means scripts
* that can reach into the app and read its session. An address that does not
* parse counts as one.
*/
export function onOrigin(url: string, origin: string): boolean {
try {
return new URL(url).origin === origin
} catch {
return true
}
}
/**
* Somebody else's page, drawn in the tile.
*
@@ -466,21 +483,31 @@ export function embedUrl(widget: WidgetDef): string {
* — with no binding of its own, so nothing has to be modelled as a message
* first. A site that refuses to be framed (`X-Frame-Options`) shows its own
* refusal inside the tile; that is between the browser and the site.
*
* By default the page runs in an opaque origin, with no cookies or storage of
* its own. **Trusted** keeps it in its own origin, which most interactive
* sites need to start (the demo's weather map renders an unstyled list
* without it), and is refused for this app's own origin.
*/
function EmbedWidget({ widget }: WidgetProps) {
const url = embedUrl(widget)
const trusted = config(widget).trusted === true
if (!url) return <p className="text-muted-foreground">Give it an address.</p>
if (trusted && onOrigin(url, window.location.origin)) {
return (
<p className="text-muted-foreground" data-testid="embed-refused">
This app's own address cannot be a trusted site.
</p>
)
}
return (
<iframe
// A changed sandbox applies from the next navigation only, so a toggle
// reloads the frame rather than leaving the old one running.
key={trusted ? "trusted" : "sandboxed"}
src={url}
title={widget.title || url}
// `allow-same-origin` keeps the page in its own origin, with the
// cookies and storage most sites will not start without: the demo's
// weather map renders an unstyled list without it. That is safe only
// while the page *is* another origin. One served from this app's own
// origin would run as the app, scripts and all, and could read the
// session.
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
sandbox={`allow-scripts allow-popups allow-forms${trusted ? " allow-same-origin" : ""}`}
referrerPolicy="no-referrer"
loading="lazy"
className="h-full w-full rounded-md border-0"