Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/tangle-cloud/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
@@ -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<IframeHTMLAttributes<HTMLIFrameElement>, '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<HTMLIFrameElement>,
) => (
<iframe
ref={ref}
title={title}
src={config.url}
sandbox={buildSandbox(config)}
allow={PERMISSIONS_POLICY_DENY_ALL}
referrerPolicy="no-referrer"
loading="lazy"
className={className}
style={{
// Default to a sensible aspect; consumer can override.
width: '100%',
minHeight: '720px',
border: '0',
borderRadius: '16px',
...style,
}}
{...iframeProps}
/>
);

const BlueprintAppFrame = forwardRef(BlueprintAppFrameInner);
BlueprintAppFrame.displayName = 'BlueprintAppFrame';

export default BlueprintAppFrame;
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { type FC, useRef } from 'react';
import BlueprintAppFrame from './BlueprintAppFrame';
import IframeAppApprovalModal from './IframeAppApprovalModal';
import { useIframeBridge } from '../iframe/useIframeBridge';
import type { BlueprintIframeConfig } from '../iframe/types';

type Props = {
config: BlueprintIframeConfig;
appDisplayName: string;
};

// Composes the hardened iframe element, the parent-side message bridge, and
// the approval modal into a single drop-in block. Use this from any page
// that wants to render a trusted iframe-mode blueprint app.
const BlueprintAppFrameHost: FC<Props> = ({ config, appDisplayName }) => {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const { pendingApproval, approve, reject } = useIframeBridge({
config,
iframeRef,
});

return (
<>
<BlueprintAppFrame
ref={iframeRef}
config={config}
title={`${appDisplayName} (sandboxed)`}
/>
<IframeAppApprovalModal
pending={pendingApproval}
config={config}
appDisplayName={appDisplayName}
onApprove={approve}
onReject={reject}
/>
</>
);
};

export default BlueprintAppFrameHost;
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ import type { FC } from 'react';
import { Link } from 'react-router';
import { resolveBlueprintAppView } from '../resolver';
import type { BlueprintAppEntry } from '../types';
import type { TangleBlueprintAppEntry } from '../manifest';
import BlueprintAppFrameHost from './BlueprintAppFrameHost';

type Props = {
entry: BlueprintAppEntry;
entry: BlueprintAppEntry | TangleBlueprintAppEntry;
};

const BlueprintAppLandingPage: FC<Props> = ({ entry }) => {
const view = resolveBlueprintAppView(entry);
const iframeConfig =
'iframe' in entry &&
view.manifest.externalApp?.mode === 'iframe' &&
view.manifest.externalApp?.trust === 'trusted'
? entry.iframe
: undefined;
const provisionPath =
view.blueprintId !== undefined
? `/blueprints/${view.blueprintId.toString()}/deploy`
Expand Down Expand Up @@ -79,6 +87,17 @@ const BlueprintAppLandingPage: FC<Props> = ({ entry }) => {
</CardContent>
</Card>

{iframeConfig && (
<Card variant="sandbox" className="overflow-hidden rounded-3xl">
<CardContent className="p-0">
<BlueprintAppFrameHost
config={iframeConfig}
appDisplayName={view.manifest.displayName}
/>
</CardContent>
</Card>
)}

<div className="grid gap-5 md:grid-cols-3">
<SummaryCard
title="Cloud service"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { type FC, useCallback, useState } from 'react';
import {
useChainId,
useSendTransaction,
useSignMessage,
useSwitchChain,
} from 'wagmi';
import type { Hex } from 'viem';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@tangle-network/sandbox-ui/primitives';
import { Button } from '../../components/sandbox/SandboxUi';
import type {
ApprovalResult,
PendingApproval,
} from '../iframe/useIframeBridge';
import type { BlueprintIframeConfig } from '../iframe/types';

type Props = {
pending: PendingApproval | null;
config: BlueprintIframeConfig;
appDisplayName: string;
onApprove: (result: ApprovalResult) => void;
onReject: (reason: string) => void;
};

const RequestSummary: FC<{ pending: PendingApproval }> = ({ pending }) => {
switch (pending.kind) {
case 'tangle.app.signTransaction': {
const r = pending.request;
return (
<div className="space-y-2 text-sm">
<Row label="Action" value="Send transaction" />
<Row label="Chain" value={String(r.chainId)} />
<Row label="To" value={r.to} mono />
{r.value && r.value !== '0' && (
<Row label="Value (wei)" value={r.value} mono />
)}
<Row
label="Calldata"
value={`${r.data.slice(0, 10)}… (${(r.data.length - 2) / 2} bytes)`}
mono
/>
</div>
);
}
case 'tangle.app.signMessage': {
const r = pending.request;
return (
<div className="space-y-2 text-sm">
<Row label="Action" value="Sign message" />
<Row label="Chain" value={String(r.chainId)} />
<div>
<p className="text-xs text-muted-foreground mb-1">Message</p>
<pre className="rounded-md border border-border bg-muted/40 p-3 text-xs whitespace-pre-wrap break-all max-h-48 overflow-auto">
{r.message}
</pre>
</div>
</div>
);
}
case 'tangle.app.switchChain': {
const r = pending.request;
return (
<div className="space-y-2 text-sm">
<Row label="Action" value="Switch network" />
<Row label="Target chain" value={String(r.chainId)} />
</div>
);
}
}
};

const Row: FC<{ label: string; value: string; mono?: boolean }> = ({
label,
value,
mono,
}) => (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span
className={mono ? 'font-mono text-xs break-all' : 'text-sm'}
title={value}
>
{value}
</span>
</div>
);

const IframeAppApprovalModal: FC<Props> = ({
pending,
config,
appDisplayName,
onApprove,
onReject,
}) => {
const [submitting, setSubmitting] = useState(false);
const { sendTransactionAsync } = useSendTransaction();
const { signMessageAsync } = useSignMessage();
const { switchChainAsync } = useSwitchChain();
const chainId = useChainId();

const handleApprove = useCallback(async () => {
if (!pending) return;
setSubmitting(true);
try {
switch (pending.kind) {
case 'tangle.app.signTransaction': {
const r = pending.request;
if (chainId !== r.chainId) {
onReject(
`Active chain ${chainId} doesn't match the request's chain ${r.chainId}. Switch network first.`,
);
return;
}
const txHash = await sendTransactionAsync({
to: r.to,
data: r.data,
value: r.value !== undefined ? BigInt(r.value) : undefined,
chainId: r.chainId,
});
onApprove({ ok: true, data: { txHash: txHash as Hex } });
return;
}
case 'tangle.app.signMessage': {
const r = pending.request;
const signature = await signMessageAsync({ message: r.message });
onApprove({ ok: true, data: { signature: signature as Hex } });
return;
}
case 'tangle.app.switchChain': {
const r = pending.request;
await switchChainAsync({ chainId: r.chainId });
onApprove({ ok: true, data: { chainId: r.chainId } });
return;
}
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Approval failed.';
onReject(message);
} finally {
setSubmitting(false);
}
}, [
chainId,
onApprove,
onReject,
pending,
sendTransactionAsync,
signMessageAsync,
switchChainAsync,
]);

const handleReject = useCallback(() => {
onReject('User rejected the request.');
}, [onReject]);

return (
<Dialog
open={pending !== null}
onOpenChange={(open) => {
if (!open && !submitting) handleReject();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
Approve {pending?.kind.replace('tangle.app.', '')}
</DialogTitle>
<DialogDescription>
<span className="font-semibold">{appDisplayName}</span> at{' '}
<span className="font-mono text-xs">{config.origin}</span> is
requesting your wallet to perform an action. Review the details
carefully — this app cannot read your wallet directly; it can only
ask you to approve specific operations declared in its manifest.
</DialogDescription>
</DialogHeader>

{pending && (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<RequestSummary pending={pending} />
</div>
)}

<DialogFooter>
<Button
variant="secondary"
onClick={handleReject}
isDisabled={submitting}
>
Reject
</Button>
<Button
onClick={() => {
void handleApprove();
}}
isLoading={submitting}
>
Approve
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

export default IframeAppApprovalModal;
Loading
Loading