From 4d520a312f38534a168dc88b3e471a3190cef6df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:01:18 +0200 Subject: [PATCH] Allow functional iframes in front components while blocking sandbox escapes (#21145) Fixes https://github.com/twentyhq/twenty/issues/19899 Front component iframes were previously forced to `sandbox=""`, which fully locks them down: no scripts, no forms, no popups. That broke any legitimate embedded content (maps, widgets, embeds) developers tried to render. But we can't just trust the app-provided sandbox value either: tokens like allow-same-origin or allow-top-navigation would let a malicious embed escape the sandbox and hijack the host Twenty tab. - Add `sanitizeIframeSandbox`, which keeps the iframe useful while enforcing security: applies a safe default (allow-scripts allow-forms allow-popups) when no sandbox is set always forces allow-scripts so embeds work - strips dangerous tokens (`allow-same-origin`, all `allow-top-navigation`*, `allow-popups-to-escape-sandbox`), case-insensitively - Wire it into `createHtmlHostWrapper` so every `iframe` rendered by a front component is sanitized. - Add unit tests for the sanitizer and Storybook interaction tests asserting dangerous sandboxes are stripped. --- .../jest.config.mjs | 38 +++++++++ .../project.json | 1 + ...rame-sandbox-dangerous.front-component.tsx | 21 +++++ ...iframe-sandbox-default.front-component.tsx | 20 +++++ .../iframe/iframe-sandbox.stories.tsx | 43 ++++++++++ .../expectStorybookIframeSandboxSanitized.ts | 47 +++++++++++ .../__tests__/sanitizeIframeSandbox.test.ts | 82 +++++++++++++++++++ .../src/host/utils/createHtmlHostWrapper.ts | 11 +-- .../src/host/utils/sanitizeIframeSandbox.ts | 29 +++++++ 9 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-front-component-renderer/jest.config.mjs create mode 100644 packages/twenty-front-component-renderer/src/__stories__/html-tag/embedded/iframe/iframe-sandbox-dangerous.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/html-tag/embedded/iframe/iframe-sandbox-default.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/html-tag/embedded/iframe/iframe-sandbox.stories.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/shared/test-utils/matchers/expectStorybookIframeSandboxSanitized.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/__tests__/sanitizeIframeSandbox.test.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/sanitizeIframeSandbox.ts diff --git a/packages/twenty-front-component-renderer/jest.config.mjs b/packages/twenty-front-component-renderer/jest.config.mjs new file mode 100644 index 0000000000..d9045ebb41 --- /dev/null +++ b/packages/twenty-front-component-renderer/jest.config.mjs @@ -0,0 +1,38 @@ +import { readFileSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { pathsToModuleNameMapper } from 'ts-jest'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const tsConfigPath = resolve(__dirname, './tsconfig.json'); +const tsConfig = JSON.parse(readFileSync(tsConfigPath, 'utf8')); + +const jestConfig = { + displayName: 'twenty-front-component-renderer', + preset: '../../jest.preset.js', + testEnvironment: 'jsdom', + transformIgnorePatterns: ['../../node_modules/'], + transform: { + '^.+\\.[tj]sx?$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'typescript', tsx: true }, + transform: { react: { runtime: 'automatic' } }, + }, + }, + ], + }, + moduleNameMapper: { + ...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, { + prefix: '/', + }), + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + extensionsToTreatAsEsm: ['.ts', '.tsx'], + coverageDirectory: './coverage', +}; + +export default jestConfig; diff --git a/packages/twenty-front-component-renderer/project.json b/packages/twenty-front-component-renderer/project.json index 9485478cc1..9f481837c7 100644 --- a/packages/twenty-front-component-renderer/project.json +++ b/packages/twenty-front-component-renderer/project.json @@ -22,6 +22,7 @@ }, "typecheck": {}, "lint": {}, + "test": {}, "generate-remote-dom-elements": { "executor": "nx:run-commands", "cache": true, diff --git a/packages/twenty-front-component-renderer/src/__stories__/html-tag/embedded/iframe/iframe-sandbox-dangerous.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/html-tag/embedded/iframe/iframe-sandbox-dangerous.front-component.tsx new file mode 100644 index 0000000000..ff66e0fb89 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/html-tag/embedded/iframe/iframe-sandbox-dangerous.front-component.tsx @@ -0,0 +1,21 @@ +import { defineFrontComponent } from 'twenty-sdk/define'; +import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card'; +import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles'; + +const IframeSandboxDangerousFrontComponent = () => ( + +