🔒️(settings) add settings for the embed block

We added 2 settings for the embed block:
- FRONTEND_EMBED_BLOCK_ENABLED: to enable or disable
  the embed block in the editor
- FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS: to define a
  list of allowed origins for the embed block, and
  the associated sandbox attributes (allow-scripts,
  allow-same-origin, etc.)
This will allow to control the usage of the embed
block in the editor, and to restrict the origins
that can be embedded.
This commit is contained in:
Anthony LC
2026-07-20 10:51:53 +02:00
parent 8f51f23d66
commit 76dd9c1dd3
10 changed files with 383 additions and 12 deletions
+2
View File
@@ -80,6 +80,8 @@ These are the environment variables you can set for the `impress-backend` contai
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_EMBED_BLOCK_ENABLED | Frontend feature flag to allow inserting the "embed" block | true |
| FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS | Allowlist mapping each embeddable host to its iframe sandbox, e.g. `{"excalidraw.com": "allow-scripts allow-same-origin"}`. Each key matches only that exact host; prefix with `*.` to also cover its subdomains, or use a bare `*` to allow any host | {"*": "allow-scripts allow-same-origin"} |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LANGFUSE_SECRET_KEY | The Langfuse secret key used by the sdk | None |
+2
View File
@@ -3087,6 +3087,8 @@ class ConfigView(drf.views.APIView):
"CONVERSION_UPLOAD_ENABLED",
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_EMBED_BLOCK_ENABLED",
"FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_JS_URL",
"FRONTEND_SILENT_LOGIN_ENABLED",
@@ -29,6 +29,10 @@ pytestmark = pytest.mark.django_db
COLLABORATION_WS_INACTIVITY_TIMEOUT=300,
CONVERSION_UPLOAD_ENABLED=False,
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_EMBED_BLOCK_ENABLED=True,
FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS={
"excalidraw.com": "allow-scripts allow-same-origin",
},
FRONTEND_JS_URL="http://testjs/",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
@@ -63,6 +67,10 @@ def test_api_config(is_authenticated):
"CONVERSION_UPLOAD_ENABLED": False,
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_EMBED_BLOCK_ENABLED": True,
"FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS": {
"excalidraw.com": "allow-scripts allow-same-origin",
},
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_JS_URL": "http://testjs/",
"FRONTEND_SILENT_LOGIN_ENABLED": False,
+22
View File
@@ -554,6 +554,28 @@ class Base(Configuration):
FRONTEND_SILENT_LOGIN_ENABLED = values.BooleanValue(
default=False, environ_name="FRONTEND_SILENT_LOGIN_ENABLED", environ_prefix=None
)
FRONTEND_EMBED_BLOCK_ENABLED = values.BooleanValue(
default=True, environ_name="FRONTEND_EMBED_BLOCK_ENABLED", environ_prefix=None
)
# Allowlist of origins that may be embedded through the "embed" custom block,
# mapping each allowed host to the sandbox attribute to apply to its iframe.
# A URL whose host is not covered here is refused by the frontend.
# Example:
# {
# "grist.numerique.gouv.fr": "allow-scripts",
# "*.numerique.gouv.fr": "allow-scripts allow-same-origin",
# }
FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS = values.DictValue(
default={
"*": (
"allow-scripts allow-same-origin allow-popups "
"allow-popups-to-escape-sandbox allow-forms"
)
},
environ_name="FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS",
environ_prefix=None,
)
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
os.path.join(BASE_DIR, "impress/configuration/theme/default.json"),
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
@@ -3,8 +3,18 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
import {
createDoc,
goToGridDoc,
overrideConfig,
verifyDocName,
} from './utils-common';
import {
getEditor,
openSuggestionMenu,
tryFocusEditorContent,
writeInEditor,
} from './utils-editor';
import { updateShareLink } from './utils-share';
import {
createRootSubPage,
@@ -671,7 +681,7 @@ test.describe('Doc Editor', () => {
await page
.locator('[data-test="embed-input"]')
.fill('http://127.0.0.1:3000/');
.fill('http://localhost:3001/');
await page.locator('[data-test="embed-input-button"]').click();
const embedIframe = page
@@ -680,9 +690,85 @@ test.describe('Doc Editor', () => {
// Check src of embed iframe
expect(await embedIframe.getAttribute('src')).toMatch(
/http:\/\/127.0.0.1:3000\//,
/http:\/\/localhost:3001\//,
);
await expect(embedIframe).toHaveAttribute('role', 'presentation');
});
test('it controls the embeds of a web page with configuration', async ({
page,
browserName,
}) => {
// Disable the embed block feature to test the configuration
await overrideConfig(page, {
FRONTEND_EMBED_BLOCK_ENABLED: false,
});
await createDoc(page, 'doc-embed-web-configuration', browserName, 1);
await tryFocusEditorContent({ page });
await page.keyboard.press('Enter');
await page.keyboard.type('/');
await expect(
page.locator('.bn-suggestion-menu').getByText('Embed a web page'),
).toBeHidden();
await overrideConfig(page, {
FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS: {
'http://localhost:3001': 'allow-scripts',
},
});
await page.reload();
await openSuggestionMenu({ page, suggestion: 'Embed a web page' });
const embedBlock = page.locator('div[data-content-type="embed"]').last();
await expect(embedBlock).toBeVisible();
// Try with a domain that is not allowed first
await page
.getByText(/Add embed/)
.first()
.click();
await page
.locator('[data-test="embed-input"]')
.fill('http://localhost:3002/');
await page.locator('[data-test="embed-input-button"]').click();
await expect(
page.getByText(
'This domain is not allowed for embedding. Please contact your administrator to have it added to the list of allowed domains.',
),
).toBeVisible();
await openSuggestionMenu({ page, suggestion: 'Embed a web page' });
// Now with a valid URL
await page
.getByText(/Add embed/)
.first()
.click();
await page
.locator('[data-test="embed-input"]')
.fill('http://localhost:3001/');
await page.locator('[data-test="embed-input-button"]').click();
const embedIframe = page
.locator('.--docs--editor-container iframe.bn-visual-media')
.first();
// Check src of embed iframe
expect(await embedIframe.getAttribute('src')).toMatch(
/http:\/\/localhost:3001\//,
);
await expect(embedIframe).toHaveAttribute('sandbox', 'allow-scripts');
await expect(embedIframe).toHaveAttribute('role', 'presentation');
});
});
@@ -25,6 +25,10 @@ export const CONFIG = {
CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'],
CONVERSION_FILE_MAX_SIZE: 20971520,
ENVIRONMENT: 'development',
FRONTEND_EMBED_BLOCK_ENABLED: true,
FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS: {
'*': 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms',
},
FRONTEND_CSS_URL: null,
FRONTEND_JS_URL: null,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
@@ -42,6 +42,17 @@ interface ThemeCustomization {
waffle?: LaGaufreV2Props;
}
/**
* Map of an allowed embed host to the iframe `sandbox` attribute to apply.
* Record<host, sandboxAttribute>
* Example:
* {
* "excalidraw.com": "allow-scripts allow-same-origin",
* "www.tldraw.com": "allow-scripts allow-same-origin",
* }
*/
export type EmbedAllowedOrigins = Record<string, string>;
export interface ConfigResponse {
AI_BOT: { name: string; color: string };
AI_FEATURE_ENABLED?: boolean;
@@ -56,6 +67,8 @@ export interface ConfigResponse {
CONVERSION_UPLOAD_ENABLED?: boolean;
ENVIRONMENT: string;
FRONTEND_CSS_URL?: string;
FRONTEND_EMBED_BLOCK_ENABLED?: boolean;
FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS?: EmbedAllowedOrigins;
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
FRONTEND_JS_URL?: string;
FRONTEND_SILENT_LOGIN_ENABLED?: boolean;
@@ -11,6 +11,8 @@ import {
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core';
import {
DocsBlockSchema,
DocsInlineContentSchema,
@@ -45,6 +47,7 @@ export const BlockNoteSuggestionMenu = ({
const dictionaryDate = useDictionary();
const basicBlocksName = dictionaryDate.slash_menu.page_break.group;
const fileBlocksName = dictionaryDate.slash_menu.file.group;
const embedEnabled = useConfig().data?.FRONTEND_EMBED_BLOCK_ENABLED ?? false;
const getInterlinkingMenuItems = useGetInterlinkingMenuItems();
@@ -57,7 +60,9 @@ export const BlockNoteSuggestionMenu = ({
getPageBreakReactSlashMenuItems(editor),
getMultiColumnSlashMenuItems?.(editor) || [],
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
getEmbedReactSlashMenuItems(editor, t, fileBlocksName),
embedEnabled
? getEmbedReactSlashMenuItems(editor, t, fileBlocksName)
: [],
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
aiAllowed && getAISlashMenuItems ? getAISlashMenuItems(editor) : [],
);
@@ -82,6 +87,7 @@ export const BlockNoteSuggestionMenu = ({
fileBlocksName,
basicBlocksName,
aiAllowed,
embedEnabled,
getInterlinkingMenuItems,
]);
@@ -3,9 +3,14 @@
* `<iframe>`. The URL is stored in the collaborative document and rendered on
* every member's machine, so it is treated as untrusted content.
*
* ⚠️ To keep it secure, the block enforces two invariants:
* ⚠️ To keep it secure, the block enforces three invariants:
* 1. The URL must be safe (https, no javascript: or data:).
* 2. The URL must be cross-origin (not same-origin with the app).
* 3. The URL's host must be explicitly allowlisted server-side
* (FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS). Each allowlist entry also carries
* the `sandbox` applied to its iframe, so an embed only gets the
* capabilities the administrator granted to that origin. A host that is not
* listed is refused, and the user is invited to ask an administrator.
*
* Two layers keep it cross-origin, where the
* Same-Origin Policy blocks parent access:
@@ -38,6 +43,7 @@ import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Icon } from '@/components';
import { EmbedAllowedOrigins, useConfig } from '@/core';
import { isSafeUrl } from '@/utils/url';
import { DocsBlockNoteEditor } from '../../types';
@@ -66,6 +72,74 @@ export const isSameOriginUrl = (url: string): boolean => {
}
};
/**
* Returns the `sandbox` to apply for a URL, or `undefined` when the host is not
* covered by the configured origins.
*
* Entry formats (port is always significant):
* - `"example.com"` — exact host, default port only
* - `"example.com:8080"` — exact host, specific port
* - `"*.example.com"` — all subdomains, default port only
* - `"*.example.com:8080"` — all subdomains, specific port
* - `"*"` — catch-all (any host/port)
*
* Priority: exact host > longest `"*."` wildcard > `"*"` catch-all.
* See `matchEmbedOrigin` tests for concrete examples (`./__tests__/EmbedBlock.test.ts`).
*/
export const matchEmbedOrigin = (
url: string,
allowedOrigins: EmbedAllowedOrigins,
): string | undefined => {
let host: string;
try {
host = new URL(url, window.location.origin).host.toLowerCase();
} catch {
return undefined;
}
let bestWildcardMatch: { baseHost: string; sandbox: string } | undefined;
let catchAllSandbox: string | undefined;
for (const [origin, sandbox] of Object.entries(allowedOrigins)) {
let allowedHost = origin.trim().toLowerCase();
if (allowedHost === '*') {
catchAllSandbox = sandbox;
continue;
}
if (allowedHost.includes('://')) {
try {
allowedHost = new URL(allowedHost).host;
} catch {
continue;
}
} else {
allowedHost = allowedHost.split('/')[0];
}
const isWildcard = allowedHost.startsWith('*.');
const baseHost = isWildcard ? allowedHost.slice(2) : allowedHost;
if (!baseHost) {
continue;
}
if (!isWildcard && host === baseHost) {
return sandbox;
}
if (
isWildcard &&
host.endsWith(`.${baseHost}`) &&
(!bestWildcardMatch ||
baseHost.length > bestWildcardMatch.baseHost.length)
) {
bestWildcardMatch = { baseHost, sandbox };
}
}
return bestWildcardMatch?.sandbox ?? catchAllSandbox;
};
type CreateEmbedBlockConfig = BlockConfig<
'embed',
{
@@ -111,13 +185,32 @@ const EmbedBlockComponent = ({ editor, block }: EmbedBlockComponentProps) => {
}
}, [lang, t]);
const isInvalidEmbed =
!!embedUrl && (!isSafeUrl(embedUrl) || isSameOriginUrl(embedUrl));
const conf = useConfig().data;
const allowedOrigins = conf?.FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS ?? {};
if (isInvalidEmbed) {
const isUnsafeEmbed =
!!embedUrl && (!isSafeUrl(embedUrl) || isSameOriginUrl(embedUrl));
const sandbox =
!!embedUrl && !isUnsafeEmbed
? matchEmbedOrigin(embedUrl, allowedOrigins)
: undefined;
const isDisallowedEmbed =
!!embedUrl && !isUnsafeEmbed && sandbox === undefined;
if (isUnsafeEmbed) {
return <CustomBlockStatus>{t('Invalid or unsafe URL.')}</CustomBlockStatus>;
}
if (isDisallowedEmbed) {
return (
<CustomBlockStatus>
{t(
'This domain is not allowed for embedding. Please contact your administrator to have it added to the list of allowed domains.',
)}
</CustomBlockStatus>
);
}
return (
<>
<EmbedBlockStyle />
@@ -137,7 +230,7 @@ const EmbedBlockComponent = ({ editor, block }: EmbedBlockComponentProps) => {
$height="450px"
src={embedUrl}
title={block.props.name || t('Embedded content')}
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms"
sandbox={sandbox ?? ''}
referrerPolicy="no-referrer"
loading="lazy"
contentEditable={false}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { isSameOriginUrl } from '../EmbedBlock';
import { isSameOriginUrl, matchEmbedOrigin } from '../EmbedBlock';
describe('EmbedBlock', () => {
describe('isSameOriginUrl', () => {
@@ -24,8 +24,143 @@ describe('EmbedBlock', () => {
expect(isSameOriginUrl('https://localhost:9999/')).toBe(false);
});
it('returns true for an unparseable URL (treated as unsafe)', () => {
it('returns true for an unparsable URL (treated as unsafe)', () => {
expect(isSameOriginUrl('not a valid url %%')).toBe(true);
});
});
describe('matchEmbedOrigin', () => {
it('returns the sandbox for an exact host match', () => {
expect(
matchEmbedOrigin('https://example.com/page', {
'example.com': 'allow-scripts',
}),
).toBe('allow-scripts');
});
it('returns undefined when the host is not in the allowlist', () => {
expect(
matchEmbedOrigin('https://example.com/', {
'other.com': 'allow-scripts',
}),
).toBeUndefined();
});
it('matches a wildcard entry for a subdomain', () => {
expect(
matchEmbedOrigin('https://docs.numerique.gouv.fr/', {
'*.numerique.gouv.fr': 'allow-scripts allow-same-origin',
}),
).toBe('allow-scripts allow-same-origin');
});
it('does not match the bare base host with a wildcard entry', () => {
expect(
matchEmbedOrigin('https://numerique.gouv.fr/', {
'*.numerique.gouv.fr': 'allow-scripts',
}),
).toBeUndefined();
});
it('prefers an exact host match over a wildcard match', () => {
expect(
matchEmbedOrigin('https://grist.numerique.gouv.fr/', {
'*.numerique.gouv.fr': 'allow-scripts',
'grist.numerique.gouv.fr': 'allow-scripts allow-forms',
}),
).toBe('allow-scripts allow-forms');
});
it('prefers the longest wildcard match when multiple wildcards match', () => {
expect(
matchEmbedOrigin('https://a.b.example.com/', {
'*.example.com': 'sandbox-a',
'*.b.example.com': 'sandbox-b',
}),
).toBe('sandbox-b');
});
it('matches the catch-all "*" entry when no other entry matches', () => {
expect(
matchEmbedOrigin('https://anything.example.com/', {
'*': 'allow-scripts',
}),
).toBe('allow-scripts');
});
it('prefers any specific match over the catch-all "*"', () => {
expect(
matchEmbedOrigin('https://example.com/', {
'*': 'allow-scripts',
'example.com': 'allow-scripts allow-forms',
}),
).toBe('allow-scripts allow-forms');
});
it('accepts origin entries written as full URLs and extracts the hostname', () => {
expect(
matchEmbedOrigin('https://example.com/page', {
'https://example.com/': 'allow-scripts',
}),
).toBe('allow-scripts');
});
it('returns undefined for an unparsable URL', () => {
expect(
matchEmbedOrigin('not a valid url %%', {
'example.com': 'allow-scripts',
}),
).toBeUndefined();
});
it('returns undefined for an empty allowlist', () => {
expect(matchEmbedOrigin('https://example.com/', {})).toBeUndefined();
});
it('does NOT match an exact host entry when the URL has a different port', () => {
expect(
matchEmbedOrigin('https://example.com:8080/page', {
'example.com': 'allow-scripts',
}),
).toBeUndefined();
});
it('matches an exact host entry when the allowlist entry includes the same port', () => {
expect(
matchEmbedOrigin('https://example.com:8080/page', {
'example.com:8080': 'allow-scripts',
}),
).toBe('allow-scripts');
});
it('does NOT match a wildcard entry when the URL has a different port', () => {
expect(
matchEmbedOrigin('https://sub.example.com:8443/', {
'*.example.com': 'allow-scripts',
}),
).toBeUndefined();
});
it('matches a wildcard entry that includes the same port', () => {
expect(
matchEmbedOrigin('https://sub.example.com:8443/', {
'*.example.com:8443': 'allow-scripts',
}),
).toBe('allow-scripts');
});
it('matches the catch-all "*" entry for a URL with a non-standard port', () => {
expect(
matchEmbedOrigin('https://example.com:3000/', { '*': 'allow-scripts' }),
).toBe('allow-scripts');
});
it('allows any https URL and returns the default sandbox', () => {
expect(
matchEmbedOrigin('https://example.com/', {
'*': 'allow-scripts',
}),
).toBe('allow-scripts');
});
});
});