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
+16
View File
@@ -37,6 +37,7 @@ and dragging is off. Picking a widget and editing its settings still works.
| **Media** | `image`, `audio`, `video` | a camera frame, a clip; see *Media tiles* below |
| **Player** | `record` | what a streamer is playing, with its transport; see *Player tiles* below |
| **Clock** | — | the time, in a size a wall can read |
| **Website** | — | another site's page in the tile; see *Website tiles* below |
Every widget carries a **title**, and **Show title** decides whether the panel
draws it. Turned off, the tile is just the reading, which is what a row of
@@ -145,6 +146,21 @@ The position counts forward in the browser between readings, so the bar moves
at one second while the device is polled at whatever rate suits it. Every
reading that arrives is taken as the truth and the count restarts from it.
## Website tiles
A website tile frames whatever page its **Address** points at: a weather map, a
camera's own web interface, a timetable. A site that refuses to be framed shows
its own refusal in the tile.
The page runs sandboxed. By default it gets an origin of its own that matches
no site, so it can run scripts but has no cookies or storage, and many
interactive pages will not load like that. **Trusted site** lets it keep its
real origin, with its cookies and storage, which is what visiting the site
would give it. Turn it on only for a site you trust. A trusted tile pointing at
this app's own address, or the hub's when the app is reached through one, shows
a notice instead: on that origin the page could reach into the app and read
your session.
## Dashboard settings
Most of what a dashboard carries is a widget: a tile bound to a message. A
@@ -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"