diff --git a/README.md b/README.md index 5f2cdd7e..31fd6ea3 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,9 @@ Oobee can perform the following to scan the target URL. - To **run** Oobee in **terminal**, run `npm start`. Questions will be prompted to assist you in providing the right inputs. - Results will be compiled in JSON format, followed by generating a HTML report. +- **NEW**: Automatic detection and neutralization of accessibility overlays (UserWay, accessiBe, etc.) for accurate baseline testing. See [OVERLAY-DETECTION.md](./OVERLAY-DETECTION.md) for details. -> NOTE: For your initial scan, there may be some loading time required before use. Oobee will also ask for your name and email address and collect your app usage data to personalise your experience. Your information fully complies with [GovTech’s Privacy Policy](https://www.tech.gov.sg/privacy/). +> NOTE: For your initial scan, there may be some loading time required before use. Oobee will also ask for your name and email address and collect your app usage data to personalise your experience. Your information fully complies with [GovTech's Privacy Policy](https://www.tech.gov.sg/privacy/). #### Delete/Edit Details diff --git a/src/constants/common.ts b/src/constants/common.ts index 65d5fd65..1fa979ea 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -378,6 +378,37 @@ const checkUrlConnectivityWithBrowser = async ( try { const page = await browserContext.newPage(); + // Apply stealth techniques to bypass bot detection + await page.addInitScript(() => { + // Remove webdriver property + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + }); + + // Override plugins to make it look real + Object.defineProperty(navigator, 'plugins', { + get: () => [1, 2, 3, 4, 5], + }); + + // Override languages + Object.defineProperty(navigator, 'languages', { + get: () => ['en-US', 'en'], + }); + + // Mock chrome object + (window as any).chrome = { + runtime: {}, + }; + + // Override permissions + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = (parameters: any) => ( + parameters.name === 'notifications' ? + Promise.resolve({ state: 'denied' } as PermissionStatus) : + originalQuery(parameters) + ); + }); + // Block native Chrome download UI try { const cdp = await browserContext.newCDPSession(page as any); @@ -401,24 +432,20 @@ const checkUrlConnectivityWithBrowser = async ( try { await page.waitForLoadState('networkidle', { timeout: 8000 }); } catch { - consoleLogger.info('networkidle not reached; proceeding with verification GET'); + consoleLogger.info('networkidle not reached; proceeding with page response'); } - // STEP 3: Verify final URL with a GET (follows redirects) + // STEP 3: Get final URL and status from the page navigation + // Note: We skip the verification GET because some sites block API requests + // but allow browser navigation (403 on fetch but 200 on browser navigation) const finalUrl = page.url(); - let verifyResp = response; - try { - verifyResp = await page.request.fetch(finalUrl, { - method: 'GET', - headers: extraHTTPHeaders, - }); - } catch (e) { - consoleLogger.info(`Verification GET failed, falling back to navigation response: ${e.message}`); - } - - // Prefer verification GET; fall back to nav response - const finalStatus = verifyResp?.status?.() ?? response?.status?.() ?? 0; - const headers = (verifyResp?.headers?.() ?? response?.headers?.()) || {}; + const navigationStatus = response?.status?.() ?? 0; + + consoleLogger.info(`Navigation to ${finalUrl} returned status: ${navigationStatus}`); + + // Use navigation response directly + const finalStatus = navigationStatus; + const headers = response?.headers?.() || {}; contentType = headers['content-type'] || ''; if (!isAllowedContentType(contentType)) { diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 8756ef40..c3c654f7 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -270,11 +270,24 @@ export const guiInfoStatusTypes = { DUPLICATE: 'duplicate', }; -let launchOptionsArgs: string[] = []; +let launchOptionsArgs: string[] = [ + // Stealth options to bypass bot detection + '--disable-blink-features=AutomationControlled', + '--disable-features=IsolateOrigins,site-per-process', + '--disable-site-isolation-trials', +]; // Check if running in docker container if (fs.existsSync('/.dockerenv')) { - launchOptionsArgs = ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']; + launchOptionsArgs = [ + '--disable-gpu', + '--no-sandbox', + '--disable-dev-shm-usage', + // Keep stealth options in Docker too + '--disable-blink-features=AutomationControlled', + '--disable-features=IsolateOrigins,site-per-process', + '--disable-site-isolation-trials', + ]; } export const impactOrder = { diff --git a/src/crawlers/commonCrawlerFunc.ts b/src/crawlers/commonCrawlerFunc.ts index c93780b5..95df6198 100644 --- a/src/crawlers/commonCrawlerFunc.ts +++ b/src/crawlers/commonCrawlerFunc.ts @@ -24,6 +24,8 @@ import type { Response as PlaywrightResponse } from 'playwright'; import fs from 'fs'; import { getStoragePath } from '../utils.js'; import path from 'path'; +import { detectOverlaysInDom, type OverlayDetection } from '../overlays/overlayDetector.js'; +import { scrubOverlaysOnPage } from '../overlays/overlayNeutralizer.js'; // types interface AxeResultsWithScreenshot extends AxeResults { @@ -72,6 +74,7 @@ type FilteredResults = { needsReview: ResultCategory; passed: ResultCategory; actualUrl?: string; + overlayDetections?: OverlayDetection[]; }; const truncateHtml = (html: string, maxBytes = 1024, suffix = '…'): string => { @@ -102,6 +105,7 @@ export const filterAxeResults = ( results: AxeResultsWithScreenshot, pageTitle: string, customFlowDetails?: CustomFlowDetails, + overlayDetections?: OverlayDetection[], ): FilteredResults => { const { violations, passes, incomplete, url } = results; @@ -230,6 +234,7 @@ export const filterAxeResults = ( goodToFix, needsReview, passed, + ...(overlayDetections && overlayDetections.length > 0 && { overlayDetections }), }; }; @@ -240,6 +245,7 @@ export const runAxeScript = async ({ customFlowDetails = null, selectors = [], ruleset = [], + crawler = null, }: { includeScreenshots: boolean; page: Page; @@ -247,6 +253,7 @@ export const runAxeScript = async ({ customFlowDetails?: CustomFlowDetails; selectors?: string[]; ruleset?: RuleFlags[]; + crawler?: any; }) => { const browserContext: BrowserContext = page.context(); const requestUrl = page.url(); @@ -328,6 +335,42 @@ export const runAxeScript = async ({ const disableOobee = ruleset.includes(RuleFlags.DISABLE_OOBEE); const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA); + consoleLogger.info(`[overlay-neutralizer] ═══════════════════════════════════════════════`); + consoleLogger.info(`[overlay-neutralizer] Starting overlay detection and neutralization`); + consoleLogger.info(`[overlay-neutralizer] Page URL: ${requestUrl}`); + consoleLogger.info(`[overlay-neutralizer] ═══════════════════════════════════════════════`); + + // Get blocked overlays from network interception (if available) + const getBlockedOverlays = (crawler as any)?.__getBlockedOverlays; + const blockedOverlays = typeof getBlockedOverlays === 'function' ? getBlockedOverlays() : []; + + // Detect overlays in DOM + const domDetections = await detectOverlaysInDom(page); + + // Merge blocked overlays with DOM detections + const overlayDetections = [...blockedOverlays, ...domDetections]; + + // Log overlay detections + if (overlayDetections.length > 0) { + consoleLogger.info( + `[overlay-neutralizer] ⚠️ OVERLAYS FOUND: ${overlayDetections.map(d => d.vendor).join(', ')} on ${requestUrl}`, + ); + overlayDetections.forEach(detection => { + consoleLogger.info( + `[overlay-neutralizer] - ${detection.vendor}: detected by ${detection.detectedBy.join(' & ')}, details: ${detection.details.join(', ')}`, + ); + }); + } else { + consoleLogger.info(`[overlay-neutralizer] ✓ No overlays detected on ${requestUrl}`); + } + + // Scrub overlay DOM elements before running axe + await scrubOverlaysOnPage(page); + + consoleLogger.info(`[overlay-neutralizer] ═══════════════════════════════════════════════`); + consoleLogger.info(`[overlay-neutralizer] Overlay neutralization complete, proceeding with axe scan`); + consoleLogger.info(`[overlay-neutralizer] ═══════════════════════════════════════════════`); + const gradingReadabilityFlag = await extractAndGradeText(page); // Ensure flag is obtained before proceeding await playwrightUtils.injectFile(page, axeScript); @@ -473,7 +516,7 @@ export const runAxeScript = async ({ } } - return filterAxeResults(results, pageTitle, customFlowDetails); + return filterAxeResults(results, pageTitle, customFlowDetails, overlayDetections); }; export const createCrawleeSubFolders = async ( diff --git a/src/crawlers/crawlDomain.ts b/src/crawlers/crawlDomain.ts index ec6c2336..cc5ee326 100644 --- a/src/crawlers/crawlDomain.ts +++ b/src/crawlers/crawlDomain.ts @@ -11,6 +11,7 @@ import { shouldSkipClickDueToDisallowedHref, shouldSkipDueToUnsupportedContent, } from './commonCrawlerFunc.js'; +import { attachOverlayNeutralization } from '../overlays/overlayNeutralizer.js'; import constants, { UrlsCrawled, blackListedFileExtensions, @@ -381,7 +382,50 @@ const crawlDomain = async ({ requestQueue, postNavigationHooks: [ async crawlingContext => { - const { page, request } = crawlingContext; + const { page, request, crawler } = crawlingContext; + + // Apply stealth techniques to bypass bot detection + await page.addInitScript(() => { + // Remove webdriver property + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + }); + + // Override plugins to make it look real + Object.defineProperty(navigator, 'plugins', { + get: () => [1, 2, 3, 4, 5], + }); + + // Override languages + Object.defineProperty(navigator, 'languages', { + get: () => ['en-US', 'en'], + }); + + // Mock chrome object + (window as any).chrome = { + runtime: {}, + }; + + // Override permissions + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = (parameters: any) => ( + parameters.name === 'notifications' ? + Promise.resolve({ state: 'denied' } as PermissionStatus) : + originalQuery(parameters) + ); + }); + + // Attach overlay neutralization to the browser context on first page + // This is done per-page but context.route() calls are idempotent + const context = page.context(); + if (context && typeof context.route === 'function') { + consoleLogger.info(`[overlay-neutralizer] 🔧 Attaching overlay neutralization to browser context for: ${page.url()}`); + const getBlockedOverlays = attachOverlayNeutralization(context); + // Store in crawler state so runAxeScript can access it + (crawler as any).__getBlockedOverlays = getBlockedOverlays; + } else { + consoleLogger.warn('[overlay-neutralizer] ⚠️ Unable to attach overlay neutralization - no context.route() available'); + } await page.evaluate(() => { return new Promise(resolve => { @@ -586,7 +630,7 @@ const crawlDomain = async ({ return; } - const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset }); + const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset, crawler }); if (isRedirected) { const isLoadedUrlInCrawledUrls = urlsCrawled.scanned.some( diff --git a/src/crawlers/crawlSitemap.ts b/src/crawlers/crawlSitemap.ts index 3150266b..d48f2f3b 100644 --- a/src/crawlers/crawlSitemap.ts +++ b/src/crawlers/crawlSitemap.ts @@ -23,10 +23,11 @@ import { } from '../constants/common.js'; import { areLinksEqual, isWhitelistedContentType, register } from '../utils.js'; import { handlePdfDownload, runPdfScan, mapPdfScanResults, doPdfScreenshots } from './pdfScanFunc.js'; -import { guiInfoLog } from '../logs.js'; +import { guiInfoLog, consoleLogger } from '../logs.js'; import { ViewportSettingsClass } from '../combine.js'; import * as path from 'path'; import fsp from 'fs/promises'; +import { attachOverlayNeutralization } from '../overlays/overlayNeutralizer.js'; const crawlSitemap = async ({ sitemapUrl, @@ -146,7 +147,50 @@ const crawlSitemap = async ({ }, requestList, postNavigationHooks: [ - async ({ page }) => { + async ({ page, crawler }) => { + // Apply stealth techniques to bypass bot detection + await page.addInitScript(() => { + // Remove webdriver property + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + }); + + // Override plugins to make it look real + Object.defineProperty(navigator, 'plugins', { + get: () => [1, 2, 3, 4, 5], + }); + + // Override languages + Object.defineProperty(navigator, 'languages', { + get: () => ['en-US', 'en'], + }); + + // Mock chrome object + (window as any).chrome = { + runtime: {}, + }; + + // Override permissions + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = (parameters: any) => ( + parameters.name === 'notifications' ? + Promise.resolve({ state: 'denied' } as PermissionStatus) : + originalQuery(parameters) + ); + }); + + // Attach overlay neutralization to the browser context on first page + // This is done per-page but context.route() calls are idempotent + const context = page.context(); + if (context && typeof context.route === 'function') { + consoleLogger.info(`[overlay-neutralizer] 🔧 Attaching overlay neutralization to browser context for: ${page.url()}`); + const getBlockedOverlays = attachOverlayNeutralization(context); + // Store in crawler state so runAxeScript can access it + (crawler as any).__getBlockedOverlays = getBlockedOverlays; + } else { + consoleLogger.warn('[overlay-neutralizer] ⚠️ Unable to attach overlay neutralization - no context.route() available'); + } + try { // Wait for a quiet period in the DOM, but with safeguards await page.evaluate(() => { @@ -313,7 +357,7 @@ const crawlSitemap = async ({ return; } - const results = await runAxeScript({ includeScreenshots, page, randomToken }); + const results = await runAxeScript({ includeScreenshots, page, randomToken, crawler }); guiInfoLog(guiInfoStatusTypes.SCANNED, { numScanned: urlsCrawled.scanned.length, diff --git a/src/overlays/overlayDetector.ts b/src/overlays/overlayDetector.ts new file mode 100644 index 00000000..02ae1b8d --- /dev/null +++ b/src/overlays/overlayDetector.ts @@ -0,0 +1,128 @@ +import type { Page } from 'playwright'; +import { overlayVendors } from './overlayVendors.js'; +import { consoleLogger } from '../logs.js'; + +export interface OverlayDetection { + vendor: string; + detectedBy: ('dom' | 'global' | 'network-blocking')[]; + details: string[]; +} + +/** + * Detect overlays that are present in the DOM or window globals of the current page. + * + * Intended for: + * - Annotating scan results (e.g. in Oobee's per-page JSON output). + * - Debugging whether an overlay would have been active. + */ +export async function detectOverlaysInDom(page: Page): Promise { + const detections: OverlayDetection[] = []; + const pageUrl = page.url(); + + consoleLogger.info(`[overlay-detector] 🔍 Starting overlay detection on: ${pageUrl}`); + + // DOM-based detection + consoleLogger.info('[overlay-detector] Checking DOM for overlay signatures...'); + const domResults = await page.evaluate( + vendors => { + const results: { vendor: string; signatures: string[] }[] = []; + + vendors.forEach((v: { name: string; domSignatures: string[] }) => { + const matched: string[] = []; + v.domSignatures.forEach((sel: string) => { + try { + if (document.querySelector(sel)) { + matched.push(sel); + } + } catch { + // ignore invalid selectors + } + }); + if (matched.length > 0) { + results.push({ vendor: v.name, signatures: matched }); + } + }); + + return results; + }, + overlayVendors.map(v => ({ name: v.name, domSignatures: v.domSignatures })), + ); + + domResults.forEach(r => { + consoleLogger.info( + `[overlay-detector] 🎯 DOM detection: Found ${r.vendor} via selectors: ${r.signatures.join(', ')}`, + ); + detections.push({ + vendor: r.vendor, + detectedBy: ['dom'], + details: r.signatures, + }); + }); + + if (domResults.length === 0) { + consoleLogger.info('[overlay-detector] ✓ No overlays detected in DOM'); + } + + // Global object detection + consoleLogger.info('[overlay-detector] Checking window globals for overlay objects...'); + const globalResults = await page.evaluate( + vendors => { + const found: { vendor: string; globals: string[] }[] = []; + vendors.forEach((v: { name: string; globalObjects: string[] }) => { + const matched: string[] = []; + v.globalObjects.forEach((g: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = window as any; + if (typeof w[g] !== 'undefined') { + matched.push(g); + } + }); + if (matched.length > 0) { + found.push({ vendor: v.name, globals: matched }); + } + }); + return found; + }, + overlayVendors.map(v => ({ name: v.name, globalObjects: v.globalObjects })), + ); + + globalResults.forEach(r => { + const existing = detections.find(d => d.vendor === r.vendor); + if (existing) { + consoleLogger.info( + `[overlay-detector] 🎯 Global detection: ${r.vendor} also found via globals: ${r.globals.join(', ')}`, + ); + if (!existing.detectedBy.includes('global')) { + existing.detectedBy.push('global'); + } + existing.details.push(...r.globals); + } else { + consoleLogger.info( + `[overlay-detector] 🎯 Global detection: Found ${r.vendor} via globals: ${r.globals.join(', ')}`, + ); + detections.push({ + vendor: r.vendor, + detectedBy: ['global'], + details: r.globals, + }); + } + }); + + if (globalResults.length === 0) { + consoleLogger.info('[overlay-detector] ✓ No overlays detected in window globals'); + } + + // Final summary + if (detections.length > 0) { + const vendorNames = detections.map(d => d.vendor).join(', '); + consoleLogger.info( + `[overlay-detector] 📊 SUMMARY: Detected ${detections.length} overlay vendor(s): ${vendorNames}`, + ); + } else { + consoleLogger.info( + '[overlay-detector] ✅ SUMMARY: No accessibility overlays detected on this page', + ); + } + + return detections; +} diff --git a/src/overlays/overlayNeutralizer.ts b/src/overlays/overlayNeutralizer.ts new file mode 100644 index 00000000..db27bdca --- /dev/null +++ b/src/overlays/overlayNeutralizer.ts @@ -0,0 +1,224 @@ +import type { BrowserContext, Page } from 'playwright'; +import { getAllOverlayUrlPatterns, overlayVendors } from './overlayVendors.js'; +import { consoleLogger } from '../logs.js'; +import type { OverlayDetection } from './overlayDetector.js'; + +/** + * Utility: very simple glob matcher for patterns like **://host/** and *. + * This is not used by Playwright routing (Playwright has its own matcher), + * but we reuse it for diagnostics. + */ +export function urlMatchesPattern(url: string, pattern: string): boolean { + // Escape regex special chars except * which we treat as a wildcard + const escaped = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '.*') + .replace(/\*/g, '[^/]*'); + const regex = new RegExp(`^${escaped}$`); + return regex.test(url); +} + +/** + * Attach network-level blocking of known overlay scripts to a Playwright BrowserContext. + * + * This should be called immediately after creating the BrowserContext and before + * navigating to any pages. + * + * Returns a function that can be called to get the list of blocked overlays. + */ +export function attachOverlayNeutralization(context: BrowserContext): () => OverlayDetection[] { + const patterns = getAllOverlayUrlPatterns(); + const blockedOverlays = new Set(); + + patterns.forEach(pattern => { + context.route(pattern, route => { + const url = route.request().url(); + + // Identify which vendor this URL belongs to + const vendor = overlayVendors.find(v => v.urlPatterns.some(p => urlMatchesPattern(url, p))); + + if (vendor) { + blockedOverlays.add(vendor.name); + consoleLogger.info(`[overlay-neutralizer] 🚫 Blocking ${vendor.name} overlay: ${url}`); + } else { + consoleLogger.info(`[overlay-neutralizer] Blocking overlay resource: ${url}`); + } + + return route.abort(); + }); + }); + + // Optional diagnostics: log potential overlay requests + context.on('request', req => { + const url = req.url(); + if (patterns.some(p => urlMatchesPattern(url, p))) { + consoleLogger.info(`[overlay-neutralizer] Overlay request detected: ${url}`); + } + }); + + // Return a function that provides the list of blocked overlays + return () => { + return Array.from(blockedOverlays).map(vendorName => ({ + vendor: vendorName, + detectedBy: ['network-blocking'], + details: [`Blocked at network level before script could load`], + })); + }; +} + +/** + * Fallback DOM scrubber. + * + * Use this only when you cannot block scripts at the network level. + * This is intentionally conservative: it removes obvious widget DOM roots, + * relaxes aria-hidden on containers that wrap main content, and restores + * body scrolling if disabled. + */ +export async function scrubOverlaysOnPage(page: Page): Promise { + consoleLogger.info('[overlay-neutralizer] 🧹 Starting DOM scrubbing for overlay elements...'); + + const scrubbingResult = await page.evaluate(() => { + const results = { + removedElements: 0, + fixedAriaHidden: 0, + restoredScrolling: false, + removedSelectors: [] as string[], + }; + + try { + const vendorSignatures = [ + '#userwayAccessibilityIcon', + '.userway', + '.uwy', + '[data-userway-widget]', + 'script#a11yWidgetSrc', + '#acsb-widget', + '.acsb-widget', + 'iframe#acsb-iframe', + '#ew_widget', + '.ew-accessibility-menu', + '[data-equalweb]', + '#ae-toolbar', + '.ae-toolbar', + '#monsido_tooltip_wrapper', + '.monsido-toolbar', + ]; + + vendorSignatures.forEach(sel => { + try { + const elements = document.querySelectorAll(sel); + if (elements.length > 0) { + results.removedSelectors.push(sel); + results.removedElements += elements.length; + } + elements.forEach(node => { + node.remove(); + }); + } catch { + // ignore selector errors + } + }); + + document.querySelectorAll('[aria-hidden="true"]').forEach(node => { + try { + if (node.querySelector && node.querySelector('main, #main, #content, [role=main]')) { + node.removeAttribute('aria-hidden'); + results.fixedAriaHidden += 1; + } + } catch { + // ignore + } + }); + + // Restore scrolling if an overlay has locked it + if (document.body && document.body.style && document.body.style.overflow === 'hidden') { + document.body.style.overflow = ''; + results.restoredScrolling = true; + } + } catch { + // fail-safe: never break the page if scrubber fails + } + + return results; + }); + + if (scrubbingResult.removedElements > 0) { + consoleLogger.info( + `[overlay-neutralizer] 🗑️ Removed ${scrubbingResult.removedElements} overlay element(s): ${scrubbingResult.removedSelectors.join(', ')}`, + ); + } else { + consoleLogger.info('[overlay-neutralizer] ✓ No overlay DOM elements found to remove'); + } + + if (scrubbingResult.fixedAriaHidden > 0) { + consoleLogger.info( + `[overlay-neutralizer] 🔓 Fixed aria-hidden on ${scrubbingResult.fixedAriaHidden} main content container(s)`, + ); + } + + if (scrubbingResult.restoredScrolling) { + consoleLogger.info('[overlay-neutralizer] 📜 Restored body scrolling'); + } + + consoleLogger.info('[overlay-neutralizer] ✅ DOM scrubbing complete'); + + await page.addScriptTag({ + content: ` + (function() { + try { + var vendorSignatures = [ + // UserWay + "#userwayAccessibilityIcon", + ".userway", + ".uwy", + "[data-userway-widget]", + "script#a11yWidgetSrc", + // accessiBe + "#acsb-widget", + ".acsb-widget", + "iframe#acsb-iframe", + // EqualWeb + "#ew_widget", + ".ew-accessibility-menu", + "[data-equalweb]", + // AudioEye + "#ae-toolbar", + ".ae-toolbar", + // Monsido + "#monsido_tooltip_wrapper", + ".monsido-toolbar" + ]; + + vendorSignatures.forEach(function(sel) { + try { + document.querySelectorAll(sel).forEach(function(node) { + node.remove(); + }); + } catch (e) { + // ignore selector errors + } + }); + + // Relax aria-hidden on containers that obviously wrap main content + document.querySelectorAll('[aria-hidden="true"]').forEach(function(node) { + try { + if (node.querySelector && node.querySelector("main, #main, #content, [role=main]")) { + node.removeAttribute("aria-hidden"); + } + } catch (e) { + // ignore + } + }); + + // Restore scrolling if an overlay has locked it + if (document.body && document.body.style && document.body.style.overflow === "hidden") { + document.body.style.overflow = ""; + } + } catch (e) { + // fail-safe: never break the page if scrubber fails + console.warn("[overlay-neutralizer] scrubOverlaysOnPage script error", e); + } + })(); + `, + }); +} diff --git a/src/overlays/overlayVendors.ts b/src/overlays/overlayVendors.ts new file mode 100644 index 00000000..f1a1eeac --- /dev/null +++ b/src/overlays/overlayVendors.ts @@ -0,0 +1,84 @@ +/** + * Known accessibility overlay vendors and their basic signatures. + * + * This list is intentionally conservative. It is better to miss a variant + * than to accidentally block a legitimate, unrelated script. + * + * Extend as needed if you find additional stable URLs or DOM markers. + */ + +export interface OverlayVendor { + name: string; + urlPatterns: string[]; + domSignatures: string[]; + globalObjects: string[]; +} + +export const overlayVendors: OverlayVendor[] = [ + { + name: 'UserWay', + urlPatterns: [ + '**://cdn.userway.org/**', + '**://*.userway.org/widgetapp/**', + '**://*.userway.org/code/**', + '**://*.userway.org/api/**', + ], + domSignatures: [ + '#userwayAccessibilityIcon', + '.userway', + '.uwy', + '[data-userway-widget]', + 'script#a11yWidgetSrc', + ], + globalObjects: ['UserWay'], + }, + { + name: 'accessiBe', + urlPatterns: ['**://acsbapp.com/**', '**://acsbcdn.com/**', '**://cdn.accessibe.com/**'], + domSignatures: [ + '#acsb-widget', + '.acsb-widget', + 'iframe#acsb-iframe', + "script[src*='acsbapp.com'],script[src*='accessibe']", + ], + globalObjects: ['acsbJS', 'acsb'], + }, + { + name: 'EqualWeb', + urlPatterns: ['**://cdn.equalweb.com/**', '**://eqweb.net/**', '**://*.equalweb.com/**'], + domSignatures: [ + '#ew_widget', + '.ew-accessibility-menu', + '[data-equalweb]', + "script[src*='equalweb']", + ], + globalObjects: ['EqualWeb'], + }, + { + name: 'AudioEye', + urlPatterns: ['**://ws.audioeye.com/**', '**://cdn.audioeye.com/**', '**://*.audioeye.com/**'], + domSignatures: [ + '#ae-toolbar', + '.ae-toolbar', + "iframe[src*='audioeye'],script[src*='audioeye']", + ], + globalObjects: ['AudioEye'], + }, + { + name: 'Monsido', + urlPatterns: [ + '**://app.monsido.com/**', + '**://cdn.monsido.com/**', + '**://scripts.monsido.com/**', + ], + domSignatures: ['#monsido_tooltip_wrapper', '.monsido-toolbar', "script[src*='monsido']"], + globalObjects: ['MonsidoPageAssist', 'Monsido'], + }, +]; + +/** + * Utility to get all URL patterns across vendors, for routing. + */ +export function getAllOverlayUrlPatterns(): string[] { + return overlayVendors.flatMap(vendor => vendor.urlPatterns); +}