From 4c701182464c695e1f98ff39b8879df01250a60e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 11:26:16 -0700 Subject: [PATCH] fix(file-viewer): sanitize docx hyperlink hrefs to block javascript: XSS Sanitize anchor hrefs rendered by docx-preview after render, stripping any scheme outside http/https/mailto (same allowlist already used by the PPTX renderer). Covers both the workspace file viewer and the unauthenticated public share page, which reuse the same component. --- .../components/file-viewer/docx-preview.tsx | 2 + apps/sim/lib/core/security/url-safety.test.ts | 61 +++++++++++++++++++ apps/sim/lib/core/security/url-safety.ts | 37 +++++++++++ apps/sim/lib/pptx-renderer/core/viewer.ts | 2 +- .../pptx-renderer/renderer/shape-renderer.ts | 2 +- .../pptx-renderer/renderer/text-renderer.ts | 2 +- .../pptx-renderer/utils/url-safety.test.ts | 16 ----- .../sim/lib/pptx-renderer/utils/url-safety.ts | 17 ------ 8 files changed, 103 insertions(+), 36 deletions(-) create mode 100644 apps/sim/lib/core/security/url-safety.test.ts create mode 100644 apps/sim/lib/core/security/url-safety.ts delete mode 100644 apps/sim/lib/pptx-renderer/utils/url-safety.test.ts delete mode 100644 apps/sim/lib/pptx-renderer/utils/url-safety.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index 6f1e8f75df0..3ff4df51c2c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared' import { PreviewToolbar } from './preview-toolbar' @@ -209,6 +210,7 @@ export const DocxPreview = memo(function DocxPreview({ ignoreHeight: false, }) if (!cancelled && containerRef.current) { + sanitizeRenderedHyperlinks(containerRef.current) applyPostRenderStyling() setHasRenderedPreview(true) setDocumentRenderVersion((version) => version + 1) diff --git a/apps/sim/lib/core/security/url-safety.test.ts b/apps/sim/lib/core/security/url-safety.test.ts new file mode 100644 index 00000000000..122dc22b8ec --- /dev/null +++ b/apps/sim/lib/core/security/url-safety.test.ts @@ -0,0 +1,61 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { isAllowedExternalUrl, sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety' + +describe('isAllowedExternalUrl', () => { + it('allows http, https, and mailto URLs', () => { + expect(isAllowedExternalUrl('https://example.com/deck')).toBe(true) + expect(isAllowedExternalUrl('http://example.com/deck')).toBe(true) + expect(isAllowedExternalUrl('mailto:support@example.com')).toBe(true) + }) + + it('rejects scriptable, data, and relative URLs', () => { + expect(isAllowedExternalUrl('javascript:alert(1)')).toBe(false) + expect(isAllowedExternalUrl('data:text/html,')).toBe(false) + expect(isAllowedExternalUrl('/workspace/files')).toBe(false) + }) +}) + +describe('sanitizeRenderedHyperlinks', () => { + function containerWithAnchor(href: string): HTMLDivElement { + const container = document.createElement('div') + const anchor = document.createElement('a') + anchor.setAttribute('href', href) + anchor.textContent = 'link' + container.appendChild(anchor) + return container + } + + it('strips javascript: hrefs from a docx-preview hyperlink rendering', () => { + const container = containerWithAnchor( + "javascript:document.body.setAttribute('data-xss-fired','1')" + ) + sanitizeRenderedHyperlinks(container) + const anchor = container.querySelector('a') + expect(anchor?.hasAttribute('href')).toBe(false) + }) + + it('strips data: and vbscript: hrefs', () => { + for (const href of ['data:text/html,', 'vbscript:msgbox(1)']) { + const container = containerWithAnchor(href) + sanitizeRenderedHyperlinks(container) + expect(container.querySelector('a')?.hasAttribute('href')).toBe(false) + } + }) + + it('preserves same-document bookmark anchors', () => { + const container = containerWithAnchor('#section-2') + sanitizeRenderedHyperlinks(container) + expect(container.querySelector('a')?.getAttribute('href')).toBe('#section-2') + }) + + it('keeps allowed external links and adds rel=noopener noreferrer', () => { + const container = containerWithAnchor('https://example.com/report') + sanitizeRenderedHyperlinks(container) + const anchor = container.querySelector('a') + expect(anchor?.getAttribute('href')).toBe('https://example.com/report') + expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer') + }) +}) diff --git a/apps/sim/lib/core/security/url-safety.ts b/apps/sim/lib/core/security/url-safety.ts new file mode 100644 index 00000000000..694ad516310 --- /dev/null +++ b/apps/sim/lib/core/security/url-safety.ts @@ -0,0 +1,37 @@ +/** + * URL safety utilities for external hyperlinks/media in untrusted document content + * (PPTX, DOCX, and other previews rendered into the app origin). + */ + +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']) + +/** + * Returns true only for absolute URLs with an allowed protocol. + */ +export function isAllowedExternalUrl(url: string): boolean { + try { + const parsed = new URL(url) + return ALLOWED_PROTOCOLS.has(parsed.protocol.toLowerCase()) + } catch { + return false + } +} + +/** + * Neutralizes anchors rendered from untrusted document content (e.g. docx-preview, + * which copies an external-relationship `Target` straight into `href` with no scheme + * check). Same-document fragment links (`#bookmark`) are left intact; anything else + * that isn't http/https/mailto has its `href` stripped so the anchor can't navigate. + * Surviving external links get `rel="noopener noreferrer"` to block tabnabbing. + */ +export function sanitizeRenderedHyperlinks(root: ParentNode): void { + for (const anchor of root.querySelectorAll('a[href]')) { + const href = anchor.getAttribute('href') ?? '' + if (href.startsWith('#')) continue + if (isAllowedExternalUrl(href)) { + anchor.setAttribute('rel', 'noopener noreferrer') + continue + } + anchor.removeAttribute('href') + } +} diff --git a/apps/sim/lib/pptx-renderer/core/viewer.ts b/apps/sim/lib/pptx-renderer/core/viewer.ts index efdb5ac5e02..3434a8ba225 100644 --- a/apps/sim/lib/pptx-renderer/core/viewer.ts +++ b/apps/sim/lib/pptx-renderer/core/viewer.ts @@ -1,11 +1,11 @@ import { getErrorMessage } from '@sim/utils/errors' import type { ECharts } from 'echarts' +import { isAllowedExternalUrl } from '@/lib/core/security/url-safety' import { buildPresentation, type PresentationData } from '../model/presentation' import type { ZipParseLimits } from '../parser/zip-parser' import { parseZip } from '../parser/zip-parser' import type { SlideHandle } from '../renderer/slide-renderer' import { renderSlide as renderSlideInternal } from '../renderer/slide-renderer' -import { isAllowedExternalUrl } from '../utils/url-safety' export type { SlideHandle } from '../renderer/slide-renderer' diff --git a/apps/sim/lib/pptx-renderer/renderer/shape-renderer.ts b/apps/sim/lib/pptx-renderer/renderer/shape-renderer.ts index 58ed7f9c70d..db73643b419 100644 --- a/apps/sim/lib/pptx-renderer/renderer/shape-renderer.ts +++ b/apps/sim/lib/pptx-renderer/renderer/shape-renderer.ts @@ -15,6 +15,7 @@ function hasVisibleText(textBody: TextBody): boolean { return false } +import { isAllowedExternalUrl } from '@/lib/core/security/url-safety' import { emuToPx } from '../parser/units' import type { SafeXmlNode } from '../parser/xml-parser' import { renderCustomGeometry } from '../shapes/custom-geometry' @@ -26,7 +27,6 @@ import { } from '../shapes/presets' import { applyTint, hexToRgb, rgbToHex } from '../utils/color' import { getOrCreateBlobUrl, resolveMediaPath } from '../utils/media' -import { isAllowedExternalUrl } from '../utils/url-safety' import { resolveColor, resolveColorToCss, diff --git a/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts b/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts index e66a802bfcd..42679dcf9e3 100644 --- a/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts +++ b/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts @@ -4,11 +4,11 @@ */ import { hexToRgb, toCssColor } from '@/lib/colors' +import { isAllowedExternalUrl } from '@/lib/core/security/url-safety' import type { PlaceholderInfo } from '../model/nodes/base-node' import type { TextBody } from '../model/nodes/shape-node' import { angleToDeg, emuToPx, pctToDecimal } from '../parser/units' import { SafeXmlNode } from '../parser/xml-parser' -import { isAllowedExternalUrl } from '../utils/url-safety' import type { RenderContext } from './render-context' import { resolveColor, resolveColorToCss } from './style-resolver' diff --git a/apps/sim/lib/pptx-renderer/utils/url-safety.test.ts b/apps/sim/lib/pptx-renderer/utils/url-safety.test.ts deleted file mode 100644 index 793ae18ea5b..00000000000 --- a/apps/sim/lib/pptx-renderer/utils/url-safety.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isAllowedExternalUrl } from '@/lib/pptx-renderer/utils/url-safety' - -describe('isAllowedExternalUrl', () => { - it('allows http, https, and mailto URLs', () => { - expect(isAllowedExternalUrl('https://example.com/deck')).toBe(true) - expect(isAllowedExternalUrl('http://example.com/deck')).toBe(true) - expect(isAllowedExternalUrl('mailto:support@example.com')).toBe(true) - }) - - it('rejects scriptable, data, and relative URLs', () => { - expect(isAllowedExternalUrl('javascript:alert(1)')).toBe(false) - expect(isAllowedExternalUrl('data:text/html,')).toBe(false) - expect(isAllowedExternalUrl('/workspace/files')).toBe(false) - }) -}) diff --git a/apps/sim/lib/pptx-renderer/utils/url-safety.ts b/apps/sim/lib/pptx-renderer/utils/url-safety.ts deleted file mode 100644 index c3e8f42b2de..00000000000 --- a/apps/sim/lib/pptx-renderer/utils/url-safety.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * URL safety utilities for external hyperlinks/media in untrusted PPTX content. - */ - -const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']) - -/** - * Returns true only for absolute URLs with an allowed protocol. - */ -export function isAllowedExternalUrl(url: string): boolean { - try { - const parsed = new URL(url) - return ALLOWED_PROTOCOLS.has(parsed.protocol.toLowerCase()) - } catch { - return false - } -}