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.
This commit is contained in:
Waleed
2026-07-11 14:14:42 -07:00
committed by GitHub
parent e2e29eed30
commit b73116226a
8 changed files with 103 additions and 36 deletions
@@ -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)
@@ -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,<script>alert(1)</script>')).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,<script>alert(1)</script>', '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')
})
})
+37
View File
@@ -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')
}
}
+1 -1
View File
@@ -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'
@@ -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,
@@ -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'
@@ -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,<script>alert(1)</script>')).toBe(false)
expect(isAllowedExternalUrl('/workspace/files')).toBe(false)
})
})
@@ -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
}
}