diff --git a/frontend/src/docs/IMAGE_PROTECTION.md b/frontend/src/docs/IMAGE_PROTECTION.md index 45c6ddac..6b811358 100644 --- a/frontend/src/docs/IMAGE_PROTECTION.md +++ b/frontend/src/docs/IMAGE_PROTECTION.md @@ -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 diff --git a/frontend/src/hooks/__tests__/useDevToolsProtection.test.tsx b/frontend/src/hooks/__tests__/useDevToolsProtection.test.tsx new file mode 100644 index 00000000..137eafec --- /dev/null +++ b/frontend/src/hooks/__tests__/useDevToolsProtection.test.tsx @@ -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; + let removeSpy: ReturnType; + + 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[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); + }); +}); diff --git a/frontend/src/hooks/useDevToolsProtection.ts b/frontend/src/hooks/useDevToolsProtection.ts index 3c49c0fe..70ff8a16 100644 --- a/frontend/src/hooks/useDevToolsProtection.ts +++ b/frontend/src/hooks/useDevToolsProtection.ts @@ -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(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,