mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 18:43:45 +00:00
2bbd9c9e36
* feat(editor): hidden-content detector + non-blocking authoring warning (TASK-1327)
Adds an authoring-honesty feature for HTML blocks: when the user pastes
or types raw HTML containing content that's invisible (or
near-invisible) to humans but readable by LLMs / agents, surface a
non-blocking warning pill above the block. Click the pill to expand an
inspector listing each hidden segment with the rule that flagged it
plus a snippet for context. "Dismiss for this block" button persists
acknowledgement via a sentinel HTML comment marker so the warning
doesn't re-fire after a doc reload.
NOT a security control. Render-time sanitization (sanitizeHtmlBlock,
TASK-1323) protects browsers from XSS. This protects authors from
unconsciously shipping content that looks one way and reads another
— common with copy-pasted HTML blobs that contain steganography
channels (display:none divs, white-on-white text, font-size:0,
hidden HTML comments, off-screen positioning, suspiciously long
aria-label / alt / title values).
## Detector
`web/src/lib/utils/hiddenContentDetector.ts` (NEW). Pure function
`detectHiddenContent(html: string): HiddenSegment[]`. Uses DOMParser
to walk the HTML tree:
- Inline-style heuristics:
- display:none, visibility:hidden, opacity:0/0%/0.0
- font-size with px value < 6
- color matches background-color (exact normalised match)
- position absolute/fixed with left/right/top/bottom <= -9000px
- transform translate to off-screen (heuristic regex on -9XXX or
-1XXXX values inside translateX/Y/translate)
- width:0 AND height:0
- clip:rect(0,0,0,0) — intentionally flagged; the user can dismiss
if it's deliberate sr-only positioning, but it's also a known
steganography channel
- HTML comments — every <!-- ... --> flags, EXCEPT the Pad-internal
ack marker (PAD_ACK_HIDDEN_MARKER) which the detector skips
- aria-label / alt / title attributes longer than 200 chars OR
containing newlines
Class-based hiding (e.g. .sr-only) is NOT flagged: resolving it
requires the page's stylesheet context, which we don't have, and the
false-positive rate is too high.
## NodeView UX
Warning pill ⚠ "N hidden segment(s) — click to inspect" appears above
the preview pane when segments > 0 AND the user hasn't dismissed for
this block. Click toggles the inspector panel below the source pane,
which lists each segment as `<code>tag</code> — rule` with the snippet
in a quoted monospace box. Dismiss button prepends
`<!-- pad:ack-hidden -->` to attrs.html via setNodeMarkup; the marker
survives markdown round-trip (it's a valid HTML comment) and the
detector skips it on subsequent runs.
The wrapper gets `.html-block--has-hidden` while the warning is
showing, in case any caller wants to react.
`updateWarning()` runs on every renderPreview call, so the warning
follows attrs.html changes (e.g. the user edits the block in source
mode and removes the hidden content — warning disappears immediately).
## Out of scope (future work)
- Class-based hiding detection (requires stylesheet resolution)
- Auto-stripping hidden content (this is an authoring honesty
feature, not a sanitizer; the user decides)
- Detection in non-HTML-block content (markdown comments, raw HTML
in markdown surface)
Parent: PLAN-1322.
* fix(detector): walk comments at doc root + normalize style values (Codex round 1)
* fix(detector): use CSSStyleDeclaration for browser-correct parsing (Codex round 2)
248 lines
8.7 KiB
TypeScript
248 lines
8.7 KiB
TypeScript
/**
|
|
* Hidden-content detector for HTML blocks (TASK-1327 / PLAN-1322).
|
|
*
|
|
* Walks an HTML string looking for elements + comments that are visible
|
|
* to LLMs / agents reading the document but invisible (or near-invisible)
|
|
* to a human author reviewing the rendered preview. The detector is an
|
|
* **authoring-honesty** feature — NOT a security control. Render-time
|
|
* sanitization (sanitizeHtmlBlock) protects browsers; this protects
|
|
* authors from accidentally pasting content that looks one way and
|
|
* reads another.
|
|
*
|
|
* Heuristics flag content that is one of:
|
|
* - CSS-hidden via inline style (display:none, visibility:hidden,
|
|
* opacity:0, font-size:0 or very small, color === background-color)
|
|
* - Off-screen positioned (position:absolute with very negative
|
|
* left/right/top/bottom, transform translate to off-screen)
|
|
* - Zero-dimension (width:0 AND height:0, clip:rect(0,0,0,0))
|
|
* - HTML comments
|
|
* - Suspiciously long aria-label / alt / title (>200 chars or
|
|
* containing newlines — heuristic for hidden text in attribute
|
|
* values)
|
|
*
|
|
* Class-based hiding (e.g. `.sr-only`) is NOT flagged: resolving it
|
|
* requires the page's stylesheet context, which we don't have here,
|
|
* and the false-positive rate would be too high. The detector stays
|
|
* conservative.
|
|
*/
|
|
|
|
/**
|
|
* The Pad-internal sentinel used by `setHiddenContentAcknowledged` to
|
|
* mark that a human has reviewed and accepted the hidden content.
|
|
* `detectHiddenContent` skips this exact comment so dismissal sticks
|
|
* after a doc reload.
|
|
*/
|
|
export const PAD_ACK_HIDDEN_MARKER = '<!-- pad:ack-hidden -->';
|
|
|
|
const PAD_ACK_HIDDEN_TEXT = ' pad:ack-hidden '; // textContent of the marker (with surrounding spaces)
|
|
const PAD_ACK_HIDDEN_TEXT_TRIMMED = 'pad:ack-hidden';
|
|
|
|
export interface HiddenSegment {
|
|
/** Lowercase tag name of the offending element, or `#comment`. */
|
|
tag: string;
|
|
/** Short human-readable rule label, e.g. `display:none` or `font-size:2px (too small)`. */
|
|
rule: string;
|
|
/** Up to 80 chars of trimmed text content, for context in the inspector. */
|
|
snippet: string;
|
|
}
|
|
|
|
/**
|
|
* Returns true when the user has previously dismissed the hidden-content
|
|
* warning for this block. The marker lives at the top of the html string
|
|
* so it's the first thing read on parse and survives markdown round-trips
|
|
* (it's just an HTML comment, valid in any context where raw HTML is
|
|
* permitted).
|
|
*/
|
|
export function isHiddenContentAcknowledged(html: string): boolean {
|
|
return html.trimStart().startsWith(PAD_ACK_HIDDEN_MARKER);
|
|
}
|
|
|
|
/**
|
|
* Prepend the ack marker if not already present. Idempotent. Used by the
|
|
* NodeView's "Dismiss for this block" affordance.
|
|
*/
|
|
export function setHiddenContentAcknowledged(html: string): string {
|
|
if (isHiddenContentAcknowledged(html)) return html;
|
|
const sep = html.startsWith('\n') || html === '' ? '' : '\n';
|
|
return `${PAD_ACK_HIDDEN_MARKER}${sep}${html}`;
|
|
}
|
|
|
|
/**
|
|
* Detect hidden content in an HTML block's raw source. Returns one
|
|
* `HiddenSegment` per offending element / attribute / comment. Returns
|
|
* an empty array when running outside a browser (no DOMParser) — the
|
|
* detector is purely client-side.
|
|
*/
|
|
export function detectHiddenContent(html: string): HiddenSegment[] {
|
|
if (typeof window === 'undefined' || typeof DOMParser === 'undefined') return [];
|
|
if (!html) return [];
|
|
|
|
const segments: HiddenSegment[] = [];
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
|
|
// Walk all elements regardless of whether the parser placed them
|
|
// inside body (the typical case) or moved them to head (e.g. <style>
|
|
// / <link> at the top of the snippet). We only check elements that
|
|
// rendered content can hide behind, but element-level checks are
|
|
// cheap so just walk the whole document.
|
|
doc.querySelectorAll('*').forEach((el) => {
|
|
// Skip <html>, <head>, <body> wrappers DOMParser inserts; they
|
|
// never carry the inline-style hide patterns we look for.
|
|
const tag = el.tagName.toLowerCase();
|
|
if (tag === 'html' || tag === 'head' || tag === 'body') return;
|
|
segments.push(...checkElement(el as HTMLElement));
|
|
});
|
|
|
|
// Walk comments anywhere in the document. DOMParser can place
|
|
// comments at document level (siblings of <html>) when the input
|
|
// has a leading `<!-- ... -->`; walking only doc.body would miss
|
|
// those. createTreeWalker on `doc` is the inclusive root.
|
|
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_COMMENT);
|
|
let node = walker.nextNode();
|
|
while (node) {
|
|
const text = node.textContent ?? '';
|
|
const trimmed = text.trim();
|
|
if (text !== PAD_ACK_HIDDEN_TEXT && trimmed !== PAD_ACK_HIDDEN_TEXT_TRIMMED) {
|
|
segments.push({
|
|
tag: '#comment',
|
|
rule: 'HTML comment',
|
|
snippet: snippetFor(text),
|
|
});
|
|
}
|
|
node = walker.nextNode();
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
function checkElement(el: HTMLElement): HiddenSegment[] {
|
|
const segments: HiddenSegment[] = [];
|
|
const tag = el.tagName.toLowerCase();
|
|
// `el.style` is the browser-parsed CSSStyleDeclaration for the
|
|
// inline `style` attribute. The browser handles every CSS edge
|
|
// case for us:
|
|
//
|
|
// - `!important` priority (display:none!important;display:block
|
|
// leaves `el.style.display === 'none'`)
|
|
// - CSS comments (display:/**/none parses correctly)
|
|
// - case-insensitive keywords (DISPLAY: NONE → 'none')
|
|
// - whitespace and unit normalisation
|
|
//
|
|
// A hand-rolled `style` attribute parser misses these consistently;
|
|
// using the CSSOM here is materially more robust.
|
|
const style = el.style;
|
|
const text = el.textContent ?? '';
|
|
|
|
if (style.display === 'none') {
|
|
segments.push({ tag, rule: 'display:none', snippet: snippetFor(text) });
|
|
}
|
|
if (style.visibility === 'hidden') {
|
|
segments.push({ tag, rule: 'visibility:hidden', snippet: snippetFor(text) });
|
|
}
|
|
if (isZeroOpacity(style.opacity)) {
|
|
segments.push({ tag, rule: `opacity:${style.opacity}`, snippet: snippetFor(text) });
|
|
}
|
|
const fontSize = parseLength(style.fontSize);
|
|
if (fontSize !== null && fontSize < 6) {
|
|
segments.push({
|
|
tag,
|
|
rule: `font-size:${style.fontSize} (too small)`,
|
|
snippet: snippetFor(text),
|
|
});
|
|
}
|
|
if (
|
|
style.color &&
|
|
style.backgroundColor &&
|
|
normalizeColor(style.color) === normalizeColor(style.backgroundColor)
|
|
) {
|
|
segments.push({
|
|
tag,
|
|
rule: 'color matches background-color',
|
|
snippet: snippetFor(text),
|
|
});
|
|
}
|
|
if (style.position === 'absolute' || style.position === 'fixed') {
|
|
for (const prop of ['left', 'right', 'top', 'bottom'] as const) {
|
|
const value = style.getPropertyValue(prop);
|
|
const offset = parseLength(value);
|
|
if (offset !== null && offset <= -9000) {
|
|
segments.push({
|
|
tag,
|
|
rule: `${prop}:${value} (off-screen)`,
|
|
snippet: snippetFor(text),
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (style.transform && /translate[xy]?\s*\(\s*-?\d+/i.test(style.transform)) {
|
|
const off = /-\s*9\d{3,}|-\s*\d{5,}/.test(style.transform);
|
|
if (off) {
|
|
segments.push({
|
|
tag,
|
|
rule: 'transform off-screen',
|
|
snippet: snippetFor(text),
|
|
});
|
|
}
|
|
}
|
|
const w = parseLength(style.width);
|
|
const h = parseLength(style.height);
|
|
if (w === 0 && h === 0) {
|
|
segments.push({ tag, rule: 'width:0;height:0', snippet: snippetFor(text) });
|
|
}
|
|
if (style.clip && /rect\(\s*0(?:px)?\s*,\s*0(?:px)?\s*,\s*0(?:px)?\s*,\s*0(?:px)?\s*\)/i.test(style.clip)) {
|
|
segments.push({ tag, rule: 'clip:rect(0,0,0,0)', snippet: snippetFor(text) });
|
|
}
|
|
|
|
for (const attr of ['aria-label', 'alt', 'title'] as const) {
|
|
const val = el.getAttribute(attr);
|
|
if (!val) continue;
|
|
const tooLong = val.length > 200;
|
|
const hasNewline = /\n/.test(val);
|
|
if (tooLong || hasNewline) {
|
|
const reasons: string[] = [];
|
|
if (tooLong) reasons.push(`${val.length} chars`);
|
|
if (hasNewline) reasons.push('contains newlines');
|
|
segments.push({
|
|
tag,
|
|
rule: `${attr} suspicious (${reasons.join(', ')})`,
|
|
snippet: snippetFor(val),
|
|
});
|
|
}
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
/**
|
|
* Parse a CSS length declaration like `12px`, `0`, `-9999px`, `1.5em`
|
|
* into a number when the unit is bare (`0`) or px. Returns `null` for
|
|
* percentage / em / rem / unrecognised forms — those don't have a
|
|
* single numeric "is this hidden" answer.
|
|
*/
|
|
function parseLength(value: string | undefined): number | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
if (trimmed === '0') return 0;
|
|
const match = trimmed.match(/^(-?[\d.]+)px$/i);
|
|
if (!match) return null;
|
|
const n = parseFloat(match[1]);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
function isZeroOpacity(value: string | undefined): boolean {
|
|
if (!value) return false;
|
|
const v = value.trim();
|
|
return v === '0' || v === '0.0' || v === '0%' || v === '.0';
|
|
}
|
|
|
|
function normalizeColor(c: string): string {
|
|
return c.toLowerCase().replace(/\s+/g, '');
|
|
}
|
|
|
|
function snippetFor(text: string): string {
|
|
const collapsed = text.trim().replace(/\s+/g, ' ');
|
|
if (collapsed.length <= 80) return collapsed;
|
|
return `${collapsed.slice(0, 77)}…`;
|
|
}
|