diff --git a/apps/tangle-cloud/netlify.toml b/apps/tangle-cloud/netlify.toml index c5e872ce7..ed9c687ef 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 000000000..f83ab2d12 --- /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, +) => ( +