Skip to content

H-6544: Load Petrinaut in HASH in an iFrame, and add the AI assistant#8790

Open
CiaranMn wants to merge 4 commits into
cm/processes-pages-in-hashfrom
cm/petrinaut-in-hash-iframe-and-ai
Open

H-6544: Load Petrinaut in HASH in an iFrame, and add the AI assistant#8790
CiaranMn wants to merge 4 commits into
cm/processes-pages-in-hashfrom
cm/petrinaut-in-hash-iframe-and-ai

Conversation

@CiaranMn
Copy link
Copy Markdown
Member

🌟 What is the purpose of this PR?

Two changes to Petrinaut in HASH:

  1. Load it in an iFrame, given that it evaluates user-provided code (the risk being that someone unsuspectingly loads a malicious model)
  2. Add an API endpoint for proxying requests to OpenAI, and enable the AI assistant

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • does not modify any publishable blocks or libraries, or modifications do not need publishing

📜 Does this require a change to the docs?

The changes in this PR:

  • are internal and do not require a docs change

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

🛡 What tests cover this?

  • None in HASH yet.

❓ How to test this?

  1. TODO

📹 Demo

@vercel
Copy link
Copy Markdown

vercel Bot commented May 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hash Error Error May 29, 2026 7:50pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored May 29, 2026 7:50pm
petrinaut Skipped Skipped May 29, 2026 7:50pm

@github-actions github-actions Bot added area/deps Relates to third-party dependencies (area) area/apps > hash* Affects HASH (a `hash-*` app) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team area/apps labels May 29, 2026
@CiaranMn CiaranMn marked this pull request as ready for review May 29, 2026 19:46
@cursor
Copy link
Copy Markdown

cursor Bot commented May 29, 2026

PR Summary

High Risk
Introduces 'unsafe-eval' in an embed CSP, a large host/iframe trust boundary over postMessage, and a new authenticated LLM proxy that spends API quota—rate limits are in-memory only on serverless.

Overview
Petrinaut in HASH now runs inside a sandboxed null-origin iframe (allow-scripts only) on /processes/<uuid>/embed, instead of rendering Petrinaut inline on the process page. The host process editor keeps routing, graph save/load, dirty guards, and revision history; the iframe owns the live editor, workers, and dirty state. A typed postMessage bridge (init/load/requestSave/revisions, etc.) syncs both sides.

Security and observability: Middleware applies a dedicated embed CSP ('unsafe-eval' for user code, frame-ancestors 'self'). Embed documents skip Sentry and use an iframe error reporter forwarded to the host’s Sentry. _app uses a minimal shell on the embed route. /fonts/* gets Access-Control-Allow-Origin: * so fonts load in the opaque-origin iframe.

AI assistant: New streaming App Router POST /api/petrinaut-ai-chat (Ory session, in-memory rate limit, OpenAI via Vercel AI SDK + petrinaut-core tools). The iframe cannot call it directly; chat goes host → API with session cookies, response bytes streamed back over the bridge. AI conversations persist in host localStorage (migrated on first save from draft).

UX/perf: The process route mounts the iframe before router.isReady to parallelize bundle load; a loading skeleton covers until ready + init.

Reviewed by Cursor Bugbot for commit e6fca59. Bugbot is set up for automated code reviews on this repo. Configure here.

}, []);

const send = useCallback<IframeBridge["send"]>((message) => {
window.parent.postMessage(message, "*");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:

Using "*" as the postMessage target origin allows any webpage to intercept messages sent from this iframe, risking information disclosure of sensitive data like user actions and document state.

More details about this

The window.parent.postMessage() calls are using "*" as the target origin, which means any webpage can receive the messages sent from this iframe.

Exploit scenario:

  1. An attacker hosts a malicious webpage and tricks a user into visiting it while they have a tab open to your application containing this iframe.
  2. The attacker's webpage listens for messages with window.addEventListener("message", ...) to capture any postMessage events from iframes.
  3. When the iframe executes window.parent.postMessage({ kind: "ready" }, "*"), the message broadcasts to any origin, including the attacker's malicious site.
  4. Similarly, when send() is called with window.parent.postMessage(message, "*"), the attacker intercepts sensitive data like saveResult, revisionsList, or setReadonly callbacks that may contain user data, document content, or application state.
  5. The attacker can extract this information and use it to steal user data or manipulate the application state.

Even though the code comment notes that "the message is delivered to the parent window only," using "*" removes the browser's origin-check enforcement, allowing any frame in any tab to listen and capture the messages.

To resolve this comment:

✨ Commit fix suggestion

Suggested change
window.parent.postMessage(message, "*");
// Use a validated, explicit parent origin from trusted configuration.
// Ensure `hostOrigin` is derived from trusted config (for example, a query param or injected config),
// normalized via `new URL(...).origin`, and checked against an exact allowlist before use.
window.parent.postMessage(message, hostOrigin);
View step-by-step instructions
  1. Replace the wildcard target origin in both window.parent.postMessage(..., "*") calls with a specific allowed origin value, for example window.parent.postMessage(message, hostOrigin).
  2. Add a way to get the parent application's origin from trusted configuration instead of trying to read it from the iframe at runtime. For example, pass it into the iframe through a query parameter such as ?parentOrigin=https://example.com or an injected config value, then store it in a variable like const hostOrigin = ....
  3. Validate that the configured origin is exactly one expected origin before using it. For example, parse it with new URL(parentOrigin).origin and compare it against an allowlist such as const allowedOrigins = ["https://example.com"].
  4. Update the initial ready message to use the validated origin: window.parent.postMessage({ kind: "ready" }, hostOrigin).
  5. Update the send callback to use the same validated origin: window.parent.postMessage(message, hostOrigin).
  6. Reject incoming message events unless event.origin matches the same expected origin before processing data. This keeps the send and receive sides consistent and prevents untrusted windows from driving the iframe logic.

Alternatively, if the parent origin can vary by environment, define an environment-specific allowlist such as ["https://app.example.com", "https://staging.example.com"] and only use the origin when it matches one of those exact values.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by wildcard-postmessage-configuration.

You can view more details about this finding in the Semgrep AppSec Platform.

Comment on lines +85 to +88
window.parent.postMessage(
{ kind: "ready" } satisfies IframeToHostMessage,
"*",
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:

The postMessage API is called with wildcard origin "*", allowing any website to potentially intercept sensitive messages sent between the iframe and parent window.

More details about this

The window.parent.postMessage() call uses "*" as the target origin, which means any website can potentially receive the message containing the { kind: "ready" } object.

Exploit scenario: An attacker could create a malicious website and trick a user into visiting it while they have your iframe-based application open in another tab. The attacker's page could listen for postMessage events via window.addEventListener("message", (event) => { console.log(event.data); }) and intercept the { kind: "ready" } message. While this specific message may seem low-risk, if the application later sends sensitive data through window.parent.postMessage(message, "*") (as seen in the send function), an attacker could harvest that information. Additionally, attackers could send malicious messages back to the iframe that get processed by the onMessage handler, potentially manipulating the iframe's state or triggering unintended actions like current.onSaveResult(data) with forged data.

The vulnerability exists because postMessage with "*" bypasses origin verification—the browser will deliver the message to window.parent but the message itself is accessible to any page that listens for it, not just the intended parent window.

To resolve this comment:

✨ Commit fix suggestion

Suggested change
window.parent.postMessage(
{ kind: "ready" } satisfies IframeToHostMessage,
"*",
);
// SECURITY: Replace this placeholder with the exact trusted host origin
// configured by the application (for example, from a validated iframe
// query parameter or environment-specific config). Do not use "*".
const PARENT_ORIGIN = "https://example.com";
window.parent.postMessage(
{ kind: "ready" } satisfies IframeToHostMessage,
PARENT_ORIGIN,
);
View step-by-step instructions
  1. Replace the wildcard target origin in window.parent.postMessage(..., "*") with a specific trusted origin string, such as window.parent.postMessage(message, "https://example.com").
  2. Pass the host origin into the iframe from a trusted source instead of trying to read it from window.parent. For example, provide it in the iframe URL like ...?parentOrigin=https%3A%2F%2Fexample.com or in an initialization message that you validate before storing it.
  3. Store that origin in a variable and reuse it for every message to the parent, including both the { kind: "ready" } message and the generic send callback, for example const parentOrigin = ...; window.parent.postMessage({ kind: "ready" }, parentOrigin); and window.parent.postMessage(message, parentOrigin);.
  4. Validate the origin value before using it so only expected schemes and hosts are allowed. For example, parse it with new URL(parentOrigin) and only allow known origins such as https://example.com.
  5. Reject incoming message events unless event.origin exactly matches the same trusted parent origin before calling any handler, for example if (event.origin !== parentOrigin) return;.
  6. Alternatively, if the parent origin is fixed for each environment, define it in configuration with a value like const PARENT_ORIGIN = "https://example.com" and use that constant everywhere instead of "*". This works because postMessage only delivers the data when the target window's origin matches the value you provide.
💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by wildcard-postmessage-configuration.

You can view more details about this finding in the Semgrep AppSec Platform.

* Target origin is "*" — see `use-iframe-bridge.ts` for why a stricter
* value isn't readable from a null-origin sandbox.
*/
window.parent.postMessage(message, "*");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:

Iframe is sending error data including stack traces to any origin via postMessage with wildcard target, exposing sensitive debugging information to attackers.

More details about this

The iframe is sending error information to its parent window with a wildcard origin ("*") in window.parent.postMessage(). This means any malicious script running on any origin can intercept the message object, which contains error details including the stack trace via the serializeError() function.

Attack scenario:

  1. An attacker controls a website and tricks a user into visiting it while the iframe from your application is embedded elsewhere (e.g., /processes/<id>/embed).
  2. The attacker's site frames or embeds content that triggers an error in your iframe (e.g., by loading a malicious script that throws an exception).
  3. When installIframeErrorReporter() registers the error event listener, the error is caught and post("window-error", event.error ?? event.message) is called.
  4. The serializeError(raw) function extracts sensitive data: name, message, and stack properties from the error object.
  5. This error object is packaged into the message variable along with source: "window-error", mode: activeMode, and sent via window.parent.postMessage(message, "*").
  6. Because the target origin is "*", any script on any page that can manipulate the frame hierarchy can receive this message and extract the error stack, which may contain file paths, variable names, or other sensitive information useful for further attacks.

To resolve this comment:

✨ Commit fix suggestion

Suggested change
window.parent.postMessage(message, "*");
const parentOriginParam = new URLSearchParams(window.location.search).get("parentOrigin");
if (!parentOriginParam) {
return;
}
let parentOrigin: string;
try {
parentOrigin = new URL(parentOriginParam).origin;
} catch {
return;
}
// Update this allowlist to the exact parent origins that are permitted to host this iframe.
const ALLOWED_PARENT_ORIGINS = ["https://example.com"];
if (!ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
return;
}
window.parent.postMessage(message, parentOrigin);
View step-by-step instructions
  1. Replace the wildcard target origin with a specific allowed origin instead of *.
    For example, change window.parent.postMessage(message, "*") to window.parent.postMessage(message, parentOrigin).

  2. Add a trusted parent origin value that the iframe can read at runtime.
    A simple option is to pass it in the iframe URL and validate it before use, for example const parentOrigin = new URLSearchParams(window.location.search).get("parentOrigin");.

  3. Validate that the origin is present and well-formed before sending the message.
    For example, parse it with new URL(parentOrigin) and only use url.origin; if parsing fails or the value is empty, return without calling postMessage(...).

  4. Restrict the value to the exact parent origins your app expects.
    For example, check if (!ALLOWED_PARENTS.includes(url.origin)) return; before window.parent.postMessage(message, url.origin). This prevents an attacker from supplying their own origin string.

  5. Update the iframe host code to pass the expected parent origin when it creates the iframe URL.
    For example, build the iframe src with a query parameter such as https://example.com/embed?...&parentOrigin=${encodeURIComponent(window.location.origin)}.

  6. If this iframe can run in a sandboxed or null-origin context, keep using the parent-provided origin value rather than trying to derive it from the iframe’s own origin.
    postMessage only needs the receiver’s origin, so an opaque iframe origin does not require using *.

Alternatively, if the parent origin is fixed for this deployment, hardcode the exact origin in configuration and use that value directly, such as const parentOrigin = "https://example.com";, instead of accepting it from the URL.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by wildcard-postmessage-configuration.

You can view more details about this finding in the Semgrep AppSec Platform.

@augmentcode
Copy link
Copy Markdown

augmentcode Bot commented May 29, 2026

🤖 Augment PR Summary

Summary: This PR isolates Petrinaut’s execution by moving it into a sandboxed iframe, and adds a streamed AI assistant backed by an authenticated OpenAI proxy.

Changes:

  • Adds a dedicated embed route (/processes/<uuid>/embed) that dynamically loads Petrinaut client-side only.
  • Refactors the main process editor to host an <iframe sandbox="allow-scripts"> and communicate via a typed postMessage bridge.
  • Introduces host/iframe message types, host and iframe bridge hooks, and a loading skeleton while the iframe warms up.
  • Adds a stricter embed-specific CSP (including 'unsafe-eval') applied via middleware for the embed route.
  • Adds an App Router streaming route handler (/api/petrinaut-ai-chat) that validates UI messages, enforces per-user rate limiting, and streams responses from OpenAI.
  • Proxies AI chat from the iframe through the host (cookie-bearing) page, relaying raw streamed bytes back to the iframe for SSE parsing by the AI SDK.
  • Persists AI conversations in host localStorage keyed by net, and forwards iframe errors to the host’s Sentry SDK.

Technical Notes: Adds CORS headers for self-hosted fonts to work from a null-origin iframe, and conditionally disables Sentry init inside the embed document in favor of postMessage-based error reporting.

🤖 Was this summary useful? React with 👍 or 👎

Copy link
Copy Markdown

@augmentcode augmentcode Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 3 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.


"font-src": ["'self'", "data:"],

"connect-src": ["'self'"],
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apps/hash-frontend/src/lib/csp.ts:137 — The embed CSP comment says connect-src is 'none', but the directive currently allows 'self', which seems to permit network requests from the sandboxed iframe to same-host endpoints. Consider aligning the directive (or the comment) with the intended “no network from iframe” boundary to avoid accidental reachability.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

setPendingSaveRequestId((prev) =>
prev === payload.requestId ? null : prev,
);
if (payload.result.ok) {
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apps/hash-frontend/src/pages/processes/[uuid]/embed.page/embed-content.tsx:161 — onSaveResult updates mode/savedSnapshot for any successful payload even when payload.requestId doesn’t match the current pendingSaveRequestId, so a late save response from a previous net/navigation could overwrite the active editor state. It seems safer to ignore saveResult messages that don’t correspond to the currently pending request.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

* `iframeRef.current` identity isn't reactive on its own, so consumers
* remount the `<iframe>` via `key` and we observe the load event.
*/
useEffect(() => {
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apps/hash-frontend/src/pages/processes/shared/use-host-bridge.ts:136 — This load listener only attaches to the iframe element that exists when the effect runs; if the iframe is ever re-mounted/swapped (as the comment describes), iframeRef.current changes but this effect won’t re-run to attach to the new element. That could leave isReady stale and allow the host to send messages before the new iframe has posted ready.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

const postToHost = (message: IframeToHostMessage) => {
// Target origin "*" — see `use-iframe-bridge.ts` for why a stricter value
// isn't readable from a null-origin sandbox. Delivered to the parent only.
window.parent.postMessage(message, "*");
Comment on lines +52 to +123
window.addEventListener("message", (event) => {
if (event.source !== window.parent) {
return;
}
const data = event.data as unknown;
if (
typeof data !== "object" ||
data === null ||
typeof (data as { kind?: unknown }).kind !== "string"
) {
return;
}

const message = data as HostToIframeMessage;
if (
message.kind !== "aiChatResponseStart" &&
message.kind !== "aiChatChunk" &&
message.kind !== "aiChatEnd" &&
message.kind !== "aiChatError"
) {
return;
}

const pending = pendingRequests.get(message.requestId);
if (!pending) {
return;
}

switch (message.kind) {
case "aiChatResponseStart": {
pending.responded = true;
pending.resolveResponse(
new Response(pending.stream, {
status: message.status,
statusText: message.statusText,
}),
);
break;
}
case "aiChatChunk": {
try {
pending.controller.enqueue(message.bytes);
} catch {
// Stream already closed/errored (e.g. consumer aborted) — drop.
}
break;
}
case "aiChatEnd": {
try {
pending.controller.close();
} catch {
// Already closed.
}
pendingRequests.delete(message.requestId);
break;
}
case "aiChatError": {
const error = new Error(message.message);
if (pending.responded) {
try {
pending.controller.error(error);
} catch {
// Already settled.
}
} else {
pending.rejectResponse(error);
}
pendingRequests.delete(message.requestId);
break;
}
}
});
* Target origin is "*" — see `use-iframe-bridge.ts` for why a stricter
* value isn't readable from a null-origin sandbox.
*/
window.parent.postMessage(message, "*");
* The data is delivered to this specific iframe's window, not
* broadcast, so a third party can't intercept it.
*/
iframeWindow.postMessage(message, "*");
Comment on lines +85 to +88
window.parent.postMessage(
{ kind: "ready" } satisfies IframeToHostMessage,
"*",
);
}, []);

const send = useCallback<IframeBridge["send"]>((message) => {
window.parent.postMessage(message, "*");
context: Record<string, unknown> = {},
) => {
// eslint-disable-next-line no-console
console.error(`[Petrinaut AI] ${reason}`, context);
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e6fca59. Configure here.

[iframeRef],
);

return { isReady, send };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unmemoized bridge object causes excessive effect re-runs

Medium Severity

useHostBridge returns a plain { isReady, send } object literal without useMemo, creating a new reference on every render. Since process-editor.tsx lists bridge in the dependency arrays of multiple useEffect hooks (e.g. the revisionsList and setReadonly effects), every host re-render fires those effects and sends redundant postMessages to the iframe. Each redundant message triggers a state update and re-render inside the iframe. The same issue exists in useIframeBridge's return value.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e6fca59. Configure here.

title,
decisionTime: result.decisionTime,
},
revisions: buildRevisionSummaries(revisions),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale revisions closure in save result callback

Low Severity

The onRequestSave handler's .then callback references revisions from the closure captured at handler-invocation time. Since persistDefinition internally awaits refetchRevisions() (which updates Apollo's cache), the closure's revisions is stale by the time saveResult is sent — it won't include the newly-saved revision. The iframe's version picker briefly shows the old revision count until the separate revisionsList effect corrects it.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e6fca59. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps > hash* Affects HASH (a `hash-*` app) area/apps area/deps Relates to third-party dependencies (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

2 participants