97 lines
6.2 KiB
TypeScript
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&c empty='' tail="unterminated`)].map(({ name, value }) => [name, value])).toEqual([
|
|
['flag', undefined], ['data-v', 'a/b&c'], ['empty', ''], ['tail', 'unterminated']
|
|
]);
|
|
});
|
|
|
|
it('sanitizes whole dangerous attributes while retaining harmless originals and safe links', () => {
|
|
const source = `<a disabled data-tip="src='inert' <b>" onclick="bad()" href="https://example.invalid/?a=1&b=2" src=bad onfocus>Text</a>`;
|
|
const sanitized = sanitizeHtmlText(source);
|
|
expect(sanitized.text).toContain(`disabled data-tip="src='inert' <b>"`);
|
|
expect(sanitized.text).toContain('href="https://example.invalid/?a=1&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}`]] }] });
|
|
});
|
|
});
|
|
});
|