Assessor_2.0/tests/importers/html-attribute-performance....

97 lines
6.2 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { htmlAttributes, htmlAttributeValue } from '../../src/importers/html-attributes';
import { indexHtmlElements } from '../../src/importers/html-element-index';
import { decodeHtmlLexically, sanitizeHtmlText } from '../../src/importers/html-extraction';
import { protectHtmlAttributeMarkup } from '../../src/importers/html-attribute-markup';
import { importSource } from '../../src/importers/engine';
import { importProfiles } from '../../src/profiles/profile-catalog';
import { nodeImportRuntimeAdapters } from '../../server/node-import-runtime-adapters';
import { browserImportRuntimeAdapters } from '../../src/platform/browser/import-runtime-adapters';
import { htmlTimeoutFixture } from './html-timeout-fixture.mjs';
describe('bounded HTML attribute and target work', () => {
it('retains quoted, unquoted, empty and boolean attributes without reading values as attributes', () => {
const text = ` disabled ID='target' data-tooltip="<b id='fake'>value</b>" zero=0 empty="" flag = false `;
expect([...htmlAttributes(text)].map(({ name, value }) => [name, value])).toEqual([
['disabled', undefined], ['id', 'target'], ['data-tooltip', "<b id='fake'>value</b>"], ['zero', '0'], ['empty', ''], ['flag', 'false']
]);
expect(htmlAttributeValue(text, 'Id')).toBe('target');
expect(htmlAttributeValue(` id='first' id=second`, 'id')).toBe('first');
expect(htmlAttributeValue(` title="href='fake'"`, 'href')).toBe('');
for (const attribute of htmlAttributes(text)) expect(text.slice(attribute.start, attribute.end).toLowerCase()).toMatch(new RegExp('^' + attribute.name));
});
it.each([1024, 4096, 16_384])('performs bounded character checks for %i-character valueless attributes', size => {
const text = ' ' + 'x'.repeat(size) + ' '.repeat(size) + ' id=retained';
const original = RegExp.prototype.exec;
let largestRegexInput = 0;
let checkedCharacters = 0;
const spy = vi.spyOn(RegExp.prototype, 'exec').mockImplementation(function (this: RegExp, value: string) {
largestRegexInput = Math.max(largestRegexInput, value.length);
checkedCharacters += value.length;
return original.call(this, value);
});
let result: string;
try { result = htmlAttributeValue(text, 'id'); } finally { spy.mockRestore(); }
expect(result!).toBe('retained');
// No input-wide backtracking regex is allowed in the attribute scanner.
expect(largestRegexInput).toBe(1);
expect(checkedCharacters).toBeLessThan(text.length * 3);
});
it('continues after malformed separators and preserves literal slash and entity values', () => {
expect([...htmlAttributes(` / = > flag data-v=a/b&amp;c empty='' tail="unterminated`)].map(({ name, value }) => [name, value])).toEqual([
['flag', undefined], ['data-v', 'a/b&amp;c'], ['empty', ''], ['tail', 'unterminated']
]);
});
it('sanitizes whole dangerous attributes while retaining harmless originals and safe links', () => {
const source = `<a disabled data-tip="src='inert' &lt;b&gt;" onclick="bad()" href="https://example.invalid/?a=1&amp;b=2" src=bad onfocus>Text</a>`;
const sanitized = sanitizeHtmlText(source);
expect(sanitized.text).toContain(`disabled data-tip="src='inert' &lt;b&gt;"`);
expect(sanitized.text).toContain('href="https://example.invalid/?a=1&amp;b=2"');
expect(sanitized.text).not.toMatch(/onclick|onfocus|src=bad/);
expect(sanitized.removedContentCount).toBe(3);
expect(sanitizeHtmlText('<a href="javascript:bad()">Text</a>').text).not.toContain('href');
});
it('retains subsequent cells, table IDs and spans after long valueless attributes', () => {
const source = `<table ${'x'.repeat(50_000)} id=retained><tr><th colspan=2>Columns</th></tr><tr><td data-tip="<b>inert</b>" rowspan=2>0</td><td>false</td></tr><tr><td>tail</td></tr></table>`;
expect(decodeHtmlLexically(source).tables[0]).toMatchObject({ name: 'retained', rows: [['Columns', 'Columns'], ['0', 'false'], ['0', 'tail']] });
});
it('indexes first definitions and ambiguous IDs once, independently of lookup count and report identity', () => {
const source = `<div id=first name=first><a name='named'></a></div><div ID=first></div><div id='__proto__'/><div title="<a id='fake'>">end</div>`;
const index = indexHtmlElements(protectHtmlAttributeMarkup(source));
expect(index.definitions.get('first')).toEqual({ count: 2, start: 0 });
expect(index.byId.get('first')).toMatchObject({ tagName: 'div', start: 0 });
expect(index.namedAnchors.has('named')).toBe(true);
expect(index.byId.has('fake')).toBe(false);
expect(index.byId.get('__proto__')?.selfClosing).toBe(true);
const spy = vi.spyOn(RegExp.prototype, 'exec');
for (let lookup = 0; lookup < 1000; lookup++) index.definitions.get('first');
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
expect(indexHtmlElements('<p id=first>changed</p>').byId.get('first')?.tagName).toBe('p');
});
it('keeps every rule and linked detail in both runtimes for the multi-megabyte timeout reproducer', async () => {
const text = htmlTimeoutFixture();
const bytes = new TextEncoder().encode(text);
const source = { metadata: { name: 'ad_hc_synthetic.local.html', sizeBytes: bytes.length, lastModified: 0 }, bytes };
const server = await importSource(source, { profiles: importProfiles }, nodeImportRuntimeAdapters);
const browser = await importSource(source, { profiles: importProfiles }, browserImportRuntimeAdapters);
expect(browser.extraction).toEqual(server.extraction);
expect(server.profileId).toBe('pingcastle');
expect(server.rawText).toBe(text);
expect(server.extraction?.candidates).toHaveLength(40);
expect(server.extraction?.diagnostics).toEqual([]);
expect(server.intermediate?.tables).toHaveLength(40);
server.extraction?.candidates.forEach((candidate, index) => {
expect(candidate.fields.find(field => field.key === 'risk-id')?.originalValue).toBe(`S-${index}`);
expect(candidate.fields.find(field => field.key === 'linked-section')?.originalValue).toMatchObject({ sections: [{ sectionId: `detail-${index}`, headers: ['Host'], rows: [[`host-${index}`]] }] });
});
});
});