fix(gallery): stop devtools protection from breaking the whole page

With enable_devtools_protection on, every click on the gallery failed and
trivial script evaluation hung -- confirmed on two independent events. A
guest with DevTools open for an unrelated reason (network tab, a CDP-attaching
extension) got a silently unresponsive gallery with no error shown.

Mechanisms found, all in the hook (both callsites were innocent):

1. detectByDebugger ran a bare `debugger;` on every tick at medium/high
   sensitivity -- and the per-event flag maps to medium. With any debugger or
   CDP client attached the renderer paused there continuously. This is why
   Runtime.evaluate hung on 1+1 and clicks reported their target gone.
2. Four separate detectors called console.clear() -- the observed clear loop.
3. handleDevToolsDetected was useCallback([options]) over a fresh object
   literal, so runDetection changed identity every render and the effect tore
   down, rebound and re-ran detection on every render -- a 1s interval turned
   into a tight loop.
4. detectByConsole monkey-patched console.log/error/warn/info every tick
   inside a try/catch that swallowed throws, so a throw between patch and
   restore left the guest's console permanently hijacked.
5. contextmenu was preventDefault'd document-wide regardless of target,
   killing the menu on text, links and form fields -- disable_right_click is
   the separate setting meant to cover the whole page.

Kept: the DevTools shortcut keys (only those exact combos; everything else
passes through), the docked-DevTools viewport heuristic as a pure measurement
on resize plus one check at mount, right-click blocked on IMG/CANVAS/VIDEO
targets only. The public API (onDevToolsDetected, redirectOnDetection,
redirectUrl, isDetected, reset) is unchanged, so PhotoLightbox needed no edit.

Removed: debugger traps, console.clear, console monkey-patching, the
timing/element/toString probes, the polling interval, document-wide
contextmenu blocking. Undocked DevTools is now deliberately undetectable --
every technique that catches it costs the page its responsiveness for
everyone. This is a deterrent, not a security boundary.

Also raised the viewport threshold (100 -> 160/200/260 by sensitivity):
browser chrome with a bookmarks bar is ~140px, so the old check false-positived
on ordinary windows, which at protectionLevel 'maximum' redirected legitimate
guests off the gallery.

Refs testplan REPORT.md #3 (Part 4).
This commit is contained in:
Paul Nothaft
2026-09-01 16:30:06 +02:00
parent c6cb01865e
commit 9d4bd7ab30
3 changed files with 244 additions and 214 deletions
+9 -7
View File
@@ -41,7 +41,7 @@ The image protection system provides multiple layers of security to prevent unau
### useDevToolsProtection Hook ### useDevToolsProtection Hook
Detects when developer tools are opened using multiple methods: Detects when developer tools are opened, passively:
```typescript ```typescript
import { useDevToolsProtection } from '../hooks/useDevToolsProtection'; import { useDevToolsProtection } from '../hooks/useDevToolsProtection';
@@ -56,12 +56,14 @@ const { isDetected, reset } = useDevToolsProtection({
``` ```
**Detection Methods:** **Detection Methods:**
- Timing-based detection (console.log performance) - Window size monitoring (docked DevTools panel)
- Window size monitoring - DevTools shortcut keys (F12, Ctrl+Shift+I/J/C, Ctrl+U)
- Console usage tracking
- Debugger statement timing Detection is deliberately passive: `debugger` traps, console overrides and
- Element inspection detection `console.clear()` probes were removed because they freeze/spam the page for
- Function toString override every visitor who has DevTools open, breaking navigation, buttons and forms —
far beyond the image protection this is meant to be. Undocked DevTools is not
detected. This is a deterrent, not a security boundary.
### Enhanced useImageProtection Hook ### Enhanced useImageProtection Hook
@@ -0,0 +1,158 @@
/**
* DevTools protection must never break the gallery for a legitimate guest.
*
* The original implementation polled a `debugger;` trap, monkey-patched the
* console and called `console.clear()` on every tick. With a debugger/CDP
* client attached the page froze continuously, so every click, link and form
* on the gallery died — a guest with DevTools open for any unrelated reason
* got a silently unresponsive page (QA P4-A.06, reproduced on two events).
*
* These tests pin the shape of the deterrent: passive detection, no console
* tampering, no `debugger`, and no document-wide interaction interception.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import fs from 'fs';
import path from 'path';
import { useDevToolsProtection } from '../useDevToolsProtection';
// Comments explain the removed techniques by name, so strip them before
// asserting that the code itself no longer uses any of them.
const hookCode = fs
.readFileSync(path.join(__dirname, '..', 'useDevToolsProtection.ts'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '');
describe('useDevToolsProtection', () => {
let addSpy: ReturnType<typeof vi.spyOn>;
let removeSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
addSpy = vi.spyOn(document, 'addEventListener');
removeSpy = vi.spyOn(document, 'removeEventListener');
});
afterEach(() => {
vi.restoreAllMocks();
});
const documentEventTypes = () => addSpy.mock.calls.map((call) => call[0]);
// React attaches its own delegated listeners to the render container, so
// compare against a disabled render to isolate what the hook itself adds.
const listenersAddedByHook = (options: Parameters<typeof useDevToolsProtection>[0]) => {
renderHook(() => useDevToolsProtection({ ...options, enabled: false }));
const baseline = new Set(documentEventTypes());
addSpy.mockClear();
renderHook(() => useDevToolsProtection(options));
return documentEventTypes().filter((type) => !baseline.has(type));
};
it('never uses a debugger trap or clears the console', () => {
expect(hookCode).not.toMatch(/(^|[^A-Za-z])debugger\s*;/);
expect(hookCode).not.toContain('console.clear');
});
it('only listens for contextmenu and keydown on the document', () => {
const added = listenersAddedByHook({ enabled: true, detectionSensitivity: 'medium' });
expect(new Set(added)).toEqual(new Set(['contextmenu', 'keydown']));
});
it('does not intercept generic interaction events', () => {
const added = listenersAddedByHook({ enabled: true, detectionSensitivity: 'high' });
['click', 'mousedown', 'mouseup', 'pointerdown', 'selectstart', 'dragstart', 'copy'].forEach(
(type) => expect(added).not.toContain(type)
);
});
it('leaves clicks on ordinary page elements working', () => {
renderHook(() => useDevToolsProtection({ enabled: true }));
const button = document.createElement('button');
document.body.appendChild(button);
const onClick = vi.fn();
button.addEventListener('click', onClick);
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
button.dispatchEvent(event);
expect(onClick).toHaveBeenCalledTimes(1);
expect(event.defaultPrevented).toBe(false);
button.remove();
});
it('blocks right-click on images only, not on the rest of the page', () => {
renderHook(() => useDevToolsProtection({ enabled: true }));
const image = document.createElement('img');
const paragraph = document.createElement('p');
document.body.append(image, paragraph);
const onImage = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
image.dispatchEvent(onImage);
expect(onImage.defaultPrevented).toBe(true);
const onText = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
paragraph.dispatchEvent(onText);
expect(onText.defaultPrevented).toBe(false);
image.remove();
paragraph.remove();
});
it('does not replace or clear console methods while enabled', () => {
const clearSpy = vi.spyOn(console, 'clear').mockImplementation(() => {});
const originalLog = console.log;
const originalWarn = console.warn;
const { rerender } = renderHook(() => useDevToolsProtection({ enabled: true }));
rerender();
window.dispatchEvent(new Event('resize'));
expect(clearSpy).not.toHaveBeenCalled();
expect(console.log).toBe(originalLog);
expect(console.warn).toBe(originalWarn);
});
it('registers nothing when disabled', () => {
renderHook(() => useDevToolsProtection({ enabled: false }));
expect(documentEventTypes()).not.toContain('contextmenu');
expect(documentEventTypes()).not.toContain('keydown');
});
it('removes its listeners on unmount', () => {
const { unmount } = renderHook(() => useDevToolsProtection({ enabled: true }));
unmount();
const removed = removeSpy.mock.calls.map((call) => call[0]);
expect(removed).toContain('contextmenu');
expect(removed).toContain('keydown');
});
it('reports detection once for a DevTools shortcut and blocks the key', () => {
const onDevToolsDetected = vi.fn();
renderHook(() => useDevToolsProtection({ enabled: true, onDevToolsDetected }));
const first = new KeyboardEvent('keydown', { key: 'F12', bubbles: true, cancelable: true });
document.body.dispatchEvent(first);
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'F12', bubbles: true, cancelable: true })
);
expect(first.defaultPrevented).toBe(true);
expect(onDevToolsDetected).toHaveBeenCalledTimes(1);
});
it('lets ordinary keystrokes through', () => {
renderHook(() => useDevToolsProtection({ enabled: true }));
const typed = new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true });
document.body.dispatchEvent(typed);
expect(typed.defaultPrevented).toBe(false);
});
});
+68 -198
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef } from 'react'; import { useEffect, useRef } from 'react';
interface UseDevToolsProtectionOptions { interface UseDevToolsProtectionOptions {
enabled: boolean; enabled: boolean;
@@ -8,202 +8,68 @@ interface UseDevToolsProtectionOptions {
detectionSensitivity?: 'low' | 'medium' | 'high'; detectionSensitivity?: 'low' | 'medium' | 'high';
} }
/**
* DevTools deterrent for protected galleries. This is a deterrent, never a
* security boundary — anything running in the page can be disabled by whoever
* is determined enough to open DevTools in the first place.
*
* It therefore stays passive. The previous implementation polled a battery of
* "aggressive" detectors (a bare `debugger;` trap, console monkey-patching,
* `console.clear()`/`console.log()` probes) every tick. With any debugger or
* CDP client attached, the `debugger` trap paused the page continuously, so
* *every* interaction died — navigation, buttons, forms, links — not just
* image saving, and the guest's console was wiped in a loop. A guest who had
* DevTools open for an unrelated reason got a silently unresponsive gallery.
*
* What is left: the docked-DevTools viewport heuristic (a pure measurement,
* no side effects) and the DevTools shortcut keys. Undocked DevTools is
* deliberately not detected — every technique that catches it costs the page
* its responsiveness for everyone.
*/
export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) => { export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) => {
const detectionTimerRef = useRef<NodeJS.Timeout | null>(null);
const lastConsoleCountRef = useRef(0);
const isDetectedRef = useRef(false); const isDetectedRef = useRef(false);
const handleDevToolsDetected = useCallback(() => { // Options are rebuilt on every render by the callsites. Read them through a
// ref so the effect below binds its listeners once per `enabled` change
// instead of tearing them down and re-running detection on every render.
const optionsRef = useRef(options);
optionsRef.current = options;
const { enabled, detectionSensitivity } = options;
useEffect(() => {
if (!enabled) return;
const handleDevToolsDetected = () => {
if (isDetectedRef.current) return; // Prevent multiple triggers if (isDetectedRef.current) return; // Prevent multiple triggers
isDetectedRef.current = true; isDetectedRef.current = true;
console.clear(); // Clear any console output optionsRef.current.onDevToolsDetected?.();
options.onDevToolsDetected?.();
if (options.redirectOnDetection) { if (optionsRef.current.redirectOnDetection) {
const redirectUrl = options.redirectUrl || '/'; const redirectUrl = optionsRef.current.redirectUrl || '/';
setTimeout(() => { setTimeout(() => {
window.location.href = redirectUrl; window.location.href = redirectUrl;
}, 100); }, 100);
} }
}, [options]);
const detectByTiming = useCallback(() => {
const threshold = options.detectionSensitivity === 'high' ? 100 :
options.detectionSensitivity === 'medium' ? 200 : 500;
const start = performance.now();
// This will be slow if DevTools is open due to console.log overhead
console.log('%c', 'color: transparent; font-size: 0px;');
console.clear();
const end = performance.now();
if (end - start > threshold) {
handleDevToolsDetected();
}
}, [options.detectionSensitivity, handleDevToolsDetected]);
const detectByWindowSize = useCallback(() => {
const heightThreshold = window.screen.height - window.innerHeight > 200;
const widthThreshold = window.screen.width - window.innerWidth > 200;
// Check if the available space suggests DevTools is open
if (heightThreshold || widthThreshold) {
// Additional check to avoid false positives (mobile keyboards, etc.)
if (window.outerHeight - window.innerHeight > 100 ||
window.outerWidth - window.innerWidth > 100) {
handleDevToolsDetected();
}
}
}, [handleDevToolsDetected]);
const detectByConsole = useCallback(() => {
let consoleCount = 0;
// Override console methods to detect usage
const originalLog = console.log;
const originalError = console.error;
const originalWarn = console.warn;
const originalInfo = console.info;
console.log = (...args) => {
consoleCount++;
return originalLog.apply(console, args);
}; };
console.error = (...args) => { // A docked DevTools panel eats a chunk of the viewport without changing
consoleCount++; // the window's outer size. Browser chrome (toolbar + bookmarks bar) alone
return originalError.apply(console, args); // accounts for ~140px, so the threshold stays well above that to avoid
}; // punishing a guest for having a bookmarks bar.
const threshold = detectionSensitivity === 'high' ? 160 :
detectionSensitivity === 'low' ? 260 : 200;
console.warn = (...args) => { const detectByWindowSize = () => {
consoleCount++; if (window.outerHeight - window.innerHeight > threshold ||
return originalWarn.apply(console, args); window.outerWidth - window.innerWidth > threshold) {
};
console.info = (...args) => {
consoleCount++;
return originalInfo.apply(console, args);
};
// Test if console is being actively used
console.log('%cDevTools Detection', 'color: transparent; font-size: 0px;');
// If console count increased significantly, DevTools might be open
if (consoleCount > lastConsoleCountRef.current + 2) {
handleDevToolsDetected(); handleDevToolsDetected();
} }
lastConsoleCountRef.current = consoleCount;
// Restore original console methods
console.log = originalLog;
console.error = originalError;
console.warn = originalWarn;
console.info = originalInfo;
}, [handleDevToolsDetected]);
const detectByDebugger = useCallback(() => {
// Use debugger statement timing to detect DevTools
const start = Date.now();
// This will pause execution if DevTools is open
try {
debugger;
} catch (e) {
// Ignore errors
}
const end = Date.now();
// If there was a significant delay, DevTools was open
if (end - start > 100) {
handleDevToolsDetected();
}
}, [handleDevToolsDetected]);
const detectByElement = useCallback(() => {
// Create a fake element that DevTools might interact with
const element = document.createElement('div');
element.id = '__devtools_detector__';
let detected = false;
// Override toString to detect if DevTools inspects the element
Object.defineProperty(element, 'id', {
get() {
detected = true;
return '__devtools_detector__';
},
configurable: true
});
// Trigger the getter
console.log(element);
console.clear();
if (detected) {
handleDevToolsDetected();
}
}, [handleDevToolsDetected]);
const detectByToString = useCallback(() => {
// Use function toString override to detect DevTools
const func = () => {};
func.toString = () => {
handleDevToolsDetected();
return 'function () { [native code] }';
}; };
console.log('%c', func); // Block F12 and the DevTools shortcuts. Only these exact combinations are
console.clear(); // touched — every other key event passes through untouched.
}, [handleDevToolsDetected]);
const runDetection = useCallback(() => {
if (!options.enabled || isDetectedRef.current) return;
try {
// Run multiple detection methods
detectByTiming();
detectByWindowSize();
detectByConsole();
// More aggressive detection for higher sensitivity
if (options.detectionSensitivity === 'medium' || options.detectionSensitivity === 'high') {
detectByDebugger();
detectByElement();
}
// Most aggressive detection
if (options.detectionSensitivity === 'high') {
detectByToString();
}
} catch (error) {
// Silently handle any detection errors
}
}, [
options.enabled,
options.detectionSensitivity,
detectByTiming,
detectByWindowSize,
detectByConsole,
detectByDebugger,
detectByElement,
detectByToString
]);
useEffect(() => {
if (!options.enabled) return;
// Disable right-click globally when DevTools protection is enabled
const handleGlobalRightClick = (e: MouseEvent) => {
e.preventDefault();
return false;
};
// Block F12 and other DevTools shortcuts
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ( if (
e.key === 'F12' || e.key === 'F12' ||
@@ -217,27 +83,31 @@ export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) =>
} }
}; };
document.addEventListener('contextmenu', handleGlobalRightClick); // Right-click is blocked on the images themselves only. Blocking it
document.addEventListener('keydown', handleKeyDown, true); // document-wide also killed the context menu on text, links and form
// fields, which has nothing to do with saving a photo (the event-level
// Start detection interval // `disable_right_click` setting is what covers the whole page).
const interval = options.detectionSensitivity === 'high' ? 500 : const handleImageContextMenu = (e: MouseEvent) => {
options.detectionSensitivity === 'medium' ? 1000 : 2000; const target = e.target as HTMLElement | null;
const tagName = target?.tagName;
detectionTimerRef.current = setInterval(runDetection, interval); if (tagName === 'IMG' || tagName === 'CANVAS' || tagName === 'VIDEO') {
e.preventDefault();
// Initial detection
runDetection();
return () => {
document.removeEventListener('contextmenu', handleGlobalRightClick);
document.removeEventListener('keydown', handleKeyDown, true);
if (detectionTimerRef.current) {
clearInterval(detectionTimerRef.current);
} }
}; };
}, [options.enabled, options.detectionSensitivity, runDetection, handleDevToolsDetected]);
document.addEventListener('contextmenu', handleImageContextMenu);
document.addEventListener('keydown', handleKeyDown, true);
// Docking/undocking DevTools resizes the viewport; the initial call covers
// the case where it was already open when the gallery loaded.
window.addEventListener('resize', detectByWindowSize);
detectByWindowSize();
return () => {
document.removeEventListener('contextmenu', handleImageContextMenu);
document.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('resize', detectByWindowSize);
};
}, [enabled, detectionSensitivity]);
return { return {
isDetected: isDetectedRef.current, isDetected: isDetectedRef.current,