Skill Tracer
Skills › gstack › browse › content-security.ts

content-security.ts

373 lines · browse/src/content-security.ts at 3f79d81

1/**
2 * Content security layer for pair-agent browser sharing.
3 *
4 * Four defense layers:
5 * 1. Datamarking — watermark text output to detect exfiltration
6 * 2. Hidden element stripping — remove invisible/deceptive elements from output
7 * 3. Content filter hooks — extensible URL/content filter pipeline
8 * 4. Instruction block hardening — SECURITY section in agent instructions
9 *
10 * This module handles layers 1-3. Layer 4 is in cli.ts.
11 */
13import { randomBytes } from 'crypto';
14import type { Page, Frame } from 'playwright';
15import { stripLoneSurrogates } from './sanitize';
17// ─── Datamarking (Layer 1) ──────────────────────────────────────
19/** Session-scoped random marker for text watermarking */
20let sessionMarker: string | null = null;
22function ensureMarker(): string {
23 if (!sessionMarker) {
24 sessionMarker = randomBytes(3).toString('base64').slice(0, 4);
25 }
26 return sessionMarker;
29/** Exported for tests only */
30export function getSessionMarker(): string {
31 return ensureMarker();
34/** Reset marker (for testing) */
35export function resetSessionMarker(): void {
36 sessionMarker = null;
39/**
40 * Insert invisible watermark into text content.
41 * Places the marker as zero-width characters between words.
42 * Only applied to `text` command output (not html, forms, or structured data).
43 */
44export function datamarkContent(content: string): string {
45 const marker = ensureMarker();
46 // Insert marker as a Unicode tag sequence between sentences (after periods followed by space)
47 // This is subtle enough to not corrupt output but detectable if exfiltrated
48 const zwsp = '\u200B'; // zero-width space
49 const taggedMarker = marker.split('').map(c => zwsp + c).join('');
50 // Insert after every 3rd sentence-ending period
51 let count = 0;
52 return content.replace(/(\. )/g, (match) => {
53 count++;
54 if (count % 3 === 0) {
55 return match + taggedMarker;
56 }
57 return match;
58 });
61// ─── Hidden Element Stripping (Layer 2) ─────────────────────────
63/** Injection-like patterns in ARIA labels */
64const ARIA_INJECTION_PATTERNS = [
65 /ignore\s+(previous|above|all)\s+instructions?/i,
66 /you\s+are\s+(now|a)\s+/i,
67 /system\s*:\s*/i,
68 /\bdo\s+not\s+(follow|obey|listen)/i,
69 /\bexecute\s+(the\s+)?following/i,
70 /\bforget\s+(everything|all|your)/i,
71 /\bnew\s+instructions?\s*:/i,
72];
74/**
75 * Detect hidden elements and ARIA injection on a page.
76 * Marks hidden elements with data-gstack-hidden attribute.
77 * Returns descriptions of what was found for logging.
78 *
79 * Detection criteria:
80 * - opacity < 0.1
81 * - font-size < 1px
82 * - off-screen (positioned far outside viewport)
83 * - visibility:hidden or display:none with text content
84 * - same foreground/background color
85 * - clip/clip-path hiding
86 * - ARIA labels with injection patterns
87 */
88export async function markHiddenElements(page: Page | Frame): Promise<string[]> {
89 return page.evaluate((ariaPatterns: string[]) => {
90 const found: string[] = [];
91 const elements = document.querySelectorAll('body *');
93 for (const el of elements) {
94 if (el instanceof HTMLElement) {
95 const style = window.getComputedStyle(el);
96 const text = el.textContent?.trim() || '';
97 if (!text) continue; // skip empty elements
99 let isHidden = false;
100 let reason = '';
102 // Check opacity
103 if (parseFloat(style.opacity) < 0.1) {
104 isHidden = true;
105 reason = 'opacity < 0.1';
106 }
107 // Check font-size
108 else if (parseFloat(style.fontSize) < 1) {
109 isHidden = true;
110 reason = 'font-size < 1px';
111 }
112 // Check off-screen positioning
113 else if (style.position === 'absolute' || style.position === 'fixed') {
114 const rect = el.getBoundingClientRect();
115 if (rect.right < -100 || rect.bottom < -100 || rect.left > window.innerWidth + 100 || rect.top > window.innerHeight + 100) {
116 isHidden = true;
117 reason = 'off-screen';
118 }
119 }
120 // Check same fg/bg color (text hiding)
121 else if (style.color === style.backgroundColor && text.length > 10) {
122 isHidden = true;
123 reason = 'same fg/bg color';
124 }
125 // Check clip-path hiding
126 else if (style.clipPath === 'inset(100%)' || style.clip === 'rect(0px, 0px, 0px, 0px)') {
127 isHidden = true;
128 reason = 'clip hiding';
129 }
130 // Check visibility: hidden
131 else if (style.visibility === 'hidden') {
132 isHidden = true;
133 reason = 'visibility hidden';
134 }
136 if (isHidden) {
137 el.setAttribute('data-gstack-hidden', 'true');
138 found.push(`[${el.tagName.toLowerCase()}] ${reason}: "${text.slice(0, 60)}..."`);
139 }
141 // Check ARIA labels for injection patterns
142 const ariaLabel = el.getAttribute('aria-label') || '';
143 const ariaLabelledBy = el.getAttribute('aria-labelledby');
144 let labelText = ariaLabel;
145 if (ariaLabelledBy) {
146 const labelEl = document.getElementById(ariaLabelledBy);
147 if (labelEl) labelText += ' ' + (labelEl.textContent || '');
148 }
150 if (labelText) {
151 for (const pattern of ariaPatterns) {
152 if (new RegExp(pattern, 'i').test(labelText)) {
153 el.setAttribute('data-gstack-hidden', 'true');
154 found.push(`[${el.tagName.toLowerCase()}] ARIA injection: "${labelText.slice(0, 60)}..."`);
155 break;
156 }
157 }
158 }
159 }
160 }
162 return found;
163 }, ARIA_INJECTION_PATTERNS.map(p => p.source));
166/**
167 * Get clean text with hidden elements stripped (for `text` command).
168 * Uses clone + remove approach: clones body, removes marked elements, returns innerText.
169 */
170export async function getCleanTextWithStripping(page: Page | Frame): Promise<string> {
171 const raw = await page.evaluate(() => {
172 const body = document.body;
173 if (!body) return '';
174 const clone = body.cloneNode(true) as HTMLElement;
175 // Remove standard noise elements
176 clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
177 // Remove hidden-marked elements
178 clone.querySelectorAll('[data-gstack-hidden]').forEach(el => el.remove());
179 return clone.innerText
180 .split('\n')
181 .map(line => line.trim())
182 .filter(line => line.length > 0)
183 .join('\n');
184 });
185 return stripLoneSurrogates(raw);
188/**
189 * Clean up data-gstack-hidden attributes from the page.
190 * Should be called after extraction is complete.
191 */
192export async function cleanupHiddenMarkers(page: Page | Frame): Promise<void> {
193 await page.evaluate(() => {
194 document.querySelectorAll('[data-gstack-hidden]').forEach(el => {
195 el.removeAttribute('data-gstack-hidden');
196 });
197 });
200// ─── Content Envelope (wrapping) ────────────────────────────────
202const ENVELOPE_BEGIN = '═══ BEGIN UNTRUSTED WEB CONTENT ═══';
203const ENVELOPE_END = '═══ END UNTRUSTED WEB CONTENT ═══';
205/**
206 * Defuse envelope sentinels that appear inside attacker-controlled page
207 * content. Any raw BEGIN/END marker inside `content` gets a zero-width
208 * space spliced through CONTENT so the marker still renders visibly but
209 * no longer matches the envelope grep the LLM anchors on.
210 *
211 * Both the wrap path (full-page content) and the split path (scoped
212 * snapshots) must funnel untrusted text through this helper before
213 * emitting the outer envelope, otherwise a page whose accessibility
214 * tree contains the literal sentinel can close the envelope early and
215 * forge a fake "trusted" section in the LLM's view.
216 */
217export function escapeEnvelopeSentinels(content: string): string {
218 const zwsp = '\u200B';
219 return content
220 .replace(/═══ BEGIN UNTRUSTED WEB CONTENT ═══/g, `═══ BEGIN UNTRUSTED WEB C${zwsp}ONTENT ═══`)
221 .replace(/═══ END UNTRUSTED WEB CONTENT ═══/g, `═══ END UNTRUSTED WEB C${zwsp}ONTENT ═══`);
224/**
225 * Wrap page content in a trust boundary envelope for scoped tokens.
226 * Escapes envelope markers in content to prevent boundary escape attacks.
227 */
228export function wrapUntrustedPageContent(
229 content: string,
230 command: string,
231 filterWarnings?: string[],
232): string {
233 const safeContent = escapeEnvelopeSentinels(content);
235 const parts: string[] = [];
237 if (filterWarnings && filterWarnings.length > 0) {
238 parts.push(`⚠ CONTENT WARNINGS: ${filterWarnings.join('; ')}`);
239 }
241 parts.push(ENVELOPE_BEGIN);
242 parts.push(safeContent);
243 parts.push(ENVELOPE_END);
245 return parts.join('\n');
248// ─── Content Filter Hooks (Layer 3) ─────────────────────────────
250export interface ContentFilterResult {
251 safe: boolean;
252 warnings: string[];
253 blocked?: boolean;
254 message?: string;
257export type ContentFilter = (
258 content: string,
259 url: string,
260 command: string,
261) => ContentFilterResult;
263const registeredFilters: ContentFilter[] = [];
265export function registerContentFilter(filter: ContentFilter): void {
266 registeredFilters.push(filter);
269export function clearContentFilters(): void {
270 registeredFilters.length = 0;
273/** Get current filter mode from env */
274export function getFilterMode(): 'off' | 'warn' | 'block' {
275 const mode = process.env.BROWSE_CONTENT_FILTER?.toLowerCase();
276 if (mode === 'off' || mode === 'block') return mode;
277 return 'warn'; // default
280/**
281 * Run all registered content filters against content.
282 * Returns aggregated result with all warnings.
283 */
284export function runContentFilters(
285 content: string,
286 url: string,
287 command: string,
288): ContentFilterResult {
289 const mode = getFilterMode();
290 if (mode === 'off') {
291 return { safe: true, warnings: [] };
292 }
294 const allWarnings: string[] = [];
295 let blocked = false;
297 for (const filter of registeredFilters) {
298 const result = filter(content, url, command);
299 if (!result.safe) {
300 allWarnings.push(...result.warnings);
301 if (mode === 'block') {
302 blocked = true;
303 }
304 }
305 }
307 if (blocked && allWarnings.length > 0) {
308 return {
309 safe: false,
310 warnings: allWarnings,
311 blocked: true,
312 message: `Content blocked: ${allWarnings.join('; ')}`,
313 };
314 }
316 return {
317 safe: allWarnings.length === 0,
318 warnings: allWarnings,
319 };
322// ─── Built-in URL Blocklist Filter ──────────────────────────────
324const BLOCKLIST_DOMAINS = [
325 'requestbin.com',
326 'pipedream.com',
327 'webhook.site',
328 'hookbin.com',
329 'requestcatcher.com',
330 'burpcollaborator.net',
331 'interact.sh',
332 'canarytokens.com',
333 'ngrok.io',
334 'ngrok-free.app',
335];
337/** Check if URL matches any blocklisted exfiltration domain */
338export function urlBlocklistFilter(content: string, url: string, _command: string): ContentFilterResult {
339 const warnings: string[] = [];
341 // Matching is case-insensitive. Hostnames and URL schemes are
342 // case-insensitive (RFC 3986), so an uppercased sink like
343 // https://WEBHOOK.SITE/x must not slip past the blocklist. Normalize the
344 // haystack to lowercase; BLOCKLIST_DOMAINS entries are lowercase by
345 // construction, so a direct compare then holds.
347 // Check page URL
348 const normalizedUrl = url.toLowerCase();
349 for (const domain of BLOCKLIST_DOMAINS) {
350 if (normalizedUrl.includes(domain)) {
351 warnings.push(`Page URL matches blocklisted domain: ${domain}`);
352 }
353 }
355 // Check for blocklisted URLs in content (links, form actions). The `i` flag
356 // keeps an uppercased scheme (HTTPS://) from evading URL extraction.
357 const urlPattern = /https?:\/\/[^\s"'<>]+/gi;
358 const contentUrls = content.match(urlPattern) || [];
359 for (const contentUrl of contentUrls) {
360 const normalizedContentUrl = contentUrl.toLowerCase();
361 for (const domain of BLOCKLIST_DOMAINS) {
362 if (normalizedContentUrl.includes(domain)) {
363 warnings.push(`Content contains blocklisted URL: ${contentUrl.slice(0, 100)}`);
364 break;
365 }
366 }
367 }
369 return { safe: warnings.length === 0, warnings };
372// Register the built-in filter on module load
373registerContentFilter(urlBlocklistFilter);