Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions apps/sim/lib/core/security/url-safety.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 37 additions & 0 deletions apps/sim/lib/core/security/url-safety.ts
Original file line number Diff line number Diff line change
@@ -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')
}
}
2 changes: 1 addition & 1 deletion apps/sim/lib/pptx-renderer/core/viewer.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/pptx-renderer/renderer/shape-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/pptx-renderer/renderer/text-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
16 changes: 0 additions & 16 deletions apps/sim/lib/pptx-renderer/utils/url-safety.test.ts

This file was deleted.

17 changes: 0 additions & 17 deletions apps/sim/lib/pptx-renderer/utils/url-safety.ts

This file was deleted.

Loading