[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 { NodeEntry, ResultSetRowEntry } from '@alfresco/js-api';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { first } from 'rxjs/operators'; import { first } from 'rxjs/operators';
import { AppTestingModule } from '../../../testing/app-testing.module'; import { AppTestingModule } from '../../../testing/app-testing.module';
import { SearchResultsRowComponent } from './search-results-row.component'; import { SearchResultsRowComponent } from './search-results-row.component';
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { UnitTestingUtils } from '@alfresco/adf-core';
@Component({ @Component({
selector: 'aca-datatable-cell-badges', selector: 'aca-datatable-cell-badges',
@@ -42,6 +42,7 @@ class MockDatatableCellBadgesComponent {
describe('SearchResultsRowComponent', () => { describe('SearchResultsRowComponent', () => {
let component: SearchResultsRowComponent; let component: SearchResultsRowComponent;
let fixture: ComponentFixture<SearchResultsRowComponent>; let fixture: ComponentFixture<SearchResultsRowComponent>;
let utils: UnitTestingUtils;
const nodeEntry: NodeEntry = { const nodeEntry: NodeEntry = {
entry: { entry: {
@@ -59,26 +60,17 @@ describe('SearchResultsRowComponent', () => {
modifiedAt: new Date(), modifiedAt: new Date(),
isFile: true, isFile: true,
name: 'Random name', name: 'Random name',
properties: { 'cm:title': 'Random title', 'cm:description': 'some random description' }, properties: {
'cm:title': 'Random title',
'cm:description': 'some random description'
},
search: { search: {
score: 10, score: 10,
highlight: [ highlight: [
{ { field: 'cm:content', snippets: [`Interesting <span class='aca-highlight'>random</span> content`] },
field: 'cm:content', { field: 'cm:name', snippets: [`<span class='aca-highlight'>Random</span>`] },
snippets: [`Interesting <span class='aca-highlight'>random</span> content`] { 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: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,41 +83,37 @@ describe('SearchResultsRowComponent', () => {
fixture = TestBed.createComponent(SearchResultsRowComponent); fixture = TestBed.createComponent(SearchResultsRowComponent);
component = fixture.componentInstance; 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', () => { it('should show the current node', () => {
component.context = { row: { node: nodeEntry } }; component.context = { row: { node: nodeEntry } };
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement.querySelector('div'); expect(utils.getByCSS('div')).not.toBeNull();
expect(element).not.toBeNull();
}); });
it('should correctly parse highlights', (done) => { it('should correctly parse highlights', (done) => {
component.context = { row: { node: resultEntry } }; component.context = { row: { node: resultEntry } };
component.content$ component.content$.pipe(first()).subscribe(() => {
.asObservable()
.pipe(first())
.subscribe(() => {
fixture.detectChanges(); fixture.detectChanges();
const nameElement: HTMLSpanElement = fixture.debugElement.query(By.css('.aca-link.aca-crop-text')).nativeElement; expect(getNameEl().innerHTML).toBe('<span class="aca-highlight">Random</span>');
expect(nameElement.innerHTML).toBe('<span class="aca-highlight">Random</span>'); expect(getNameEl().title).toBe('Random');
expect(nameElement.title).toBe('Random');
const titleElement: HTMLSpanElement = fixture.debugElement.query(By.css('[data-automation-id="search-results-entry-title"]')).nativeElement; expect(getTitleEl().innerHTML).toBe(' ( <span class="aca-highlight">Random</span> title )');
expect(titleElement.innerHTML).toBe(' ( <span class="aca-highlight">Random</span> title )'); expect(getTitleEl().title).toBe('Random title');
expect(titleElement.title).toBe('Random title');
const descriptionElement: HTMLDivElement = fixture.debugElement.query( expect(getDescriptionEl().innerHTML).toBe('some <span class="aca-highlight">random</span> description');
By.css('[data-automation-id="search-results-entry-description"]') expect(getDescriptionEl().title).toBe('some random description');
).nativeElement;
expect(descriptionElement.innerHTML).toBe('some <span class="aca-highlight">random</span> description');
expect(descriptionElement.title).toBe('some random description');
const contentElement: HTMLDivElement = fixture.debugElement.query(By.css('.aca-result-content.aca-crop-text')).nativeElement; expect(getContentEl().innerHTML).toBe('...Interesting <span class="aca-highlight">random</span> content...');
expect(contentElement.innerHTML).toBe('...Interesting <span class="aca-highlight">random</span> content...'); expect(getContentEl().title).toBe('...Interesting random content...');
expect(contentElement.title).toBe('...Interesting random content...');
done(); done();
}); });
fixture.detectChanges(); fixture.detectChanges();
@@ -133,8 +121,58 @@ describe('SearchResultsRowComponent', () => {
it('should pass node to badge component', () => { it('should pass node to badge component', () => {
component.context = { row: { node: nodeEntry } }; 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).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 { export class SearchResultsRowComponent implements OnInit {
private settings = inject(AppSettingsService); private settings = inject(AppSettingsService);
private readonly highlightPrefix = "<span class='aca-highlight'>"; private readonly highlightPrefix = `<span class="aca-highlight">`;
private readonly highlightPostfix = '</span>'; 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; private node: NodeEntry;
@@ -122,17 +134,26 @@ export class SearchResultsRowComponent implements OnInit {
break; break;
} }
}); });
this.name$.next(name);
this.description$.next(description); const safeName = this.sanitizeAndHighlight(name);
this.content$.next(content); 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.nameStripped = this.stripHighlighting(name);
this.titleStripped = this.stripHighlighting(title);
this.descriptionStripped = this.stripHighlighting(description); this.descriptionStripped = this.stripHighlighting(description);
this.contentStripped = this.stripHighlighting(content); this.contentStripped = this.stripHighlighting(content);
if (title !== name) { if (title && title !== name) {
this.title$.next(title ? ` ( ${title} )` : ''); const safeTitle = this.sanitizeAndHighlight(` ( ${title} )`);
this.title$.next(safeTitle);
this.titleStripped = this.stripHighlighting(title); 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)); this.store.dispatch(new NavigateToFolder(this.node));
} }
private stripHighlighting(highlightedContent: string): string { private stripHighlighting(input: string): string {
return highlightedContent if (!input) {
? highlightedContent.replace(new RegExp(this.highlightPrefix, 'g'), '').replace(new RegExp(this.highlightPostfix, 'g'), '') 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;
} }
} }