From 5176efbbe44f4cdef3dfd35a5e70bae16150a748 Mon Sep 17 00:00:00 2001 From: drewstone Date: Thu, 7 May 2026 00:17:09 +0200 Subject: [PATCH] feat(tangle-cloud): sandboxed iframe blueprint apps (#3175) --- apps/tangle-cloud/netlify.toml | 25 ++ .../components/BlueprintAppFrame.tsx | 72 +++++ .../components/BlueprintAppFrameHost.tsx | 40 +++ .../components/BlueprintAppLandingPage.tsx | 21 +- .../components/IframeAppApprovalModal.tsx | 212 ++++++++++++ .../src/blueprintApps/iframe/manifest.spec.ts | 78 +++++ .../src/blueprintApps/iframe/manifest.ts | 135 ++++++++ .../src/blueprintApps/iframe/origin.spec.ts | 80 +++++ .../src/blueprintApps/iframe/origin.ts | 39 +++ .../src/blueprintApps/iframe/policy.spec.ts | 124 +++++++ .../src/blueprintApps/iframe/policy.ts | 142 ++++++++ .../src/blueprintApps/iframe/protocol.spec.ts | 126 ++++++++ .../src/blueprintApps/iframe/protocol.ts | 214 ++++++++++++ .../src/blueprintApps/iframe/types.ts | 35 ++ .../blueprintApps/iframe/useIframeBridge.ts | 304 ++++++++++++++++++ .../src/blueprintApps/manifest.ts | 138 +++++--- apps/tangle-cloud/src/blueprintApps/policy.ts | 51 +++ 17 files changed, 1796 insertions(+), 40 deletions(-) create mode 100644 apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrame.tsx create mode 100644 apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrameHost.tsx create mode 100644 apps/tangle-cloud/src/blueprintApps/components/IframeAppApprovalModal.tsx create mode 100644 apps/tangle-cloud/src/blueprintApps/iframe/manifest.spec.ts create mode 100644 apps/tangle-cloud/src/blueprintApps/iframe/manifest.ts create mode 100644 apps/tangle-cloud/src/blueprintApps/iframe/origin.spec.ts create mode 100644 apps/tangle-cloud/src/blueprintApps/iframe/origin.ts create mode 100644 apps/tangle-cloud/src/blueprintApps/iframe/policy.spec.ts create mode 100644 apps/tangle-cloud/src/blueprintApps/iframe/policy.ts create mode 100644 apps/tangle-cloud/src/blueprintApps/iframe/protocol.spec.ts create mode 100644 apps/tangle-cloud/src/blueprintApps/iframe/protocol.ts create mode 100644 apps/tangle-cloud/src/blueprintApps/iframe/types.ts create mode 100644 apps/tangle-cloud/src/blueprintApps/iframe/useIframeBridge.ts diff --git a/apps/tangle-cloud/netlify.toml b/apps/tangle-cloud/netlify.toml index c5e872ce7e..ed9c687ef4 100644 --- a/apps/tangle-cloud/netlify.toml +++ b/apps/tangle-cloud/netlify.toml @@ -7,3 +7,28 @@ # If the CHANGELOG.md file has changed, continue the build process, # Otherwise, stop the build process ignore = "[ \"$BRANCH\" != \"master\" ] && exit 1 || git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- apps/tangle-cloud/CHANGELOG.md && exit 0 || exit 1" + +# ─── Security headers ─────────────────────────────────────────────────── +# +# Scope is intentionally narrow: we set ONLY the headers needed to harden +# the iframe blueprint-apps surface. We do NOT set default-src / script-src +# / connect-src here because the dapp's wallet, RPC, and IPFS connections +# require a permissive baseline that would be brittle to enumerate. +# +# - frame-src: allowlists which origins are permitted to be loaded as +# iframe sources by the dapp. Belt-and-braces with the in-app gating in +# policy.ts. Update both in tandem when adding a new iframe-eligible host. +# - frame-ancestors 'self': prevents the dapp itself from being framed by +# an attacker page (clickjacking). +# - Permissions-Policy: denies sensitive Web APIs by default for the dapp +# and any nested iframes. Each iframe element ALSO sets allow="" for +# defence-in-depth. +# - X-Frame-Options: redundant with frame-ancestors on modern browsers but +# still required by some legacy mobile webviews. +[[headers]] + for = "/*" + [headers.values] + Content-Security-Policy = "frame-src 'self' https://cloud.tangle.tools https://app.tangle.tools https://apps.tangle.tools https://*.blueprint.tangle.tools https://*.blueprint.tangle.sh; frame-ancestors 'self'" + Permissions-Policy = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=(), accelerometer=(), gyroscope=(), magnetometer=(), midi=(), serial=()" + X-Frame-Options = "SAMEORIGIN" + Referrer-Policy = "strict-origin-when-cross-origin" diff --git a/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrame.tsx b/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrame.tsx new file mode 100644 index 0000000000..f83ab2d123 --- /dev/null +++ b/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrame.tsx @@ -0,0 +1,72 @@ +import { + forwardRef, + type CSSProperties, + type ForwardedRef, + type IframeHTMLAttributes, +} from 'react'; +import type { BlueprintIframeConfig } from '../iframe/types'; + +type Props = { + config: BlueprintIframeConfig; + title: string; + className?: string; + style?: CSSProperties; + // Tests / non-DOM environments may want to render with a stub. + iframeProps?: Pick, 'name'>; +}; + +// Hardened iframe sandbox. We deliberately omit: +// - allow-same-origin: forces opaque origin so the iframe can't reach +// parent.localStorage / parent.cookies / window.parent.ethereum. +// - allow-top-navigation: blocks the iframe from navigating the parent +// window away to a phishing page. +// - allow-modals / allow-pointer-lock: not needed; reduces UX-hijack surface. +// +// We allow: +// - allow-scripts: required for the embedded app to function at all. +// - allow-forms: required for normal form interactions inside the iframe. +// - allow-popups[-to-escape-sandbox]: gated on the manifest's allowPopups +// flag because they widen attack surface (oauth flows commonly need them). +const buildSandbox = (config: BlueprintIframeConfig): string => { + const tokens = ['allow-scripts', 'allow-forms']; + if (config.allowPopups) { + tokens.push('allow-popups', 'allow-popups-to-escape-sandbox'); + } + return tokens.join(' '); +}; + +// `allow=""` — an empty Permissions-Policy attribute on the iframe blocks +// every powerful API regardless of what the parent's response header says. +// Belt-and-braces: the parent ALSO sets a deny-everything Permissions-Policy +// header. If either layer is misconfigured the other still blocks. +const PERMISSIONS_POLICY_DENY_ALL = ''; + +const BlueprintAppFrameInner = ( + { config, title, className, style, iframeProps }: Props, + ref: ForwardedRef, +) => ( +