From 0d6538ab1a38fe395827b737570130486f9e14c7 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 18 May 2026 22:02:31 +0100 Subject: [PATCH] feat: iframe configuration --- .env.example | 7 ++++++ apps/server/src/common/helpers/index.ts | 1 + .../src/common/helpers/security-headers.ts | 19 +++++++++++++++ .../environment/environment.service.ts | 15 ++++++++++++ apps/server/src/main.ts | 24 +++++++++++++++++++ 5 files changed, 66 insertions(+) create mode 100644 apps/server/src/common/helpers/security-headers.ts diff --git a/.env.example b/.env.example index b218bdb84..cf2dafc1d 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,13 @@ GOTENBERG_URL= DISABLE_TELEMETRY=false +# Allow other sites to embed Docmost in an iframe. +IFRAME_EMBED_ALLOWED=false + +# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed. +# Example: https://intranet.example.com,https://portal.example.com +IFRAME_ALLOWED_ORIGINS= + # Enable debug logging in production (default: false) DEBUG_MODE=false diff --git a/apps/server/src/common/helpers/index.ts b/apps/server/src/common/helpers/index.ts index 13e44d526..80e9a9028 100644 --- a/apps/server/src/common/helpers/index.ts +++ b/apps/server/src/common/helpers/index.ts @@ -2,3 +2,4 @@ export * from './utils'; export * from './nanoid.utils'; export * from './file.helper'; export * from './constants'; +export * from './security-headers'; diff --git a/apps/server/src/common/helpers/security-headers.ts b/apps/server/src/common/helpers/security-headers.ts new file mode 100644 index 000000000..931300e2d --- /dev/null +++ b/apps/server/src/common/helpers/security-headers.ts @@ -0,0 +1,19 @@ +export type SecurityHeader = { name: string; value: string }; + +export function resolveFrameHeader( + iframeEmbedAllowed: boolean, + allowedOrigins: string[], +): SecurityHeader | null { + if (!iframeEmbedAllowed) { + return { name: 'X-Frame-Options', value: 'SAMEORIGIN' }; + } + + if (allowedOrigins.length === 0) { + return null; + } + + return { + name: 'Content-Security-Policy', + value: `frame-ancestors 'self' ${allowedOrigins.join(' ')}`, + }; +} diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index abee1966f..20824d355 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -325,4 +325,19 @@ export class EnvironmentService { .toLowerCase(); return disabled === 'true'; } + + isIframeEmbedAllowed(): boolean { + const allowed = this.configService + .get('IFRAME_EMBED_ALLOWED', 'false') + .toLowerCase(); + return allowed === 'true'; + } + + getIframeAllowedOrigins(): string[] { + const raw = this.configService.get('IFRAME_ALLOWED_ORIGINS', ''); + return raw + .split(',') + .map((o) => o.trim()) + .filter(Boolean); + } } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 877411223..1c2ccebf1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -12,6 +12,8 @@ import fastifyMultipart from '@fastify/multipart'; import fastifyCookie from '@fastify/cookie'; import fastifyIp from 'fastify-ip'; import { InternalLogFilter } from './common/logger/internal-log-filter'; +import { EnvironmentService } from './integrations/environment/environment.service'; +import { resolveFrameHeader } from './common/helpers'; async function bootstrap() { const app = await NestFactory.create( @@ -50,6 +52,28 @@ async function bootstrap() { await app.register(fastifyMultipart); await app.register(fastifyCookie); + const environmentService = app.get(EnvironmentService); + const frameHeader = resolveFrameHeader( + environmentService.isIframeEmbedAllowed(), + environmentService.getIframeAllowedOrigins(), + ); + if (frameHeader) { + // Skipped routes: + // /api/files/ - attachment controller sets its own CSP we'd overwrite + // /share/ 0 public share pages are safe to embed + const frameHeaderSkippedPrefixes = ['/api/files/', '/share/']; + app + .getHttpAdapter() + .getInstance() + .addHook('onSend', (req, reply, payload, done) => { + if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) { + return done(null, payload); + } + reply.header(frameHeader.name, frameHeader.value); + done(null, payload); + }); + } + app .getHttpAdapter() .getInstance()