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:
@@ -41,7 +41,7 @@ The image protection system provides multiple layers of security to prevent unau
|
||||
|
||||
### useDevToolsProtection Hook
|
||||
|
||||
Detects when developer tools are opened using multiple methods:
|
||||
Detects when developer tools are opened, passively:
|
||||
|
||||
```typescript
|
||||
import { useDevToolsProtection } from '../hooks/useDevToolsProtection';
|
||||
@@ -56,12 +56,14 @@ const { isDetected, reset } = useDevToolsProtection({
|
||||
```
|
||||
|
||||
**Detection Methods:**
|
||||
- Timing-based detection (console.log performance)
|
||||
- Window size monitoring
|
||||
- Console usage tracking
|
||||
- Debugger statement timing
|
||||
- Element inspection detection
|
||||
- Function toString override
|
||||
- Window size monitoring (docked DevTools panel)
|
||||
- DevTools shortcut keys (F12, Ctrl+Shift+I/J/C, Ctrl+U)
|
||||
|
||||
Detection is deliberately passive: `debugger` traps, console overrides and
|
||||
`console.clear()` probes were removed because they freeze/spam the page for
|
||||
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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface UseDevToolsProtectionOptions {
|
||||
enabled: boolean;
|
||||
@@ -8,202 +8,68 @@ interface UseDevToolsProtectionOptions {
|
||||
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) => {
|
||||
const detectionTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastConsoleCountRef = useRef(0);
|
||||
const isDetectedRef = useRef(false);
|
||||
|
||||
const handleDevToolsDetected = useCallback(() => {
|
||||
if (isDetectedRef.current) return; // Prevent multiple triggers
|
||||
isDetectedRef.current = true;
|
||||
// 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;
|
||||
|
||||
console.clear(); // Clear any console output
|
||||
options.onDevToolsDetected?.();
|
||||
|
||||
if (options.redirectOnDetection) {
|
||||
const redirectUrl = options.redirectUrl || '/';
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectUrl;
|
||||
}, 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) => {
|
||||
consoleCount++;
|
||||
return originalError.apply(console, args);
|
||||
};
|
||||
|
||||
console.warn = (...args) => {
|
||||
consoleCount++;
|
||||
return originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
console.clear();
|
||||
}, [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
|
||||
]);
|
||||
const { enabled, detectionSensitivity } = options;
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) return;
|
||||
if (!enabled) return;
|
||||
|
||||
// Disable right-click globally when DevTools protection is enabled
|
||||
const handleGlobalRightClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
const handleDevToolsDetected = () => {
|
||||
if (isDetectedRef.current) return; // Prevent multiple triggers
|
||||
isDetectedRef.current = true;
|
||||
|
||||
optionsRef.current.onDevToolsDetected?.();
|
||||
|
||||
if (optionsRef.current.redirectOnDetection) {
|
||||
const redirectUrl = optionsRef.current.redirectUrl || '/';
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectUrl;
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Block F12 and other DevTools shortcuts
|
||||
// A docked DevTools panel eats a chunk of the viewport without changing
|
||||
// the window's outer size. Browser chrome (toolbar + bookmarks bar) alone
|
||||
// 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;
|
||||
|
||||
const detectByWindowSize = () => {
|
||||
if (window.outerHeight - window.innerHeight > threshold ||
|
||||
window.outerWidth - window.innerWidth > threshold) {
|
||||
handleDevToolsDetected();
|
||||
}
|
||||
};
|
||||
|
||||
// Block F12 and the DevTools shortcuts. Only these exact combinations are
|
||||
// touched — every other key event passes through untouched.
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 'F12' ||
|
||||
@@ -217,27 +83,31 @@ export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) =>
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('contextmenu', handleGlobalRightClick);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
|
||||
// Start detection interval
|
||||
const interval = options.detectionSensitivity === 'high' ? 500 :
|
||||
options.detectionSensitivity === 'medium' ? 1000 : 2000;
|
||||
|
||||
detectionTimerRef.current = setInterval(runDetection, interval);
|
||||
|
||||
// Initial detection
|
||||
runDetection();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleGlobalRightClick);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
|
||||
if (detectionTimerRef.current) {
|
||||
clearInterval(detectionTimerRef.current);
|
||||
// Right-click is blocked on the images themselves only. Blocking it
|
||||
// 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
|
||||
// `disable_right_click` setting is what covers the whole page).
|
||||
const handleImageContextMenu = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tagName = target?.tagName;
|
||||
if (tagName === 'IMG' || tagName === 'CANVAS' || tagName === 'VIDEO') {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
}, [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 {
|
||||
isDetected: isDetectedRef.current,
|
||||
|
||||
Reference in New Issue
Block a user