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:
@@ -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,
|
||||
|
||||
+21
@@ -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,
|
||||
});
|
||||
+20
@@ -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,
|
||||
});
|
||||
+43
@@ -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 });
|
||||
},
|
||||
});
|
||||
+47
@@ -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 },
|
||||
);
|
||||
};
|
||||
+82
@@ -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(' ');
|
||||
};
|
||||
Reference in New Issue
Block a user