feat(iframe-embed-widget): sandboxed external-URL embed + archive#14
Merged
Merged
Conversation
…ail-closed allow-list + CSP frame-src Adds a launchpad_iframe dashboard widget that embeds an admin-allow-listed external URL in a sandboxed iframe. The allow-list (iframe_allowed_hosts app config) is enforced fail-closed both at save time and at CSP-assembly time, and is contributed host-by-host (never a wildcard) to LaunchPad's own frame-src CSP directive via a new AddContentSecurityPolicyEvent listener. The widget always carries a sandbox attribute (allow-top- navigation can never be granted) and degrades gracefully to a fallback card with an open-in-new-tab link when a target refuses framing via X-Frame-Options/frame-ancestors, which cannot be overridden client-side. Registered as type `iframe` in the frontend widgetRegistry.js catalog, matching this app's actual architecture (no lib/Widget/ PHP-provider layer exists for any widget type — clock/weather/livetile are the same shape). REQ-IFRAME-001's IManager v2 registration scenario is left unimplemented and documented as such in tasks.md; it does not match how any widget in this app registers today.
Replaces the hand-written canonical spec with the one openspec archive generates, so the spec is tool-owned rather than a manual mirror.
rubenvdlinde
requested review from
Rem-Dam,
SudoThijn,
WilcoLouwerse,
bbrands02,
remko48 and
rjzondervan
as code owners
July 23, 2026 23:16
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements and archives
iframe-embed-widget. Embedding an external URL as a widget has been requested on Nextcloud since 2019 (dashboard#53) and is served today only by third-party micro-apps (iFrame Widget, External Portal, DashLink).What
IframeWidget.vue/IframeWidgetForm.vue/iframeClient.js, registered as widget typeiframe.IframeService— fail-closed host allow-list (iframe_allowed_hosts; empty = deny everything) plus sandbox-token sanitisation.CspListener— contributes allow-listed hosts to app-scopedframe-srcviaAddContentSecurityPolicyEvent. No wildcard.POST /api/iframe/validate-urlfor save-time validation (registered before the catch-all route).The X-Frame-Options reality
A target sending
X-Frame-Options: DENYorframe-ancestors 'none'cannot be embedded, and no embedder can override that — so the widget detects the refusal instead of showing a blank frame. Two signals: noloadevent within a timeout, or aloadwhosecontentDocumentis same-origin-readable and empty. ASecurityErrorwhen readingcontentDocumentis treated as success — that's the normal signature of a legitimately-loaded cross-origin page. Both paths are unit-tested. Failure renders a fallback card (icon and text, never colour-only) with a keyboard-reachable "Open in new tab".Verification (local, no CI wait)
npx vitest run(widget, form, registry) → 39 tests pass.phpunit(IframeService, IframeController, CspListener) → OK, 27 tests, 36 assertions.Deviation, stated plainly
REQ-IFRAME-001 asks for
lib/Widget/IframeWidgetProvider.phpregistering viaOCP\\Dashboard\\IManager. This app has no PHP widget-provider layer at all —clock,weatherandlivetileall register fromsrc/constants/widgetRegistry.js. I followed the real pattern and left that scenario unchecked intasks.mdrather than reinterpret it silently. The spec text is the thing that's wrong here, not the code.Also: the build agent had hand-written
openspec/specs/iframe-embed-widget/spec.md; I removed it and letopenspec archivegenerate it, so the canonical spec stays tool-owned.Not done
Playwright e2e; an admin settings panel for
iframe_allowed_hosts(config isocc-only, matching thelivetile_allowed_hostsprecedent).