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
2 changes: 1 addition & 1 deletion src/components/GroundingHtml.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export function GroundingHtml({ html, style }: GroundingHtmlProps) {

scrollEl.addEventListener('wheel', onWheel, { capture: true, passive: false })
return () => scrollEl.removeEventListener('wheel', onWheel, { capture: true })
}, [contentWidth])
}, [])

const captureBubblePress = useCallback((event: SyntheticEvent) => {
stopBubbleScrollCapture(event)
Expand Down
75 changes: 75 additions & 0 deletions src/utils/__tests__/sanitizeGroundingHtml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ describe('stripExecutableGroundingMarkup', () => {
const html = '<scr<script>ipt>alert(1)</scr</script>ipt>'
expect(stripExecutableGroundingMarkup(html)).toBe('')
})

it('removes embed-capable elements', () => {
const html =
'<iframe src="https://evil.example"></iframe><object data="x"></object><embed src="x"><div>ok</div>'
expect(stripExecutableGroundingMarkup(html)).toBe('<div>ok</div>')
})

it('removes resource-loading head tags', () => {
const html =
'<link rel="stylesheet" href="https://evil.example/style.css"><meta http-equiv="refresh" content="0"><base href="https://evil.example/"><div>ok</div>'
expect(stripExecutableGroundingMarkup(html)).toBe('<div>ok</div>')
})
})

describe('sanitizeGroundingHtmlLinksRegex', () => {
Expand All @@ -41,6 +53,55 @@ describe('sanitizeGroundingHtmlLinksRegex', () => {
const html = '<a href="https://example.com">good</a>'
expect(sanitizeGroundingHtmlLinksRegex(html)).toBe('<a href="https://example.com">good</a>')
})

it('removes unsafe img src values', () => {
const html = '<img alt="track" src="javascript:alert(1)">'
expect(sanitizeGroundingHtmlLinksRegex(html)).toBe('<img alt="track">')
})

it('keeps safe http(s) img src values', () => {
const html = '<img alt="icon" src="https://example.com/icon.png">'
expect(sanitizeGroundingHtmlLinksRegex(html)).toBe(
'<img alt="icon" src="https://example.com/icon.png">',
)
})
Comment thread
equationalapplications marked this conversation as resolved.

it('does not match data-href or data-src attribute names', () => {
const html =
'<a data-href="javascript:alert(1)">x</a><img data-src="javascript:alert(2)" alt="y">'
expect(sanitizeGroundingHtmlLinksRegex(html)).toBe(
'<a data-href="javascript:alert(1)">x</a><img data-src="javascript:alert(2)" alt="y">',
)
})

it('removes href when the last duplicate is unsafe', () => {
const html = '<a href="https://example.com" href="javascript:alert(1)">bad</a>'
expect(sanitizeGroundingHtmlLinksRegex(html)).toBe('<a>bad</a>')
})

it('keeps href when the last duplicate is safe', () => {
const html = '<a href="javascript:alert(1)" href="https://example.com">good</a>'
expect(sanitizeGroundingHtmlLinksRegex(html)).toBe(
'<a href="https://example.com">good</a>',
)
})

it('removes src when the last duplicate is unsafe', () => {
const html = '<img alt="x" src="https://example.com/icon.png" src="javascript:alert(1)">'
expect(sanitizeGroundingHtmlLinksRegex(html)).toBe('<img alt="x">')
})

it('keeps src when the last duplicate is safe on self-closing img', () => {
const html = '<img alt="x" src="javascript:alert(1)" src="https://example.com/icon.png" />'
expect(sanitizeGroundingHtmlLinksRegex(html)).toBe(
'<img alt="x" src="https://example.com/icon.png" />',
)
})

it('sanitizes xlink:href on anchor tags', () => {
const html = '<a xlink:href="javascript:alert(1)">bad</a>'
expect(sanitizeGroundingHtmlLinksRegex(html)).toBe('<a>bad</a>')
})
})

describe('sanitizeGroundingHtmlForNative', () => {
Expand All @@ -49,4 +110,18 @@ describe('sanitizeGroundingHtmlForNative', () => {
'<a href="javascript:alert(1)" onclick="alert(2)">x</a><script>alert(3)</script>'
expect(sanitizeGroundingHtmlForNative(html)).toBe('<a>x</a>')
})

it('removes embed-capable elements and external stylesheet links', () => {
const html =
'<link rel="stylesheet" href="https://evil.example/style.css"><iframe src="https://evil.example"></iframe><div class="row">Suggestion</div>'
expect(sanitizeGroundingHtmlForNative(html)).toBe('<div class="row">Suggestion</div>')
})

it('strips base tags and unsafe img src while preserving safe markup', () => {
const html =
'<base href="https://evil.example/"><img src="javascript:alert(1)" alt="x"><img src="https://cdn.example/icon.png" alt="icon"><a href="https://example.com">go</a>'
expect(sanitizeGroundingHtmlForNative(html)).toBe(
'<img alt="x"><img src="https://cdn.example/icon.png" alt="icon"><a href="https://example.com">go</a>',
)
})
})
7 changes: 5 additions & 2 deletions src/utils/groundingShadowContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,11 @@ const GROUNDING_URL_ATTRS = new Set(['href', 'src'])

function isAllowedGroundingAttribute(tagName: string, attributeName: string): boolean {
const lowerName = attributeName.toLowerCase()
if (lowerName.startsWith('on') || lowerName.startsWith('data-') || lowerName.startsWith('aria-')) {
return lowerName.startsWith('aria-') || lowerName.startsWith('data-')
if (lowerName.startsWith('on')) {
return false
}
if (lowerName.startsWith('aria-') || lowerName.startsWith('data-')) {
return true
}
if (GROUNDING_GLOBAL_ALLOWED_ATTRS.has(lowerName)) {
return true
Expand Down
71 changes: 54 additions & 17 deletions src/utils/sanitizeGroundingHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ const SCRIPT_TAG_RE = /<script\b[^<]*(?:(?!<\/script[^>]*>)<[^<]*)*<\/script[^>]
const SCRIPT_SELF_CLOSING_RE = /<script\b[^>]*\/?>/gi
const INLINE_EVENT_HANDLER_RE = /\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi
const ANCHOR_TAG_RE = /<a\b([^>]*)>/gi
const HREF_ATTR_RE = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i
const IMG_TAG_RE = /<img\b([^>]*)>/gi
const HREF_ATTR_RE =
/(?:^|\s)(?:xlink:)?href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi
const SRC_ATTR_RE = /(?:^|\s)src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi

/** Repeat replace until stable — avoids CodeQL incomplete-sanitization bypasses. */
function replaceUntilStable(html: string, pattern: RegExp): string {
Expand All @@ -27,26 +30,60 @@ export function stripExecutableGroundingMarkup(html: string): string {
result,
/<(?:iframe|object|embed|frame|frameset)\b[^>]*>(?:[\s\S]*?<\/(?:iframe|object|embed|frame|frameset)\s*>)?/gi,
)
result = replaceUntilStable(result, /<(?:link|meta)\b[^>]*>/gi)
result = replaceUntilStable(result, /<(?:link|meta|base)\b[^>]*>/gi)
return result
}

/** Strip unsafe hrefs from anchor tags (native path; web uses DOMParser). */
function splitSelfClosing(attrs: string): { body: string; selfClosing: boolean } {
const trimmed = attrs.trim()
const selfClosingMatch = trimmed.match(/^(.*?)\s*\/\s*$/)
if (selfClosingMatch) {
return { body: selfClosingMatch[1].trim(), selfClosing: true }
}
return { body: trimmed, selfClosing: false }
}

function formatTag(tagName: 'a' | 'img', attrs: string, selfClosing = false): string {
const trimmed = attrs.trim()
if (!trimmed) {
return selfClosing ? `<${tagName} />` : `<${tagName}>`
}
return selfClosing ? `<${tagName} ${trimmed} />` : `<${tagName} ${trimmed}>`
}

function stripUnsafeUrlAttribute(
tagName: 'a' | 'img',
attrs: string,
urlAttrRe: RegExp,
): string {
const matches = [...attrs.matchAll(urlAttrRe)]
if (matches.length === 0) {
return `<${tagName}${attrs}>`
}

const lastMatch = matches[matches.length - 1]
const url = lastMatch[1] ?? lastMatch[2] ?? lastMatch[3] ?? ''
const strippedAttrs = attrs.replace(urlAttrRe, '').replace(/\s{2,}/g, ' ').trim()
const { body, selfClosing } = splitSelfClosing(strippedAttrs)

if (!isSafeHttpUrl(url)) {
return formatTag(tagName, body, selfClosing)
}

if (matches.length === 1) {
return `<${tagName}${attrs}>`
}

const safeAttr = lastMatch[0].trim()
const combinedBody = body ? `${body} ${safeAttr}` : safeAttr
return formatTag(tagName, combinedBody, selfClosing)
}

/** Strip unsafe hrefs and img src values (native path; web uses DOMParser). */
export function sanitizeGroundingHtmlLinksRegex(html: string): string {
return html.replace(ANCHOR_TAG_RE, (_, attrs: string) => {
const hrefMatch = attrs.match(HREF_ATTR_RE)
if (!hrefMatch) {
return `<a${attrs}>`
}

const href = hrefMatch[1] ?? hrefMatch[2] ?? hrefMatch[3] ?? ''
if (!isSafeHttpUrl(href)) {
const cleanedAttrs = attrs.replace(HREF_ATTR_RE, '').trim()
return cleanedAttrs ? `<a ${cleanedAttrs}>` : '<a>'
}

return `<a${attrs}>`
})
return html
.replace(ANCHOR_TAG_RE, (_, attrs: string) => stripUnsafeUrlAttribute('a', attrs, HREF_ATTR_RE))
.replace(IMG_TAG_RE, (_, attrs: string) => stripUnsafeUrlAttribute('img', attrs, SRC_ATTR_RE))
}

/** Defense-in-depth sanitization before rendering grounding HTML on native WebViews. */
Expand Down