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
2 changes: 2 additions & 0 deletions projects/ui/src/lib/components/po-helper/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './po-helper.module';
export * from './po-helper.component';
export * from './interfaces/po-helper.interface';
export * from './po-helper-content-utils';
export * from './po-helper-content.pipe';
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
/**
* @usedBy PoHelperComponent
* @usedBy PoHelperComponent, PoPageDefaultComponent
*
* @description
*
* *Interface* que define as opções de configuração do componente po-helper.
*
* Permite customizar o conteúdo, título, tipo do ícone, modo de abertura do popover, ações customizadas e eventos.
*
* Interface para configuração das opções de ajuda (*helper*).
*/
export interface PoHelperOptions {
/**
Expand All @@ -26,6 +23,14 @@ export interface PoHelperOptions {
* @description
*
* Texto explicativo exibido no popover.
*
* Suporta formatação básica com as tags `<b>` (negrito), `<strong>` (negrito), `<i>` (itálico), `<em>` (itálico) e
* `<u>` (sublinhado).
*
* Exemplo:
* ```typescript
* content: 'Texto <b>importante</b> com <em>destaque</em> e <u>sublinhado</u>'
* ```
*/
content?: string;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { parseHelperContent } from './po-helper-content-utils';

describe('PoHelperSafeParser', () => {
describe('parseHelperContent', () => {
it('should return empty array for null/undefined/empty content', () => {
expect(parseHelperContent(null)).toEqual([]);
expect(parseHelperContent(undefined)).toEqual([]);
expect(parseHelperContent('')).toEqual([]);
});

it('should return single fragment for plain text without tags', () => {
const result = parseHelperContent('Texto simples');
expect(result).toEqual([{ text: 'Texto simples', bold: false, italic: false, underline: false }]);
});

it('should parse bold tag correctly', () => {
const result = parseHelperContent('Texto <b>negrito</b> normal');
expect(result).toEqual([
{ text: 'Texto ', bold: false, italic: false, underline: false },
{ text: 'negrito', bold: true, italic: false, underline: false },
{ text: ' normal', bold: false, italic: false, underline: false }
]);
});

it('should parse italic tag correctly', () => {
const result = parseHelperContent('Texto <i>itálico</i> normal');
expect(result).toEqual([
{ text: 'Texto ', bold: false, italic: false, underline: false },
{ text: 'itálico', bold: false, italic: true, underline: false },
{ text: ' normal', bold: false, italic: false, underline: false }
]);
});

it('should parse underline tag correctly', () => {
const result = parseHelperContent('Texto <u>sublinhado</u> normal');
expect(result).toEqual([
{ text: 'Texto ', bold: false, italic: false, underline: false },
{ text: 'sublinhado', bold: false, italic: false, underline: true },
{ text: ' normal', bold: false, italic: false, underline: false }
]);
});

it('should parse nested tags correctly', () => {
const result = parseHelperContent('<b><i>negrito e itálico</i></b>');
expect(result).toEqual([{ text: 'negrito e itálico', bold: true, italic: true, underline: false }]);
});

it('should parse multiple nested tags', () => {
const result = parseHelperContent('<b><i><u>todos</u></i></b>');
expect(result).toEqual([{ text: 'todos', bold: true, italic: true, underline: true }]);
});

it('should handle mixed formatting in sequence', () => {
const result = parseHelperContent('<b>negrito</b> e <i>itálico</i>');
expect(result).toEqual([
{ text: 'negrito', bold: true, italic: false, underline: false },
{ text: ' e ', bold: false, italic: false, underline: false },
{ text: 'itálico', bold: false, italic: true, underline: false }
]);
});

it('should sanitize disallowed tags (script)', () => {
const result = parseHelperContent('Texto <script>alert("xss")</script> seguro');
expect(result).toEqual([{ text: 'Texto alert("xss") seguro', bold: false, italic: false, underline: false }]);
});

it('should sanitize disallowed tags (div, span, a)', () => {
const result = parseHelperContent('<div>conteúdo</div> <span>texto</span> <a href="x">link</a>');
expect(result).toEqual([{ text: 'conteúdo texto link', bold: false, italic: false, underline: false }]);
});

it('should sanitize tags with attributes', () => {
const result = parseHelperContent('<b style="color:red">negrito</b>');
expect(result).toEqual([{ text: 'negrito', bold: true, italic: false, underline: false }]);
});

it('should handle unclosed tags gracefully', () => {
const result = parseHelperContent('<b>negrito sem fechar');
expect(result).toEqual([{ text: 'negrito sem fechar', bold: true, italic: false, underline: false }]);
});

it('should handle extra closing tags gracefully', () => {
const result = parseHelperContent('texto</b> normal');
expect(result).toEqual([
{ text: 'texto', bold: false, italic: false, underline: false },
{ text: ' normal', bold: false, italic: false, underline: false }
]);
});

it('should handle case-insensitive tags', () => {
const result = parseHelperContent('<B>negrito</B> <I>itálico</I>');
expect(result).toEqual([
{ text: 'negrito', bold: true, italic: false, underline: false },
{ text: ' ', bold: false, italic: false, underline: false },
{ text: 'itálico', bold: false, italic: true, underline: false }
]);
});

it('should prevent XSS via event handlers in allowed tags', () => {
const result = parseHelperContent('<b onmouseover="alert(1)">texto</b>');
expect(result).toEqual([{ text: 'texto', bold: true, italic: false, underline: false }]);
});

it('should prevent XSS via img tag with onerror', () => {
const result = parseHelperContent('<img src=x onerror="alert(1)">texto');
expect(result).toEqual([{ text: 'texto', bold: false, italic: false, underline: false }]);
});

it('should handle partially overlapping tags', () => {
const result = parseHelperContent('<b>bold <i>bold+italic</b> italic</i>');
expect(result).toEqual([
{ text: 'bold ', bold: true, italic: false, underline: false },
{ text: 'bold+italic', bold: true, italic: true, underline: false },
{ text: ' italic', bold: false, italic: true, underline: false }
]);
});

it('should parse <strong> as bold', () => {
const result = parseHelperContent('Texto <strong>importante</strong> normal');
expect(result).toEqual([
{ text: 'Texto ', bold: false, italic: false, underline: false },
{ text: 'importante', bold: true, italic: false, underline: false },
{ text: ' normal', bold: false, italic: false, underline: false }
]);
});

it('should parse <em> as italic', () => {
const result = parseHelperContent('Texto <em>enfatizado</em> normal');
expect(result).toEqual([
{ text: 'Texto ', bold: false, italic: false, underline: false },
{ text: 'enfatizado', bold: false, italic: true, underline: false },
{ text: ' normal', bold: false, italic: false, underline: false }
]);
});

it('should handle mixed <strong>, <em>, <b>, <i>, <u> together', () => {
const result = parseHelperContent('<strong>bold</strong> <em>italic</em> <u>underline</u>');
expect(result).toEqual([
{ text: 'bold', bold: true, italic: false, underline: false },
{ text: ' ', bold: false, italic: false, underline: false },
{ text: 'italic', bold: false, italic: true, underline: false },
{ text: ' ', bold: false, italic: false, underline: false },
{ text: 'underline', bold: false, italic: false, underline: true }
]);
});
});
});
126 changes: 126 additions & 0 deletions projects/ui/src/lib/components/po-helper/po-helper-content-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* @description
*
* Utilitário de "Safe Parser" para o componente po-helper.
*
* Permite o uso de tags de formatação básica (`<b>`, `<strong>`, `<i>`, `<em>`, `<u>`) no conteúdo do helper, sem
* utilizar `innerHTML`, garantindo proteção contra ataques XSS.
*
* Qualquer tag HTML que não esteja na whitelist é sanitizada (removida), preservando apenas o texto interno.
*/

/**
* Representa um fragmento de texto com suas propriedades de formatação.
*/
export interface PoHelperTextFragment {
/** Conteúdo textual do fragmento */
text: string;
/** Indica se o fragmento deve ser exibido em negrito */
bold: boolean;
/** Indica se o fragmento deve ser exibido em itálico */
italic: boolean;
/** Indica se o fragmento deve ser exibido com sublinhado */
underline: boolean;
}

/** Tags permitidas pelo safe parser */
const ALLOWED_TAGS = ['b', 'i', 'u', 'strong', 'em'];

/** Mapa de normalização: tags semânticas são convertidas para suas equivalentes de formatação */
const TAG_NORMALIZE_MAP: Record<string, string> = {
strong: 'b',
em: 'i'
};

/**
* Regex para capturar tags HTML (abertura e fechamento).
* Captura: tag name e se é fechamento (/).
*/
const TAG_REGEX = /<(\/?)(\w+)(?:\s[^>]*)?\/?>/gi;

/**
* Remove todas as tags HTML que não estão na whitelist, preservando o texto interno.
*
* @param input String com possíveis tags HTML
* @returns String com apenas as tags permitidas
*/
function sanitizeContent(input: string): string {
return input.replace(TAG_REGEX, (_match, closing, tagName) => {
const normalizedTag = tagName.toLowerCase();
if (ALLOWED_TAGS.includes(normalizedTag)) {
const outputTag = TAG_NORMALIZE_MAP[normalizedTag] || normalizedTag;
return closing ? `</${outputTag}>` : `<${outputTag}>`;
}
return '';
});
}

/** Mapa de contadores por tag */
interface TagCounters {
b: number;
i: number;
u: number;
}

/**
* Incrementa o contador da tag de abertura.
*/
function incrementTagCounter(counters: TagCounters, tag: string): void {
counters[tag]++;
}

/**
* Decrementa o contador da tag de fechamento.
*/
function decrementTagCounter(counters: TagCounters, tag: string): void {
counters[tag] = Math.max(0, counters[tag] - 1);
}

/**
* Faz o parsing de uma string com tags de formatação permitidas (`<b>`, `<i>`, `<u>`)
* e retorna um array de fragmentos com as propriedades de formatação aplicadas.
*
* Tags não permitidas são removidas (sanitizadas). Tags aninhadas são suportadas.
*
* @param content String de conteúdo com possíveis tags de formatação
* @returns Array de fragmentos de texto com informações de formatação
*/
export function parseHelperContent(content: string): Array<PoHelperTextFragment> {
if (!content) {
return [];
}

const sanitized = sanitizeContent(content);
const fragments: Array<PoHelperTextFragment> = [];
const counters: TagCounters = { b: 0, i: 0, u: 0 };

const splitRegex = /(<\/?[biu]>)/gi;
const parts = sanitized.split(splitRegex);

for (const part of parts) {
if (!part) {
continue;
}

const tagMatch = /^<(\/?)([biu])>$/i.exec(part);

if (tagMatch) {
const isClosing = tagMatch[1] === '/';
const tag = tagMatch[2].toLowerCase();
if (isClosing) {
decrementTagCounter(counters, tag);
} else {
incrementTagCounter(counters, tag);
}
} else {
fragments.push({
text: part,
bold: counters.b > 0,
italic: counters.i > 0,
underline: counters.u > 0
});
}
}

return fragments;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { PoHelperContentPipe } from './po-helper-content.pipe';

describe('PoHelperContentPipe', () => {
let pipe: PoHelperContentPipe;

beforeEach(() => {
pipe = new PoHelperContentPipe();
});

it('should create an instance', () => {
expect(pipe).toBeTruthy();
});

it('should return empty array for null content', () => {
expect(pipe.transform(null)).toEqual([]);
});

it('should return empty array for undefined content', () => {
expect(pipe.transform(undefined)).toEqual([]);
});

it('should return empty array for empty string', () => {
expect(pipe.transform('')).toEqual([]);
});

it('should return single fragment for plain text', () => {
const result = pipe.transform('Texto simples');
expect(result).toEqual([{ text: 'Texto simples', bold: false, italic: false, underline: false }]);
});

it('should parse bold tag', () => {
const result = pipe.transform('Texto <b>negrito</b>');
expect(result[1]).toEqual({ text: 'negrito', bold: true, italic: false, underline: false });
});

it('should parse italic tag', () => {
const result = pipe.transform('Texto <i>itálico</i>');
expect(result[1]).toEqual({ text: 'itálico', bold: false, italic: true, underline: false });
});

it('should parse underline tag', () => {
const result = pipe.transform('Texto <u>sublinhado</u>');
expect(result[1]).toEqual({ text: 'sublinhado', bold: false, italic: false, underline: true });
});

it('should normalize strong to bold', () => {
const result = pipe.transform('<strong>bold</strong>');
expect(result[0]).toEqual({ text: 'bold', bold: true, italic: false, underline: false });
});

it('should normalize em to italic', () => {
const result = pipe.transform('<em>italic</em>');
expect(result[0]).toEqual({ text: 'italic', bold: false, italic: true, underline: false });
});

it('should sanitize script tags', () => {
const result = pipe.transform('<script>alert("xss")</script>safe');
const allText = result.map(f => f.text).join('');
expect(allText).not.toContain('<script>');
expect(allText).toContain('safe');
});
});
20 changes: 20 additions & 0 deletions projects/ui/src/lib/components/po-helper/po-helper-content.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import { parseHelperContent, PoHelperTextFragment } from './po-helper-content-utils';

/**
* @description
*
* Pipe que transforma o conteúdo do helper em fragmentos de texto formatados,
* utilizando o Safe Parser para garantir segurança contra XSS.
*
* Uso interno do componente `po-helper`.
*/
@Pipe({
name: 'poHelperContent',
standalone: false
})
export class PoHelperContentPipe implements PipeTransform {
transform(content: string): Array<PoHelperTextFragment> {
return parseHelperContent(content);
}
}
Loading
Loading