mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user