Skip to content

Commit b731162

Browse files
authored
fix(file-viewer): sanitize docx hyperlink hrefs to block javascript: XSS (#5599)
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.
1 parent e2e29ee commit b731162

8 files changed

Lines changed: 103 additions & 36 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'
44
import { cn } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import { toError } from '@sim/utils/errors'
7+
import { sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety'
78
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
89
import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
910
import { PreviewToolbar } from './preview-toolbar'
@@ -209,6 +210,7 @@ export const DocxPreview = memo(function DocxPreview({
209210
ignoreHeight: false,
210211
})
211212
if (!cancelled && containerRef.current) {
213+
sanitizeRenderedHyperlinks(containerRef.current)
212214
applyPostRenderStyling()
213215
setHasRenderedPreview(true)
214216
setDocumentRenderVersion((version) => version + 1)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { isAllowedExternalUrl, sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety'
6+
7+
describe('isAllowedExternalUrl', () => {
8+
it('allows http, https, and mailto URLs', () => {
9+
expect(isAllowedExternalUrl('https://example.com/deck')).toBe(true)
10+
expect(isAllowedExternalUrl('http://example.com/deck')).toBe(true)
11+
expect(isAllowedExternalUrl('mailto:support@example.com')).toBe(true)
12+
})
13+
14+
it('rejects scriptable, data, and relative URLs', () => {
15+
expect(isAllowedExternalUrl('javascript:alert(1)')).toBe(false)
16+
expect(isAllowedExternalUrl('data:text/html,<script>alert(1)</script>')).toBe(false)
17+
expect(isAllowedExternalUrl('/workspace/files')).toBe(false)
18+
})
19+
})
20+
21+
describe('sanitizeRenderedHyperlinks', () => {
22+
function containerWithAnchor(href: string): HTMLDivElement {
23+
const container = document.createElement('div')
24+
const anchor = document.createElement('a')
25+
anchor.setAttribute('href', href)
26+
anchor.textContent = 'link'
27+
container.appendChild(anchor)
28+
return container
29+
}
30+
31+
it('strips javascript: hrefs from a docx-preview hyperlink rendering', () => {
32+
const container = containerWithAnchor(
33+
"javascript:document.body.setAttribute('data-xss-fired','1')"
34+
)
35+
sanitizeRenderedHyperlinks(container)
36+
const anchor = container.querySelector('a')
37+
expect(anchor?.hasAttribute('href')).toBe(false)
38+
})
39+
40+
it('strips data: and vbscript: hrefs', () => {
41+
for (const href of ['data:text/html,<script>alert(1)</script>', 'vbscript:msgbox(1)']) {
42+
const container = containerWithAnchor(href)
43+
sanitizeRenderedHyperlinks(container)
44+
expect(container.querySelector('a')?.hasAttribute('href')).toBe(false)
45+
}
46+
})
47+
48+
it('preserves same-document bookmark anchors', () => {
49+
const container = containerWithAnchor('#section-2')
50+
sanitizeRenderedHyperlinks(container)
51+
expect(container.querySelector('a')?.getAttribute('href')).toBe('#section-2')
52+
})
53+
54+
it('keeps allowed external links and adds rel=noopener noreferrer', () => {
55+
const container = containerWithAnchor('https://example.com/report')
56+
sanitizeRenderedHyperlinks(container)
57+
const anchor = container.querySelector('a')
58+
expect(anchor?.getAttribute('href')).toBe('https://example.com/report')
59+
expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer')
60+
})
61+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* URL safety utilities for external hyperlinks/media in untrusted document content
3+
* (PPTX, DOCX, and other previews rendered into the app origin).
4+
*/
5+
6+
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
7+
8+
/**
9+
* Returns true only for absolute URLs with an allowed protocol.
10+
*/
11+
export function isAllowedExternalUrl(url: string): boolean {
12+
try {
13+
const parsed = new URL(url)
14+
return ALLOWED_PROTOCOLS.has(parsed.protocol.toLowerCase())
15+
} catch {
16+
return false
17+
}
18+
}
19+
20+
/**
21+
* Neutralizes anchors rendered from untrusted document content (e.g. docx-preview,
22+
* which copies an external-relationship `Target` straight into `href` with no scheme
23+
* check). Same-document fragment links (`#bookmark`) are left intact; anything else
24+
* that isn't http/https/mailto has its `href` stripped so the anchor can't navigate.
25+
* Surviving external links get `rel="noopener noreferrer"` to block tabnabbing.
26+
*/
27+
export function sanitizeRenderedHyperlinks(root: ParentNode): void {
28+
for (const anchor of root.querySelectorAll('a[href]')) {
29+
const href = anchor.getAttribute('href') ?? ''
30+
if (href.startsWith('#')) continue
31+
if (isAllowedExternalUrl(href)) {
32+
anchor.setAttribute('rel', 'noopener noreferrer')
33+
continue
34+
}
35+
anchor.removeAttribute('href')
36+
}
37+
}

apps/sim/lib/pptx-renderer/core/viewer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { getErrorMessage } from '@sim/utils/errors'
22
import type { ECharts } from 'echarts'
3+
import { isAllowedExternalUrl } from '@/lib/core/security/url-safety'
34
import { buildPresentation, type PresentationData } from '../model/presentation'
45
import type { ZipParseLimits } from '../parser/zip-parser'
56
import { parseZip } from '../parser/zip-parser'
67
import type { SlideHandle } from '../renderer/slide-renderer'
78
import { renderSlide as renderSlideInternal } from '../renderer/slide-renderer'
8-
import { isAllowedExternalUrl } from '../utils/url-safety'
99

1010
export type { SlideHandle } from '../renderer/slide-renderer'
1111

apps/sim/lib/pptx-renderer/renderer/shape-renderer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function hasVisibleText(textBody: TextBody): boolean {
1515
return false
1616
}
1717

18+
import { isAllowedExternalUrl } from '@/lib/core/security/url-safety'
1819
import { emuToPx } from '../parser/units'
1920
import type { SafeXmlNode } from '../parser/xml-parser'
2021
import { renderCustomGeometry } from '../shapes/custom-geometry'
@@ -26,7 +27,6 @@ import {
2627
} from '../shapes/presets'
2728
import { applyTint, hexToRgb, rgbToHex } from '../utils/color'
2829
import { getOrCreateBlobUrl, resolveMediaPath } from '../utils/media'
29-
import { isAllowedExternalUrl } from '../utils/url-safety'
3030
import {
3131
resolveColor,
3232
resolveColorToCss,

apps/sim/lib/pptx-renderer/renderer/text-renderer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
*/
55

66
import { hexToRgb, toCssColor } from '@/lib/colors'
7+
import { isAllowedExternalUrl } from '@/lib/core/security/url-safety'
78
import type { PlaceholderInfo } from '../model/nodes/base-node'
89
import type { TextBody } from '../model/nodes/shape-node'
910
import { angleToDeg, emuToPx, pctToDecimal } from '../parser/units'
1011
import { SafeXmlNode } from '../parser/xml-parser'
11-
import { isAllowedExternalUrl } from '../utils/url-safety'
1212
import type { RenderContext } from './render-context'
1313
import { resolveColor, resolveColorToCss } from './style-resolver'
1414

apps/sim/lib/pptx-renderer/utils/url-safety.test.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.

apps/sim/lib/pptx-renderer/utils/url-safety.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)