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
- }
-}