Skip to content

[chat] Sanitize image part URLs and share the URL allow-list with markdown - #23058

Merged
hasdfa merged 4 commits into
mui:masterfrom
hasdfa:advisor/001-sanitize-message-part-urls
Aug 4, 2026
Merged

[chat] Sanitize image part URLs and share the URL allow-list with markdown#23058
hasdfa merged 4 commits into
mui:masterfrom
hasdfa:advisor/001-sanitize-message-part-urls

Conversation

@hasdfa

@hasdfa hasdfa commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

This branch originally fixed the unsanitized href sinks flagged in the chat message-part renderers. Those were fixed on master in the meantime by #23187, and after review the remaining protocol-relative change was dropped as well (see below), so what's left is small and carries no security claim:

  1. Image src sinks reuse the guard their sibling href already has. [chat] Normalize message part link URLs #23187 routed every anchor href through safeUri() / safeFileUri() but left the two <img> sinks binding part.url directly. This is consistency, not a live vulnerability — javascript: does not execute in an img src, and data:image/svg+xml cannot run script when loaded as an image.
  2. The scheme allow-list is now shared between markdown and part rendering (@Janpot's suggestion). renderMarkdown's sanitizer had its own copy of ['http:', 'https:', 'mailto:', 'tel:'] plus a "Kept in sync with the headless safeUri" comment; it now calls the already-exported safeUri from @mui/x-chat-headless/internals.

Dropped: protocol-relative rejection

The earlier version of this PR made safeUri reject //host. @Janpot pointed out this buys nothing, and that's right: source-url parts are RAG citations whose purpose is linking out, so safeUri('https://evil.com') is accepted by design. Blocking the protocol-relative spelling of a destination that's allowed via the front door is not a security boundary. That change and its test are gone.

Markdown is a separate case — it rejects //host and its \ variants today, and that behavior is unchanged here. The guard stays local to renderMarkdown rather than moving into safeUri, so the two callers share the allow-list without markdown's stricter policy leaking into part rendering. Whether markdown should keep that policy at all is the same question as above and is left alone in this PR.

Changes

  • packages/x-chat-headless/src/message/parts/FilePart.tsx — the <Preview> src reuses the same safeFileUri() result as the link href (hoisted into a local, since both bindings need it).
  • packages/x-chat-headless/src/message/defaultMessagePartRenderers.tsx — same for renderDefaultFilePart's <img src>.
  • packages/x-chat/src/ChatMessage/renderMarkdown.tsx — sanitizer delegates the scheme allow-list to safeUri; keeps its own /^[/\\]{2}/ protocol-relative guard. No behavior change.
  • Tests — unsafe scheme yields no src on both FilePart and renderDefaultFilePart; markdown coverage for the /\host variant.

safeFileUri is used for the image sinks rather than safeUri so same-origin blob: previews of composer attachments keep working (this was the second Codex comment on the original commit).

Testing

  • pnpm test:unit --project "x-chat" --run — 290 passed, 9 skipped
  • pnpm test:unit --project "x-chat-headless" --run — 651 passed, 24 skipped
  • pnpm --filter "@mui/x-chat*" run typescript — passes
  • pnpm eslint — clean

Route part.url through the existing safeUri() guard in SourceUrlPart
and FilePart before binding it to href/src, closing a DOM XSS vector
where a model/tool could emit javascript: or data: URLs in message
parts. Also closes a gap in safeUri that let protocol-relative //host
URLs through unchecked.
Copilot AI review requested due to automatic review settings July 4, 2026 23:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@code-infra-dashboard

code-infra-dashboard Bot commented Jul 4, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-23058--material-ui-x.netlify.app/
QR code for https://deploy-preview-23058--material-ui-x.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-pro 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-premium 0B(0.00%) 0B(0.00%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-scheduler 0B(0.00%) 0B(0.00%)
@mui/x-scheduler-premium 0B(0.00%) 0B(0.00%)
@mui/x-chat ▼-139B(-0.04%) ▼-69B(-0.08%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14633fbf28

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 30 to 34
if (trimmed.startsWith('//')) {
return '';
}

if (trimmed.startsWith('#') || trimmed.startsWith('/')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject backslash protocol-relative URLs too

For source/file URLs that start with /\, \/, or \\, this check falls through: /\evil.com/x passes the later startsWith('/') branch and \\evil.com/x parses with an https: protocol, but browsers resolve both as https://evil.com/x (the markdown sanitizer rejects these with /^[/\\]{2}/ in renderMarkdown.tsx). This leaves the protocol-relative form this change is trying to neutralize clickable in href/src; reject any two leading slash/backslash characters before accepting same-origin paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — all three resolve to https://evil.com/x and only // was caught. Moot now that the protocol-relative check is dropped, see the thread above.

Still relevant for markdown, where the guard stays: \/host and \\host never reach the sanitizer in that shape because CommonMark unescapes them first, leaving a same-origin path. Only /\host survives intact; covered by a test in f2e6b98216.

ownerState,
});

const href = safeUri(part.url);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve blob URLs from composer attachments

When users send attachments via the built-in composer, useChatComposer creates file parts with url: attachment.previewUrl ?? URL.createObjectURL(attachment.file) (packages/x-chat-headless/src/hooks/useChatComposer.ts:191-196), so those outgoing message parts use blob: URLs. Passing every file URL through safeUri here drops blob: because the allow-list only includes http/https/mailto/tel, leaving uploaded image previews without src and file attachments without href; keep these locally-created blob URLs renderable for file parts or use a file-specific sanitizer.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed on master in cfd7257 (#23187) — safeFileUri preserves same-origin blob URLs. This PR uses it for both the href and the image src, so composer attachment previews keep working.

@zannager zannager added the scope: chat Changes related to the AI chat. label Jul 7, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jul 21, 2026
@mnajdova
mnajdova requested a review from Janpot July 31, 2026 10:03

@mnajdova mnajdova left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can confirm that the reports in defaultMessagePartRenderers are already fixed:

This is the commit: cfd7257

@Janpot Janpot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few questions

// Protocol-relative `//host` resolves to an external origin — reject it
// (matches the markdown sanitizer in renderMarkdown.tsx). Fragments and
// single-slash absolute paths remain safe same-origin references.
if (trimmed.startsWith('//')) {

@Janpot Janpot Jul 31, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels weird, do we accept external origins or not? If yes, then what does it matter it's obtained through a protocol relative URL, if no, then it should be rejected after url parsing and comparing origin with window.location.origin.
I fail to see what rejecting protocol relative urls but not external origin buys us in terms of security.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — dropped in f2e6b98216. Source URL parts are RAG citations that link out by design, so safeUri('https://evil.com') is accepted anyway; rejecting the // spelling of a destination we already allow isn't a boundary. This also moots the Codex comment below about the /\, \/ and \\ variants — they bypassed a check that guarded nothing.

const trimmed = uri.trim();

// Protocol-relative `//host` resolves to an external origin — reject it
// (matches the markdown sanitizer in renderMarkdown.tsx). Fragments and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If they have to align, would it make sense to have markdown and react rendering share an implementation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f2e6b98216. safeUri was already exported from @mui/x-chat-headless/internals and renderMarkdown.tsx already imports from there, so the sanitizer calls it instead of keeping its own protocol list and the "kept in sync" comment.

Markdown still rejects //host and its \ variants. I kept that guard in renderMarkdown rather than moving it into safeUri, so both callers share the allow-list with no behavior change either side. Whether markdown keeps that policy is the same question as your other comment; left alone here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Markdown still rejects //host and its \ variants.

Why?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No good reason. It came from a review finding in #22488, not from a real decision. I kept it only because I did not want to change behavior in a refactor.

Your point works here too. Markdown already allows https://evil.com, so blocking //evil.com gives us nothing.

The sanitizer is now just safeUri(value) || null.

The href sinks flagged in GHSA-g895-hqpf-c7f2 were fixed on master by mui#23187.
Resolution keeps master's safeUri/safeFileUri wiring and retains this branch's
two remaining deltas: safeUri rejects protocol-relative //host, and image src
sinks are sanitized alongside their href.
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 3, 2026
@hasdfa hasdfa changed the title [chat-headless] Sanitize source and file part URLs [chat-headless] Sanitize image part URLs and reject protocol-relative URLs Aug 3, 2026
…kdown

Drops the protocol-relative rejection per review: external origins are
intentionally allowed for source citations, so blocking //host while
accepting https://host buys nothing. Markdown keeps its own stricter
guard and now reuses safeUri for the scheme allow-list.
@hasdfa hasdfa changed the title [chat-headless] Sanitize image part URLs and reject protocol-relative URLs [chat] Sanitize image part URLs and share the URL allow-list with markdown Aug 3, 2026
@hasdfa

hasdfa commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Rebased on master. #23187 landed the href sanitization in the meantime, so this is down to two things: the image src sinks reuse the same guard as their sibling href, and renderMarkdown's sanitizer shares the scheme allow-list with safeUri instead of duplicating it. Protocol-relative rejection is dropped per @Janpot's review, details in the threads.

No security claim left in here — javascript: doesn't execute in an img src. It's consistency cleanup.

@hasdfa hasdfa self-assigned this Aug 3, 2026
@hasdfa hasdfa added the type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. label Aug 3, 2026
Markdown already allows https://evil.com, so rejecting //evil.com added
nothing. The sanitizer is now just safeUri(value) || null, so markdown and
part rendering use one implementation.

@brijeshb42 brijeshb42 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@hasdfa
hasdfa merged commit 205220c into mui:master Aug 4, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: chat Changes related to the AI chat. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants