[ACS-10116] [ACA] Search page renders HTML from the Description,Title field instead of showing it as plain text (#4784)

This commit is contained in:
dominikiwanekhyland
2025-09-18 14:33:58 +02:00
committed by GitHub
parent 3c26475767
commit f8401f55d5
2 changed files with 126 additions and 54 deletions
@@ -24,11 +24,11 @@
import { NodeEntry, ResultSetRowEntry } from '@alfresco/js-api';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { first } from 'rxjs/operators';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { SearchResultsRowComponent } from './search-results-row.component';
import { Component, Input } from '@angular/core';
import { UnitTestingUtils } from '@alfresco/adf-core';
@Component({
selector: 'aca-datatable-cell-badges',
@@ -42,6 +42,7 @@ class MockDatatableCellBadgesComponent {
describe('SearchResultsRowComponent', () => {
let component: SearchResultsRowComponent;
let fixture: ComponentFixture<SearchResultsRowComponent>;
let utils: UnitTestingUtils;
const nodeEntry: NodeEntry = {
entry: {
@@ -59,26 +60,17 @@ describe('SearchResultsRowComponent', () => {
modifiedAt: new Date(),
isFile: true,
name: 'Random name',
properties: { 'cm:title': 'Random title', 'cm:description': 'some random description' },
properties: {
'cm:title': 'Random title',
'cm:description': 'some random description'
},
search: {
score: 10,
highlight: [
{
field: 'cm:content',
snippets: [`Interesting <span class='aca-highlight'>random</span> content`]
},
{
field: 'cm:name',
snippets: [`<span class='aca-highlight'>Random</span>`]
},
{
field: 'cm:title',
snippets: [`<span class='aca-highlight'>Random</span> title`]
},
{
field: 'cm:description',
snippets: [`some <span class='aca-highlight'>random</span> description`]
}
{ field: 'cm:content', snippets: [`Interesting <span class='aca-highlight'>random</span> content`] },
{ field: 'cm:name', snippets: [`<span class='aca-highlight'>Random</span>`] },
{ field: 'cm:title', snippets: [`<span class='aca-highlight'>Random</span> title`] },
{ field: 'cm:description', snippets: [`some <span class='aca-highlight'>random</span> description`] }
]
}
}
@@ -91,50 +83,96 @@ describe('SearchResultsRowComponent', () => {
fixture = TestBed.createComponent(SearchResultsRowComponent);
component = fixture.componentInstance;
utils = new UnitTestingUtils(fixture.debugElement);
});
const getNameEl = (): HTMLSpanElement => utils.getByCSS('.aca-link.aca-crop-text').nativeElement;
const getTitleEl = (): HTMLSpanElement => utils.getByDataAutomationId('search-results-entry-title').nativeElement;
const getDescriptionEl = (): HTMLDivElement => utils.getByDataAutomationId('search-results-entry-description').nativeElement;
const getContentEl = (): HTMLDivElement => utils.getByCSS('.aca-result-content.aca-crop-text').nativeElement;
it('should show the current node', () => {
component.context = { row: { node: nodeEntry } };
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('div');
expect(element).not.toBeNull();
expect(utils.getByCSS('div')).not.toBeNull();
});
it('should correctly parse highlights', (done) => {
component.context = { row: { node: resultEntry } };
component.content$
.asObservable()
.pipe(first())
.subscribe(() => {
fixture.detectChanges();
component.content$.pipe(first()).subscribe(() => {
fixture.detectChanges();
const nameElement: HTMLSpanElement = fixture.debugElement.query(By.css('.aca-link.aca-crop-text')).nativeElement;
expect(nameElement.innerHTML).toBe('<span class="aca-highlight">Random</span>');
expect(nameElement.title).toBe('Random');
expect(getNameEl().innerHTML).toBe('<span class="aca-highlight">Random</span>');
expect(getNameEl().title).toBe('Random');
const titleElement: HTMLSpanElement = fixture.debugElement.query(By.css('[data-automation-id="search-results-entry-title"]')).nativeElement;
expect(titleElement.innerHTML).toBe(' ( <span class="aca-highlight">Random</span> title )');
expect(titleElement.title).toBe('Random title');
expect(getTitleEl().innerHTML).toBe(' ( <span class="aca-highlight">Random</span> title )');
expect(getTitleEl().title).toBe('Random title');
const descriptionElement: HTMLDivElement = fixture.debugElement.query(
By.css('[data-automation-id="search-results-entry-description"]')
).nativeElement;
expect(descriptionElement.innerHTML).toBe('some <span class="aca-highlight">random</span> description');
expect(descriptionElement.title).toBe('some random description');
expect(getDescriptionEl().innerHTML).toBe('some <span class="aca-highlight">random</span> description');
expect(getDescriptionEl().title).toBe('some random description');
const contentElement: HTMLDivElement = fixture.debugElement.query(By.css('.aca-result-content.aca-crop-text')).nativeElement;
expect(contentElement.innerHTML).toBe('...Interesting <span class="aca-highlight">random</span> content...');
expect(contentElement.title).toBe('...Interesting random content...');
done();
});
expect(getContentEl().innerHTML).toBe('...Interesting <span class="aca-highlight">random</span> content...');
expect(getContentEl().title).toBe('...Interesting random content...');
done();
});
fixture.detectChanges();
});
it('should pass node to badge component', () => {
component.context = { row: { node: nodeEntry } };
const badgeElement = fixture.debugElement.query(By.css('aca-datatable-cell-badges'));
fixture.detectChanges();
const badgeElement = utils.getByCSS('aca-datatable-cell-badges').componentInstance;
expect(badgeElement).not.toBe(null);
expect(badgeElement.componentInstance.node).toBe(component.context.node);
expect(badgeElement.node).toBe(component.context.row.node);
});
it('should escape plain < and > in values', (done) => {
const customEntry: ResultSetRowEntry = {
entry: { ...nodeEntry.entry, name: '2 < 5 > 3', search: { score: 5 } }
} as ResultSetRowEntry;
component.context = { row: { node: customEntry } };
component.name$.pipe(first()).subscribe(() => {
fixture.detectChanges();
expect(getNameEl().innerHTML).toBe('2 &lt; 5 &gt; 3');
expect(getNameEl().textContent).toBe('2 < 5 > 3');
done();
});
fixture.detectChanges();
});
it('should not render script tags as HTML', (done) => {
const customEntry: ResultSetRowEntry = {
entry: { ...nodeEntry.entry, name: '<script>alert("xss")</script>', search: { score: 5 } }
} as ResultSetRowEntry;
component.context = { row: { node: customEntry } };
component.name$.pipe(first()).subscribe(() => {
fixture.detectChanges();
expect(getNameEl().innerHTML).toContain('&lt;script&gt;alert("xss")&lt;/script&gt;');
expect(getNameEl().textContent).toBe('<script>alert("xss")</script>');
done();
});
fixture.detectChanges();
});
it('should allow highlight spans but escape other tags', (done) => {
const customEntry: ResultSetRowEntry = {
entry: { ...nodeEntry.entry, name: '<b><span class="aca-highlight">BoldHighlight</span></b>', search: { score: 5 } }
} as ResultSetRowEntry;
component.context = { row: { node: customEntry } };
component.name$.pipe(first()).subscribe(() => {
fixture.detectChanges();
expect(getNameEl().innerHTML).toBe('&lt;b&gt;<span class="aca-highlight">BoldHighlight</span>&lt;/b&gt;');
expect(getNameEl().textContent).toBe('<b>BoldHighlight</b>');
done();
});
fixture.detectChanges();
});
});
@@ -48,8 +48,20 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class SearchResultsRowComponent implements OnInit {
private settings = inject(AppSettingsService);
private readonly highlightPrefix = "<span class='aca-highlight'>";
private readonly highlightPostfix = '</span>';
private readonly highlightPrefix = `<span class="aca-highlight">`;
private readonly highlightPostfix = `</span>`;
private readonly highlightOpenEscapedRegex = /&lt;span class=(['"])aca-highlight\1&gt;/g;
private readonly highlightCloseEscapedRegex = /&lt;\/span&gt;/g;
private readonly highlightOpenRawRegex = /<span class=(['"])aca-highlight\1>/g;
private readonly highlightCloseRawRegex = /<\/span>/g;
private readonly escapeMap: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;'
};
private node: NodeEntry;
@@ -122,17 +134,26 @@ export class SearchResultsRowComponent implements OnInit {
break;
}
});
this.name$.next(name);
this.description$.next(description);
this.content$.next(content);
const safeName = this.sanitizeAndHighlight(name);
const safeDescription = this.sanitizeAndHighlight(description);
const safeContent = this.sanitizeAndHighlight(content);
this.name$.next(safeName);
this.description$.next(safeDescription);
this.content$.next(safeContent);
this.nameStripped = this.stripHighlighting(name);
this.titleStripped = this.stripHighlighting(title);
this.descriptionStripped = this.stripHighlighting(description);
this.contentStripped = this.stripHighlighting(content);
if (title !== name) {
this.title$.next(title ? ` ( ${title} )` : '');
if (title && title !== name) {
const safeTitle = this.sanitizeAndHighlight(` ( ${title} )`);
this.title$.next(safeTitle);
this.titleStripped = this.stripHighlighting(title);
} else {
this.title$.next('');
}
}
@@ -149,9 +170,22 @@ export class SearchResultsRowComponent implements OnInit {
this.store.dispatch(new NavigateToFolder(this.node));
}
private stripHighlighting(highlightedContent: string): string {
return highlightedContent
? highlightedContent.replace(new RegExp(this.highlightPrefix, 'g'), '').replace(new RegExp(this.highlightPostfix, 'g'), '')
: '';
private stripHighlighting(input: string): string {
if (!input) {
return '';
}
return input.replace(this.highlightOpenRawRegex, '').replace(this.highlightCloseRawRegex, '');
}
private sanitizeAndHighlight(value: string | null | undefined): string {
if (!value) {
return '';
}
let escaped = value.replace(/[&<>]/g, (char) => this.escapeMap[char] ?? char);
escaped = escaped.replace(this.highlightOpenEscapedRegex, this.highlightPrefix).replace(this.highlightCloseEscapedRegex, this.highlightPostfix);
return escaped;
}
}