Skip to content
Open
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
64 changes: 41 additions & 23 deletions apps/hash-frontend/instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,47 @@ import {
SENTRY_ENVIRONMENT,
SENTRY_REPLAYS_SESSION_SAMPLE_RATE,
} from "./src/lib/public-env";
import { installIframeErrorReporter } from "./src/pages/processes/shared/iframe-error-reporter";

Sentry.init({
dsn: SENTRY_DSN,
enabled: !!SENTRY_DSN,
environment: SENTRY_ENVIRONMENT,
integrations: [
Sentry.browserApiErrorsIntegration(),
Sentry.browserProfilingIntegration(),
Sentry.browserSessionIntegration(),
Sentry.browserTracingIntegration(),
Sentry.graphqlClientIntegration({
endpoints: [/\/graphql$/],
}),
Sentry.replayIntegration(),
],
release: buildStamp,
replaysOnErrorSampleRate: 1,
replaysSessionSampleRate: parseFloat(
SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0",
),
sendDefaultPii: true,
tracePropagationTargets: ["localhost", /^https:\/\/(?:.*\.)?hash\.ai/],
tracesSampleRate: isProduction ? 1.0 : 0,
});
/**
* The Petrinaut embed page (`/processes/<uuid>/embed`) runs inside a
* sandboxed null-origin iframe with `connect-src 'self'`. Sentry's
* transport would be blocked by CSP and the resulting events would lack
* the host's authenticated-user context anyway. Instead we install a
* tiny reporter that forwards errors to the host over the postMessage
* bridge, and the host's Sentry SDK captures them with iframe-specific
* tags.
*/
const isPetrinautEmbedDocument =
typeof window !== "undefined" &&
/^\/processes\/[^/]+\/embed/.test(window.location.pathname);

if (isPetrinautEmbedDocument) {
installIframeErrorReporter();
} else {
Sentry.init({
dsn: SENTRY_DSN,
enabled: !!SENTRY_DSN,
environment: SENTRY_ENVIRONMENT,
integrations: [
Sentry.browserApiErrorsIntegration(),
Sentry.browserProfilingIntegration(),
Sentry.browserSessionIntegration(),
Sentry.browserTracingIntegration(),
Sentry.graphqlClientIntegration({
endpoints: [/\/graphql$/],
}),
Sentry.replayIntegration(),
],
release: buildStamp,
replaysOnErrorSampleRate: 1,
replaysSessionSampleRate: parseFloat(
SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0",
),
sendDefaultPii: true,
tracePropagationTargets: ["localhost", /^https:\/\/(?:.*\.)?hash\.ai/],
tracesSampleRate: isProduction ? 1.0 : 0,
});
}

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
23 changes: 21 additions & 2 deletions apps/hash-frontend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ const sentryWebpackPluginOptions = {
process.env.NEXT_PUBLIC_SHOW_WORKER_COST =
process.env.SHOW_WORKER_COST ?? "false";

// This allows the frontend to generate the graph type IDs in the browser
process.env.NEXT_PUBLIC_FRONTEND_URL = process.env.FRONTEND_URL;
if (process.env.FRONTEND_URL) {
// Feeds frontendUrl in isomorphic-utils/environment.ts
// Fallbacks to Vercel-provided URL, and ultimately localhost:3000.
process.env.NEXT_PUBLIC_FRONTEND_URL = process.env.FRONTEND_URL;
}

// The API origin
process.env.NEXT_PUBLIC_API_ORIGIN =
Expand Down Expand Up @@ -139,6 +142,22 @@ export default withSentryConfig(
},
],
},
{
/**
* Self-hosted fonts referenced from `globals.scss` (Inter, Open
* Sauce Two, Apercu, IBM Plex, …). The Petrinaut embed route is
* loaded into a sandboxed null-origin iframe; the browser's
* anonymous-CORS rule for font fetches treats the same-host
* request as cross-origin and rejects it without these headers.
*/
source: "/fonts/:path*",
headers: [
{
key: "access-control-allow-origin",
value: "*",
},
],
},
];
},
pageExtensions: ["page.tsx", "page.ts", "page.jsx", "page.jsx", "api.ts"],
Expand Down
5 changes: 4 additions & 1 deletion apps/hash-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"start:test:healthcheck": "wait-on --timeout 600000 http://localhost:3000"
},
"dependencies": {
"@ai-sdk/openai": "3.0.63",
"@apollo/client": "3.10.5",
"@blockprotocol/core": "0.1.4",
"@blockprotocol/graph": "workspace:*",
Expand Down Expand Up @@ -69,6 +70,7 @@
"@tldraw/tlvalidate": "2.0.0-alpha.12",
"@types/prismjs": "1.26.5",
"@vercel/edge-config": "0.4.1",
"ai": "6.0.182",
"axios": "1.16.1",
"buffer": "6.0.3",
"clsx": "2.1.1",
Expand Down Expand Up @@ -129,7 +131,8 @@
"url-regex-safe": "4.0.0",
"use-font-face-observer": "1.3.0",
"uuid": "14.0.0",
"web-worker": "1.4.1"
"web-worker": "1.4.1",
"zod": "4.4.3"
},
"devDependencies": {
"@graphql-codegen/cli": "^6.2.1",
Expand Down
70 changes: 67 additions & 3 deletions apps/hash-frontend/src/lib/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

import { apiOrigin } from "@local/hash-isomorphic-utils/environment";

const buildDirectiveString = (directives: Record<string, string[]>): string =>
Object.entries(directives)
.map(([key, values]) => `${key} ${values.join(" ")}`)
.join("; ");

export const buildCspHeader = (nonce: string): string => {
const directives: Record<string, string[]> = {
"default-src": ["'self'"],
Expand Down Expand Up @@ -86,7 +91,66 @@ export const buildCspHeader = (nonce: string): string => {
"form-action": ["'self'"],
};

return Object.entries(directives)
.map(([key, values]) => `${key} ${values.join(" ")}`)
.join("; ");
return buildDirectiveString(directives);
};

/**
* Stricter CSP for the Petrinaut embed route (`/processes/<uuid>/embed`).
*
* The embed route is loaded into a sandboxed null-origin iframe so user-
* provided code (place visualizers, metric/scenario expressions) can be
* compiled with `new Function()` without endangering the parent HASH origin.
*
* Key differences vs the default CSP:
* - `script-src` includes `'unsafe-eval'` so Babel + `new Function()` work.
* - `connect-src` is `'self'`, but the sandbox's opaque origin makes that
* effectively no network reach.
* All persistence + AI requests deliberately round-trip through the
* host via postMessage instead. `'self'` (rather than `'none'`) is kept only
* so Next.js's dev-mode HMR probe doesn't spew CSP-violation noise inside the
* iframe; the real isolation is the opaque origin, not this directive.
* - `frame-ancestors 'self'` β€” only HASH itself may embed this route.
* - `worker-src` allows `blob:` because Monaco / petrinaut spawn workers
* from blob URLs.
*/
export const buildEmbedCspHeader = (nonce: string): string => {
const directives: Record<string, string[]> = {
"default-src": ["'none'"],

"script-src": [
"'self'",
`'nonce-${nonce}'`,
"'wasm-unsafe-eval'",
// The whole point of the embed route: user-provided code is compiled
// with `new Function()`, which requires `'unsafe-eval'`. Contained to
// the null-origin iframe.
"'unsafe-eval'",
],

"style-src": [
"'self'",
// Required for Emotion/MUI CSS-in-JS inline style injection.
"'unsafe-inline'",
],

"img-src": ["'self'", "data:", "blob:"],

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

// Effectively no real reach from the opaque-origin sandbox β€” see the
// `connect-src` note in this function's doc comment.
"connect-src": ["'self'"],
Comment thread
CiaranMn marked this conversation as resolved.

"worker-src": ["'self'", "blob:"],

"frame-src": ["'none'"],

"frame-ancestors": ["'self'"],

"object-src": ["'none'"],
"base-uri": ["'none'"],
"form-action": ["'none'"],
};

return buildDirectiveString(directives);
};
15 changes: 13 additions & 2 deletions apps/hash-frontend/src/middleware.page.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { get } from "@vercel/edge-config";
import { type NextRequest, NextResponse } from "next/server";

import { buildCspHeader } from "./lib/csp";
import { buildCspHeader, buildEmbedCspHeader } from "./lib/csp";
import {
returnTypeAsJson,
versionedUrlRegExp,
Expand Down Expand Up @@ -30,9 +30,20 @@ const applyCspHeaders = (
return response;
};

/**
* Matches the Petrinaut iframe embed route, which gets a stricter, eval-
* permitting CSP applied on top of being loaded into a sandboxed null-origin
* iframe by the host page.
*
* @see {@link buildEmbedCspHeader}
*/
const petrinautEmbedRouteRegExp = /^\/processes\/[^/]+\/embed(?:\/|$)/;

export const middleware = async (request: NextRequest) => {
const nonce = generateNonce();
const cspHeader = buildCspHeader(nonce);
const cspHeader = petrinautEmbedRouteRegExp.test(request.nextUrl.pathname)
? buildEmbedCspHeader(nonce)
: buildCspHeader(nonce);

// Forward the nonce to server-side rendering via a request header so that
// _document.page.tsx can read it and apply it to <Head> / <NextScript>.
Expand Down
60 changes: 59 additions & 1 deletion apps/hash-frontend/src/pages/_app.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { NotificationCountContextProvider } from "../shared/notification-count-c
import { PropertyTypesContextProvider } from "../shared/property-types-context";
import { RoutePageInfoProvider } from "../shared/routing";
import { ErrorFallback } from "./_app.page/error-fallback";
import { reportIframeReactError } from "./processes/shared/iframe-error-reporter";
import { redirectInGetInitialProps } from "./shared/_app.util";
import { AuthInfoProvider, useAuthInfo } from "./shared/auth-info-context";
import { DataTypesContextProvider } from "./shared/data-types-context";
Expand Down Expand Up @@ -200,10 +201,52 @@ const App: FunctionComponent<AppProps> = ({
);
};

const PETRINAUT_EMBED_PATHNAME = "/processes/[uuid]/embed";

/**
* Minimal `_app` shell for the Petrinaut embed route.
*/
const PetrinautEmbedAppShell: FunctionComponent<AppProps> = ({
Component,
pageProps,
emotionCache = clientSideEmotionCache,
}) => (
<Suspense>
<CacheProvider value={emotionCache}>
<ThemeProvider theme={theme}>
<ErrorBoundary
beforeCapture={(scope) => {
scope.setTag("error-boundary", "_app-embed");
}}
/**
* Forward into the host's Sentry. The boundary's local
* captureException is a no-op here because Sentry isn't
* initialised inside the embed iframe (see
* `instrumentation-client.ts`).
*/
onError={(error) => reportIframeReactError(error)}
fallback={ErrorFallback}
>
<Component {...pageProps} />
</ErrorBoundary>
</ThemeProvider>
</CacheProvider>
{globalStyles}
</Suspense>
);

const AppWithTypeSystemContextProvider: AppPage<AppProps, AppInitialProps> = (
props,
) => {
const { initialAuthenticatedUserSubgraph, user } = props;
const {
initialAuthenticatedUserSubgraph,
user,
router: { pathname },
} = props;

if (pathname === PETRINAUT_EMBED_PATHNAME) {
return <PetrinautEmbedAppShell {...props} />;
}
Comment thread
cursor[bot] marked this conversation as resolved.

return (
<ApolloProvider client={apolloClient}>
Expand Down Expand Up @@ -305,6 +348,21 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => {
? getRoots(initialAuthenticatedUserSubgraph)[0]
: undefined;

if (pathname === PETRINAUT_EMBED_PATHNAME) {
if (userEntity) {
/**
* Don't inject user data into the Petrinaut embed route.
*/
return {};
}
return {
redirectTo: redirectInGetInitialProps({
appContext,
location: `/signin?return_to=${req?.url ?? asPath}`,
}),
};
}

/** @todo: make additional pages publicly accessible */
if (!userEntity) {
let redirectTo: string | undefined;
Expand Down
Loading
Loading