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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user