docs(docs-infra): sanitize markdown tooltip in Code editor

(cherry picked from commit 0f960a5514)
This commit is contained in:
Matthieu Riegler 2026-03-28 01:46:35 +01:00 committed by Pawel Kozlowski
parent 3c41e74fdd
commit 9ea8cb6eea
3 changed files with 72 additions and 10 deletions

View file

@ -19,6 +19,8 @@ import {NodeRuntimeSandbox} from '../node-runtime-sandbox.service';
import {TypingsLoader} from '../typings-loader.service';
import {FileAndContentRecord} from '@angular/docs';
import {DomSanitizer} from '@angular/platform-browser';
import {NodeRuntimeState} from '../node-runtime-state.service';
import {CODE_EDITOR_EXTENSIONS} from './constants/code-editor-extensions';
import {LANGUAGES} from './constants/code-editor-languages';
import {getAutocompleteExtension} from './extensions/autocomplete';
@ -26,10 +28,9 @@ import {getDiagnosticsExtension} from './extensions/diagnostics';
import {getTooltipExtension} from './extensions/tooltip';
import {DiagnosticsState} from './services/diagnostics-state.service';
import {TsVfsWorkerActions} from './workers/enums/actions';
import {TYPESCRIPT_VFS_WORKER_FACTORY} from './workers/factory-provider';
import {CodeChangeRequest} from './workers/interfaces/code-change-request';
import {ActionMessage} from './workers/interfaces/message';
import {NodeRuntimeState} from '../node-runtime-state.service';
import {TYPESCRIPT_VFS_WORKER_FACTORY} from './workers/factory-provider';
export interface EditorFile {
filename: string;
@ -81,6 +82,7 @@ export class CodeMirrorEditor {
private readonly typingsLoader = inject(TypingsLoader);
private readonly destroyRef = inject(DestroyRef);
private readonly diagnosticsState = inject(DiagnosticsState);
private readonly domSanitizer = inject(DomSanitizer);
private readonly tsVfsWorkerFactory = inject(TYPESCRIPT_VFS_WORKER_FACTORY);
private tsVfsWorker: Worker | null = null;
@ -448,7 +450,12 @@ export class CodeMirrorEditor {
this.sendRequestToTsVfs,
this.diagnosticsState,
),
getTooltipExtension(this.eventManager$, this.currentFile, this.sendRequestToTsVfs),
getTooltipExtension(
this.eventManager$,
this.currentFile,
this.sendRequestToTsVfs,
this.domSanitizer,
),
];
}

View file

@ -0,0 +1,41 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {DomSanitizer} from '@angular/platform-browser';
import {TestBed} from '@angular/core/testing';
import {getMarkedHtmlFromString, getTagsHtml} from './tooltip';
describe('getMarkedHtmlFromString', () => {
it('sanitizes markdown HTML content before assigning to innerHTML', () => {
const markdownContent = 'hello <img src=x onerror="alert(1)" />';
const domSanitizer = TestBed.inject(DomSanitizer);
const result = getMarkedHtmlFromString(markdownContent, domSanitizer);
expect(result.innerHTML.trim()).toBe('<p>hello <img src="x"></p>');
expect(result.innerHTML).not.toContain('onerror');
});
});
describe('getTagsHtml', () => {
it('sanitizes JSDoc tag content before assigning to innerHTML', () => {
const tags = [
{
name: 'example',
text: [{text: 'hello <img src=x onerror="alert(1)" />'}],
},
] as any[];
const domSanitizer = TestBed.inject(DomSanitizer);
const result = getTagsHtml(tags, domSanitizer);
expect(result.innerHTML).toContain('@example');
expect(result.innerHTML).not.toContain('onerror');
});
});

View file

@ -6,7 +6,9 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {Signal} from '@angular/core';
import {SecurityContext, Signal} from '@angular/core';
import {DomSanitizer} from '@angular/platform-browser';
import {Tooltip, hoverTooltip} from '@codemirror/view';
import {marked} from 'marked';
import {Subject, filter, take} from 'rxjs';
@ -23,6 +25,7 @@ export const getTooltipExtension = (
emitter: Subject<ActionMessage<DisplayTooltipResponse>>,
currentFile: Signal<EditorFile>,
sendRequestToTsVfs: (request: ActionMessage<DisplayTooltipRequest>) => void,
domSanitizer: DomSanitizer,
) => {
return hoverTooltip(
async (_, pos: number): Promise<Tooltip | null> => {
@ -58,9 +61,9 @@ export const getTooltipExtension = (
// use documentation if available as it's more informative than tags
if (documentation?.[0]?.text) {
tooltip.appendChild(getMarkedHtmlFromString(documentation[0]?.text));
tooltip.appendChild(getMarkedHtmlFromString(documentation[0]?.text, domSanitizer));
} else if (tags?.length) {
tooltip.appendChild(getTagsHtml(tags));
tooltip.appendChild(getTagsHtml(tags, domSanitizer));
}
return {
@ -91,9 +94,13 @@ function forceTooltipScrollTop() {
}
}
function getMarkedHtmlFromString(content: string): HTMLDivElement {
export function getMarkedHtmlFromString(
content: string,
domSanitizer: DomSanitizer,
): HTMLDivElement {
const wrapper = document.createElement('div');
wrapper.innerHTML = marked(content) as string;
const sanitizedHtml = renderAndSanitizeMarkdownToHtml(content, domSanitizer);
wrapper.innerHTML = sanitizedHtml;
return wrapper;
}
@ -123,7 +130,7 @@ function getHtmlFromDisplayParts(displayParts: ts.SymbolDisplayPart[]): HTMLDivE
return wrapper;
}
function getTagsHtml(tags: ts.JSDocTagInfo[]): HTMLDivElement {
export function getTagsHtml(tags: ts.JSDocTagInfo[], domSanitizer: DomSanitizer): HTMLDivElement {
const tagsWrapper = document.createElement('div');
let contentString = '';
@ -138,7 +145,14 @@ function getTagsHtml(tags: ts.JSDocTagInfo[]): HTMLDivElement {
}
}
tagsWrapper.innerHTML = marked(contentString) as string;
const sanitizedHtml = renderAndSanitizeMarkdownToHtml(contentString, domSanitizer);
tagsWrapper.innerHTML = sanitizedHtml;
return tagsWrapper;
}
function renderAndSanitizeMarkdownToHtml(content: string, domSanitizer: DomSanitizer): string {
const markedHtml = marked(content) as string;
const sanitizedHtml = domSanitizer.sanitize(SecurityContext.HTML, markedHtml) ?? '';
return sanitizedHtml;
}