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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,4 @@ packages/x-markdown/src/XMarkdown/__benchmark__/blob-report/
packages/x-markdown/src/XMarkdown/__benchmark__/playwright/.cache/
packages/x-markdown/src/XMarkdown/__benchmark__/playwright/.auth/
packages/x-markdown/src/XMarkdown/__benchmark__/test-results
.codefuse/codefuse.json
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"ignoreUnknown": true,
"includes": [
"**",
"!**/.dumi/**/*",
"!**/.dumi/tmp*",
"!**/dist/**/*",
"!**/es/**/*",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "x-mono",
"version": "2.7.0",
"version": "2.8.0",
"private": true,
"scripts": {
"presite": "npm run prestart --workspaces",
Expand Down
2 changes: 1 addition & 1 deletion packages/x-card/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ant-design/x-card",
"version": "2.7.0",
"version": "2.8.0",
"description": "React card loader for dynamic content loading and management",
"keywords": [
"A2UI",
Expand Down
2 changes: 1 addition & 1 deletion packages/x-card/src/A2UI/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function validateComponentAgainstCatalog(
const errors: string[] = [];

// If no catalog, pass by default
if (!catalog || !catalog.components) {
if (!catalog?.components) {
return { valid: true, errors: [] };
}

Expand Down
2 changes: 1 addition & 1 deletion packages/x-card/src/version.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// This file is auto generated by npm run version
export default '2.7.0';
export default '2.8.0';
2 changes: 1 addition & 1 deletion packages/x-markdown/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ant-design/x-markdown",
"version": "2.7.0",
"version": "2.8.0",
"scripts": {
"compile": "father build",
"tsc": "tsc --noEmit",
Expand Down
155 changes: 141 additions & 14 deletions packages/x-markdown/src/XMarkdown/__tests__/Renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,20 +1032,28 @@ describe('Renderer', () => {

const renderer = new Renderer({ components });

// Spy on DOMPurify.sanitize
const sanitizeSpy = jest.spyOn(DOMPurify, 'sanitize');
const createElementSpy = jest.spyOn(React, 'createElement');

// Verify that custom tags are preserved and dangerous tags (script) are removed
const html = '<custom-tag>content</custom-tag><script>alert("xss")</script>';
renderer.processHtml(html);

expect(sanitizeSpy).toHaveBeenCalledWith(
html,
// The custom tag should be rendered (via React.createElement)
expect(createElementSpy).toHaveBeenCalledWith(
MockComponent,
expect.objectContaining({
ADD_TAGS: expect.arrayContaining(['custom-tag']),
streamStatus: 'done',
}),
);

sanitizeSpy.mockRestore();
// The script tag should be stripped by DOMPurify — no createElement
// call should have 'alert' or 'script' in its arguments
const scriptCalls = createElementSpy.mock.calls.filter(
(call) => typeof call[0] === 'string' && (call[0] === 'script' || call[0] === 'Script'),
);
expect(scriptCalls).toHaveLength(0);

createElementSpy.mockRestore();
});

it('should respect user dompurifyConfig', () => {
Expand All @@ -1063,22 +1071,30 @@ describe('Renderer', () => {
dompurifyConfig: userConfig,
});

// Spy on DOMPurify.sanitize
const sanitizeSpy = jest.spyOn(DOMPurify, 'sanitize');
const createElementSpy = jest.spyOn(React, 'createElement');

// With ALLOWED_TAGS and ALLOWED_ATTR, only the custom-tag with class
// attribute should be preserved; id attribute should be stripped
const html = '<custom-tag class="test" id="test-id">content</custom-tag>';
renderer.processHtml(html);

expect(sanitizeSpy).toHaveBeenCalledWith(
html,
// The custom tag should be rendered with the class attribute
expect(createElementSpy).toHaveBeenCalledWith(
MockComponent,
expect.objectContaining({
class: 'test',
}),
);

// Verify id attribute is stripped (not in ALLOWED_ATTR)
expect(createElementSpy).not.toHaveBeenCalledWith(
MockComponent,
expect.objectContaining({
ALLOWED_TAGS: expect.arrayContaining(['custom-tag']),
ALLOWED_ATTR: expect.arrayContaining(['class']),
ADD_TAGS: expect.arrayContaining(['custom-tag']),
id: 'test-id',
}),
);

sanitizeSpy.mockRestore();
createElementSpy.mockRestore();
});
});

Expand Down Expand Up @@ -1391,6 +1407,117 @@ describe('Renderer', () => {
});
});

describe('createPatchedDOMPurify', () => {
it('should handle nodeName patch fallback for various node types', () => {
// Test that Renderer works correctly with patched DOMPurify
const components = {
'test-component': MockComponent,
};

const renderer = new Renderer({ components });
const createElementSpy = jest.spyOn(React, 'createElement');

const html = '<test-component>content</test-component>';
renderer.processHtml(html);

// Verify the component was rendered
expect(createElementSpy).toHaveBeenCalledWith(
MockComponent,
expect.objectContaining({
streamStatus: 'done',
}),
);

createElementSpy.mockRestore();
});

it('should handle DOMPurify with custom config', () => {
const components = {
'custom-element': MockComponent,
};

const renderer = new Renderer({
components,
dompurifyConfig: {
ALLOWED_TAGS: ['custom-element'],
ALLOWED_ATTR: ['data-custom'],
},
});

const createElementSpy = jest.spyOn(React, 'createElement');

const html = '<custom-element data-custom="value">content</custom-element>';
renderer.processHtml(html);

expect(createElementSpy).toHaveBeenCalledWith(
MockComponent,
expect.objectContaining({
'data-custom': 'value',
streamStatus: 'done',
}),
);

createElementSpy.mockRestore();
});

it('should process HTML with text nodes', () => {
const components = {};
const renderer = new Renderer({ components });

// Simple HTML with just text
const html = '<p>Plain text content</p>';
const result = renderer.processHtml(html);

expect(result).toBeDefined();
});

it('should handle empty or invalid HTML gracefully', () => {
const components = {};
const renderer = new Renderer({ components });

// Empty string - should not throw
expect(() => renderer.render('')).not.toThrow();

// Whitespace only - should not throw
expect(() => renderer.render(' ')).not.toThrow();
});

it('should handle HTML with comments', () => {
const components = {};
const renderer = new Renderer({ components });

const html = '<!-- comment --><p>content</p>';
const result = renderer.processHtml(html);

expect(result).toBeDefined();
});

it('should handle streaming mode with animation', () => {
const createElementSpy = jest.spyOn(React, 'createElement');

// Use a custom component to avoid AnimationText being skipped due to parent custom component check
const CustomComponent: React.FC<any> = (props) => React.createElement('div', props);
const rendererWithComponent = new Renderer({
components: { 'custom-wrapper': CustomComponent },
streaming: {
enableAnimation: true,
animationConfig: {
fadeDuration: 100,
easing: 'ease-in',
},
},
});

const html = '<custom-wrapper>Animated text content</custom-wrapper>';
rendererWithComponent.processHtml(html);

// Verify that createElement was called (meaning the renderer processed the HTML)
expect(createElementSpy).toHaveBeenCalled();

createElementSpy.mockRestore();
});
});

describe('SSR / no DOM safety', () => {
it('returns null from processHtml when DOMPurify.sanitize is unavailable', () => {
const renderer = new Renderer({ components: { 'custom-tag': MockComponent } });
Expand Down
2 changes: 1 addition & 1 deletion packages/x-markdown/src/XMarkdown/core/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ class Parser {

// If the last token is not text type, don't inject tail
// This prevents tail from appearing before incomplete components
if (!lastNonEmptyToken || lastNonEmptyToken.type !== 'text') {
if (lastNonEmptyToken?.type !== 'text') {
return null;
}

Expand Down
132 changes: 131 additions & 1 deletion packages/x-markdown/src/XMarkdown/core/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,132 @@ interface RendererOptions {
streaming?: XMarkdownProps['streaming'];
}

/**
* Fix for DOMPurify 3.x in environments (e.g., happy-dom) where the cached
* Node.prototype getters return incorrect values for elements created in a
* different document context (the template content owner document).
*
* DOMPurify 3.x caches property getters from Node.prototype at module
* initialization time:
* const getNodeName = lookupGetter(Node.prototype, 'nodeName');
* const getNodeValue = lookupGetter(Node.prototype, 'nodeValue');
*
* In happy-dom, calling these cached getters on elements from the template
* content owner document returns empty string / null respectively, while
* direct property access returns the correct value. This causes DOMPurify
* to compute empty tagNames and strip nearly all elements from the output.
*
* The fix patches Node.prototype.nodeName and Node.prototype.nodeValue with
* safe fallbacks that use direct property access when the cached getter
* returns a falsy value. Then a fresh DOMPurify instance is created that
* picks up the patched getters.
*
* This is idempotent — calling it multiple times has no additional effect.
*/
let patchedDOMPurify: typeof DOMPurify | null = null;

function createPatchedDOMPurify(): typeof DOMPurify {
if (patchedDOMPurify) return patchedDOMPurify;

if (typeof window !== 'undefined' && typeof Node !== 'undefined') {
// Detect if the Node.prototype.nodeName getter is broken for template
// content elements (the happy-dom bug).
try {
const template = document.createElement('template');
template.innerHTML = '<test-detect></test-detect>';
const testEl = template.content.firstChild;
if (testEl) {
const nodeNameGetter = Object.getOwnPropertyDescriptor(Node.prototype, 'nodeName')?.get;
const nodeValueGetter = Object.getOwnPropertyDescriptor(Node.prototype, 'nodeValue')?.get;

// If the cached getter returns empty/falsy for an element that has
// a valid nodeName via direct access, we need to patch.
const needsNodeNamePatch =
nodeNameGetter && testEl.nodeName && !nodeNameGetter.call(testEl);

if (needsNodeNamePatch) {
const originalNodeNameGet = nodeNameGetter;
Object.defineProperty(Node.prototype, 'nodeName', {
get: function (this: Node) {
const value = originalNodeNameGet!.call(this);
if (value) return value;
// Fallback: for Element nodes, use tagName directly
if (this.nodeType === 1 && 'tagName' in this) {
return (this as unknown as { tagName: string }).tagName;
}
// Fallback: standard nodeName values for other node types
switch (this.nodeType) {
case 3:
return '#text';
case 4:
return '#cdata-section';
case 7:
return '#processing-instruction';
case 8:
return '#comment';
case 9:
return '#document';
case 11:
return '#document-fragment';
}
return value;
},
configurable: true,
enumerable: true,
});
}

// Check if nodeValue getter is also broken for text nodes
const needsNodeValuePatch =
nodeValueGetter &&
(() => {
template.innerHTML = '<div>test</div>';
const textNode = template.content.firstChild?.firstChild;
if (!textNode) return false;
const viaGetter = nodeValueGetter.call(textNode);
const viaDirect = textNode.nodeValue;
// getter returns null/undefined but direct access returns the value
return viaDirect != null && viaGetter == null;
})();

if (needsNodeValuePatch) {
const originalNodeValueGet = nodeValueGetter;
Object.defineProperty(Node.prototype, 'nodeValue', {
get: function (this: Node) {
const value = originalNodeValueGet!.call(this);
if (value !== null && value !== undefined) return value;
// For CharacterData nodes (text, comment, CDATA), fall back to
// the .data property which stores the text content in happy-dom.
if (
(this.nodeType === 3 || this.nodeType === 4 || this.nodeType === 8) &&
'data' in this
) {
return (this as CharacterData).data;
}
return value;
},
configurable: true,
enumerable: true,
});
}

if (needsNodeNamePatch || needsNodeValuePatch) {
// Create a fresh DOMPurify instance that will re-read (and cache)
// the patched getters from Node.prototype.
patchedDOMPurify = DOMPurify(window);
return patchedDOMPurify;
}
}
} catch {
// If detection fails (e.g., in SSR), fall through to default DOMPurify
}
}

// No patching needed — use the default exported instance
patchedDOMPurify = DOMPurify;
return patchedDOMPurify;
}

class Renderer {
private readonly options: RendererOptions;
private static readonly NON_WHITESPACE_REGEX = /[^\r\n\s]+/;
Expand Down Expand Up @@ -131,9 +257,13 @@ class Renderer {
const unclosedTags = this.detectUnclosedTags(htmlString);
const cidRef = { current: 0, tagIndexes: {} };

// Get a DOMPurify instance that works correctly in environments where
// Node.prototype getters are broken for template content elements.
const purify = createPatchedDOMPurify();

// Use DOMPurify to clean HTML while preserving custom components and target attributes
const purifyConfig = this.configureDOMPurify();
const cleanHtml = DOMPurify.sanitize(htmlString, purifyConfig);
const cleanHtml = purify.sanitize(htmlString, purifyConfig);

return parseHtml(cleanHtml, {
replace: this.createReplaceElement(unclosedTags, cidRef),
Expand Down
Loading
Loading