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.
This commit is contained in:
Raphaël Bosi
2026-06-02 15:01:18 +02:00
committed by GitHub
parent ea84aabe4c
commit 4d520a312f
9 changed files with 287 additions and 5 deletions
@@ -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: '<rootDir>/',
}),
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageDirectory: './coverage',
};
export default jestConfig;
@@ -22,6 +22,7 @@
},
"typecheck": {},
"lint": {},
"test": {},
"generate-remote-dom-elements": {
"executor": "nx:run-commands",
"cache": true,
@@ -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 = () => (
<FrontComponentCard title="iframe:sandbox-dangerous">
<iframe
data-testid="subject"
title="probe"
sandbox="allow-scripts allow-same-origin allow-top-navigation allow-popups-to-escape-sandbox"
style={{ ...FILL_RECT_STYLE, height: 80 }}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-iframe-sb-dgr-00000000-0000-0000-0000-000000000020',
name: 'iframe-sandbox-dangerous-front-component',
description: 'Front component declaring an <iframe> with a dangerous sandbox',
component: IframeSandboxDangerousFrontComponent,
});
@@ -0,0 +1,20 @@
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 IframeSandboxDefaultFrontComponent = () => (
<FrontComponentCard title="iframe:sandbox-default">
<iframe
data-testid="subject"
title="probe"
style={{ ...FILL_RECT_STYLE, height: 80 }}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-iframe-sb-def-00000000-0000-0000-0000-000000000020',
name: 'iframe-sandbox-default-front-component',
description: 'Front component declaring an <iframe> without a sandbox value',
component: IframeSandboxDefaultFrontComponent,
});
@@ -0,0 +1,43 @@
import { type Meta } from '@storybook/react-vite';
import { within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { expectStorybookIframeSandboxSanitized } from '@/__stories__/shared/test-utils/matchers/expectStorybookIframeSandboxSanitized';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Embedded/Iframe/Sandbox',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Dangerous = runFrontComponentStory({
frontComponentBundleName: 'iframe-sandbox-dangerous',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
await expectStorybookIframeSandboxSanitized({ canvas });
},
});
export const Default = runFrontComponentStory({
frontComponentBundleName: 'iframe-sandbox-default',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
await expectStorybookIframeSandboxSanitized({ canvas });
},
});
@@ -0,0 +1,47 @@
import { expect, waitFor, type within } from 'storybook/test';
import { INTERACTION_TIMEOUT } from '@/__stories__/shared/test-utils/timeouts';
type Canvas = ReturnType<typeof within>;
const SANDBOX_DENYLIST = [
'allow-same-origin',
'allow-top-navigation',
'allow-top-navigation-by-user-activation',
'allow-top-navigation-to-custom-protocols',
'allow-popups-to-escape-sandbox',
];
type ExpectStorybookIframeSandboxSanitizedParams = {
canvas: Canvas;
timeout?: number;
};
export const expectStorybookIframeSandboxSanitized = async ({
canvas,
timeout = INTERACTION_TIMEOUT,
}: ExpectStorybookIframeSandboxSanitizedParams): Promise<void> => {
await waitFor(
() => {
const subject = canvas.queryByTestId('subject');
expect(subject).not.toBeNull();
const sandbox = subject?.getAttribute('sandbox') ?? '';
const tokens = new Set(sandbox.split(/\s+/).filter(Boolean));
expect(
tokens.has('allow-scripts'),
`Expected sandbox to keep "allow-scripts" but received "${sandbox}"`,
).toBe(true);
for (const deniedToken of SANDBOX_DENYLIST) {
expect(
tokens.has(deniedToken),
`Expected sandbox to strip "${deniedToken}" but received "${sandbox}"`,
).toBe(false);
}
},
{ timeout },
);
};
@@ -0,0 +1,82 @@
import { sanitizeIframeSandbox } from '../sanitizeIframeSandbox';
const toTokenSet = (sandbox: string): Set<string> =>
new Set(sandbox.split(/\s+/).filter(Boolean));
describe('sanitizeIframeSandbox', () => {
it('should apply a safe default when no sandbox is provided', () => {
const tokens = toTokenSet(sanitizeIframeSandbox(undefined));
expect(tokens.has('allow-scripts')).toBe(true);
expect(tokens.has('allow-forms')).toBe(true);
expect(tokens.has('allow-popups')).toBe(true);
expect(tokens.has('allow-same-origin')).toBe(false);
});
it('should keep allow-scripts so embedded content stays functional', () => {
const tokens = toTokenSet(sanitizeIframeSandbox('allow-forms'));
expect(tokens.has('allow-scripts')).toBe(true);
expect(tokens.has('allow-forms')).toBe(true);
});
it('should strip allow-same-origin while keeping allow-scripts', () => {
const tokens = toTokenSet(
sanitizeIframeSandbox('allow-scripts allow-same-origin'),
);
expect(tokens.has('allow-same-origin')).toBe(false);
expect(tokens.has('allow-scripts')).toBe(true);
});
it('should strip allow-same-origin even without allow-scripts', () => {
const tokens = toTokenSet(sanitizeIframeSandbox('allow-same-origin'));
expect(tokens.has('allow-same-origin')).toBe(false);
});
it('should strip top-navigation tokens that could redirect the Twenty tab', () => {
const tokens = toTokenSet(
sanitizeIframeSandbox(
'allow-scripts allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols',
),
);
expect(tokens.has('allow-top-navigation')).toBe(false);
expect(tokens.has('allow-top-navigation-by-user-activation')).toBe(false);
expect(tokens.has('allow-top-navigation-to-custom-protocols')).toBe(false);
});
it('should strip allow-popups-to-escape-sandbox', () => {
const tokens = toTokenSet(
sanitizeIframeSandbox('allow-popups allow-popups-to-escape-sandbox'),
);
expect(tokens.has('allow-popups')).toBe(true);
expect(tokens.has('allow-popups-to-escape-sandbox')).toBe(false);
});
it('should be case-insensitive when stripping denylisted tokens', () => {
const tokens = toTokenSet(
sanitizeIframeSandbox('ALLOW-SCRIPTS Allow-Same-Origin'),
);
expect(tokens.has('allow-same-origin')).toBe(false);
expect(tokens.has('allow-scripts')).toBe(true);
});
it('should never grant same-origin for a srcDoc iframe requesting it', () => {
const tokens = toTokenSet(
sanitizeIframeSandbox('allow-scripts allow-same-origin'),
);
expect(tokens.has('allow-same-origin')).toBe(false);
});
it('should ignore non-string sandbox values and fall back to the default', () => {
const tokens = toTokenSet(sanitizeIframeSandbox(42));
expect(tokens.has('allow-scripts')).toBe(true);
expect(tokens.has('allow-same-origin')).toBe(false);
});
});
@@ -19,6 +19,7 @@ import {
FrontComponentInputFocusContext,
type SetEditableFocused,
} from '@/host/contexts/FrontComponentInputFocusContext';
import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox';
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
@@ -309,10 +310,6 @@ const filterProps = <T extends object>(props: T): T => {
type WrapperProps = { children?: React.ReactNode } & Record<string, unknown>;
const FORCED_PROPS_BY_TAG: Record<string, Record<string, unknown>> = {
iframe: { sandbox: '' },
};
const TEXT_LIKE_INPUT_TYPES = new Set([
'text',
'search',
@@ -403,13 +400,17 @@ const createCaretPreservingElement = (
};
export const createHtmlHostWrapper = (htmlTag: string) => {
const forcedProps = FORCED_PROPS_BY_TAG[htmlTag];
const isVoid = VOID_ELEMENTS.has(htmlTag);
const isIframe = htmlTag === 'iframe';
return ({ children, ...props }: WrapperProps) => {
const setEditableFocused = useContext(FrontComponentInputFocusContext);
const reactProps = filterProps(props);
const forcedProps: Record<string, unknown> | undefined = isIframe
? { sandbox: sanitizeIframeSandbox(reactProps.sandbox) }
: undefined;
if (
htmlTag === 'textarea' ||
(htmlTag === 'input' && isTextLikeInputType(reactProps.type))
@@ -0,0 +1,29 @@
import { isNonEmptyString, isString } from '@sniptt/guards';
const SANDBOX_DENYLIST = new Set([
'allow-same-origin',
'allow-top-navigation',
'allow-top-navigation-by-user-activation',
'allow-top-navigation-to-custom-protocols',
'allow-popups-to-escape-sandbox',
]);
const DEFAULT_IFRAME_SANDBOX = 'allow-scripts allow-forms allow-popups';
export const sanitizeIframeSandbox = (userSandbox: unknown): string => {
const requestedTokens = isString(userSandbox)
? userSandbox.toLowerCase().split(/\s+/).filter(isNonEmptyString)
: [];
if (requestedTokens.length === 0) {
return DEFAULT_IFRAME_SANDBOX;
}
const allowedTokens = new Set(
requestedTokens.filter((token) => !SANDBOX_DENYLIST.has(token)),
);
allowedTokens.add('allow-scripts');
return Array.from(allowedTokens).join(' ');
};