From ed35ee613bcdce7724fd6b6e644c3cbfbe32beed Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Tue, 3 Feb 2026 20:33:11 +0100 Subject: [PATCH 1/3] Align structure with Storage and .NET 10 --- .github/workflows/ci.yml | 33 ++ .gitignore | 484 ++++++++++++++++++ Directory.Build.props | 34 ++ Playwright.Stealth.sln | 56 ++ README.md | 70 +++ global.json | 8 + .../Playwright.Stealth.csproj | 18 + .../PlaywrightStealthExtensions.cs | 24 + .../Resources/js/chrome.app.js | 71 +++ .../Resources/js/chrome.csi.js | 27 + .../Resources/js/chrome.hairline.js | 14 + .../Resources/js/chrome.load.times.js | 122 +++++ .../Resources/js/chrome.runtime.js | 262 ++++++++++ .../Resources/js/generate.magic.arrays.js | 142 +++++ .../Resources/js/iframe.contentWindow.js | 97 ++++ .../Resources/js/media.codecs.js | 63 +++ .../js/navigator.hardwareConcurrency.js | 8 + .../Resources/js/navigator.languages.js | 3 + .../Resources/js/navigator.permissions.js | 19 + .../Resources/js/navigator.platform.js | 5 + .../Resources/js/navigator.plugins.js | 92 ++++ .../Resources/js/navigator.userAgent.js | 5 + .../Resources/js/navigator.vendor.js | 3 + .../Resources/js/navigator.webdriver.js | 14 + src/Playwright.Stealth/Resources/js/utils.js | 456 +++++++++++++++++ .../Resources/js/webgl.vendor.js | 25 + .../Resources/js/window.outerdimensions.js | 12 + src/Playwright.Stealth/StealthConfig.cs | 50 ++ .../StealthScriptProvider.cs | 137 +++++ .../Playwright.Stealth.Tests.csproj | 17 + .../StealthConfigTests.cs | 19 + .../StealthIntegrationTests.cs | 74 +++ 32 files changed, 2464 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 Playwright.Stealth.sln create mode 100644 README.md create mode 100644 global.json create mode 100644 src/Playwright.Stealth/Playwright.Stealth.csproj create mode 100644 src/Playwright.Stealth/PlaywrightStealthExtensions.cs create mode 100644 src/Playwright.Stealth/Resources/js/chrome.app.js create mode 100644 src/Playwright.Stealth/Resources/js/chrome.csi.js create mode 100644 src/Playwright.Stealth/Resources/js/chrome.hairline.js create mode 100644 src/Playwright.Stealth/Resources/js/chrome.load.times.js create mode 100644 src/Playwright.Stealth/Resources/js/chrome.runtime.js create mode 100644 src/Playwright.Stealth/Resources/js/generate.magic.arrays.js create mode 100644 src/Playwright.Stealth/Resources/js/iframe.contentWindow.js create mode 100644 src/Playwright.Stealth/Resources/js/media.codecs.js create mode 100644 src/Playwright.Stealth/Resources/js/navigator.hardwareConcurrency.js create mode 100644 src/Playwright.Stealth/Resources/js/navigator.languages.js create mode 100644 src/Playwright.Stealth/Resources/js/navigator.permissions.js create mode 100644 src/Playwright.Stealth/Resources/js/navigator.platform.js create mode 100644 src/Playwright.Stealth/Resources/js/navigator.plugins.js create mode 100644 src/Playwright.Stealth/Resources/js/navigator.userAgent.js create mode 100644 src/Playwright.Stealth/Resources/js/navigator.vendor.js create mode 100644 src/Playwright.Stealth/Resources/js/navigator.webdriver.js create mode 100644 src/Playwright.Stealth/Resources/js/utils.js create mode 100644 src/Playwright.Stealth/Resources/js/webgl.vendor.js create mode 100644 src/Playwright.Stealth/Resources/js/window.outerdimensions.js create mode 100644 src/Playwright.Stealth/StealthConfig.cs create mode 100644 src/Playwright.Stealth/StealthScriptProvider.cs create mode 100644 tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj create mode 100644 tests/Playwright.Stealth.Tests/StealthConfigTests.cs create mode 100644 tests/Playwright.Stealth.Tests/StealthIntegrationTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aad5ec5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: build-and-test + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +env: + DOTNET_VERSION: '10.0.x' + +jobs: + build-and-test: + name: build-and-test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore Playwright.Stealth.sln + + - name: Build + run: dotnet build Playwright.Stealth.sln --configuration Release --no-restore + + - name: Test + run: dotnet test --solution Playwright.Stealth.sln --configuration Release --no-build --verbosity normal diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc78471 --- /dev/null +++ b/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9050686 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,34 @@ + + + net10.0 + 14 + true + embedded + enable + enable + true + $(NoWarn);1591 + true + + + + ManagedCode Community + true + Github + MIT + README.md + https://github.com/AtuboDad/playwright_stealth + https://github.com/AtuboDad/playwright_stealth + Playwright Stealth + 1.0.0 + 1.0.0 + + + + true + + + + + + diff --git a/Playwright.Stealth.sln b/Playwright.Stealth.sln new file mode 100644 index 0000000..1c69022 --- /dev/null +++ b/Playwright.Stealth.sln @@ -0,0 +1,56 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Playwright.Stealth", "src\Playwright.Stealth\Playwright.Stealth.csproj", "{4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Playwright.Stealth.Tests", "tests\Playwright.Stealth.Tests\Playwright.Stealth.Tests.csproj", "{E168CA70-F764-4D1F-A424-23C692241414}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Debug|x64.ActiveCfg = Debug|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Debug|x64.Build.0 = Debug|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Debug|x86.ActiveCfg = Debug|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Debug|x86.Build.0 = Debug|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Release|Any CPU.Build.0 = Release|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Release|x64.ActiveCfg = Release|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Release|x64.Build.0 = Release|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Release|x86.ActiveCfg = Release|Any CPU + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}.Release|x86.Build.0 = Release|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Debug|x64.ActiveCfg = Debug|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Debug|x64.Build.0 = Debug|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Debug|x86.ActiveCfg = Debug|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Debug|x86.Build.0 = Debug|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Release|Any CPU.Build.0 = Release|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Release|x64.ActiveCfg = Release|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Release|x64.Build.0 = Release|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Release|x86.ActiveCfg = Release|Any CPU + {E168CA70-F764-4D1F-A424-23C692241414}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {4FEFBA12-C714-433F-BA7E-8BAC95CCB72A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E168CA70-F764-4D1F-A424-23C692241414} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..be954b7 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Playwright.Stealth (.NET) + +A .NET port of the Playwright stealth scripts from [AtuboDad/playwright_stealth](https://github.com/AtuboDad/playwright_stealth). +This package adds a single call to apply a collection of stealth init scripts to a Playwright page or browser context. + +## Install + +```bash + dotnet add package Playwright.Stealth +``` + +## Usage + +```csharp +using Microsoft.Playwright; +using Playwright.Stealth; + +using var playwright = await Playwright.CreateAsync(); +await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions +{ + Headless = true +}); + +var context = await browser.NewContextAsync(); +await context.ApplyStealthAsync(); + +var page = await context.NewPageAsync(); +await page.GotoAsync("https://www.browserscan.net/bot-detection"); +``` + +## Configuration + +```csharp +var config = new StealthConfig +{ + NavigatorHardwareConcurrency = 8, + NavigatorLanguages = true, + NavigatorUserAgentValue = "Mozilla/5.0 ...", + WebglVendor = true +}; + +await page.ApplyStealthAsync(config); +``` + +## Development + +1. Install .NET SDK 10.0.102 or later. +2. Restore dependencies: + +```bash + dotnet restore Playwright.Stealth.sln +``` + +3. Run tests (uses Microsoft.Testing.Platform via `global.json`): + +```bash + dotnet test --solution Playwright.Stealth.sln --configuration Release +``` + +## Test Sites + +The integration tests exercise stealth checks on these sites: + +- https://www.browserscan.net/bot-detection +- https://bot.sannysoft.com/ +- https://www.intoli.com/blog/not-possible-to-block-chrome-headless/chrome-headless-test.html +- https://fingerprint.com/demo +- https://nowsecure.nl + +> These sites can change at any time; adjust assertions as needed. diff --git a/global.json b/global.json new file mode 100644 index 0000000..2cf953f --- /dev/null +++ b/global.json @@ -0,0 +1,8 @@ +{ + "sdk": { + "version": "10.0.102" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/Playwright.Stealth/Playwright.Stealth.csproj b/src/Playwright.Stealth/Playwright.Stealth.csproj new file mode 100644 index 0000000..4be679e --- /dev/null +++ b/src/Playwright.Stealth/Playwright.Stealth.csproj @@ -0,0 +1,18 @@ + + + + Playwright.Stealth + Playwright.Stealth + Stealth tweaks for Microsoft.Playwright. + playwright;stealth;bot-detection + + + + + + + + + + + diff --git a/src/Playwright.Stealth/PlaywrightStealthExtensions.cs b/src/Playwright.Stealth/PlaywrightStealthExtensions.cs new file mode 100644 index 0000000..702d395 --- /dev/null +++ b/src/Playwright.Stealth/PlaywrightStealthExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Playwright; + +namespace Playwright.Stealth; + +public static class PlaywrightStealthExtensions +{ + public static async Task ApplyStealthAsync(this IPage page, StealthConfig? config = null) + { + var stealthConfig = config ?? new StealthConfig(); + foreach (var script in StealthScriptProvider.BuildScripts(stealthConfig)) + { + await page.AddInitScriptAsync(script).ConfigureAwait(false); + } + } + + public static async Task ApplyStealthAsync(this IBrowserContext context, StealthConfig? config = null) + { + var stealthConfig = config ?? new StealthConfig(); + foreach (var script in StealthScriptProvider.BuildScripts(stealthConfig)) + { + await context.AddInitScriptAsync(script).ConfigureAwait(false); + } + } +} diff --git a/src/Playwright.Stealth/Resources/js/chrome.app.js b/src/Playwright.Stealth/Resources/js/chrome.app.js new file mode 100644 index 0000000..7a73929 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/chrome.app.js @@ -0,0 +1,71 @@ +if (!window.chrome) { + // Use the exact property descriptor found in headful Chrome + // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` + Object.defineProperty(window, 'chrome', { + writable: true, + enumerable: true, + configurable: false, // note! + value: {} // We'll extend that later + }) +} + +// app in window.chrome means we're running headful and don't need to mock anything +if (!('app' in window.chrome)) { + const makeError = { + ErrorInInvocation: fn => { + const err = new TypeError(`Error in invocation of app.${fn}()`) + return utils.stripErrorWithAnchor( + err, + `at ${fn} (eval at ` + ) + } + } + +// There's a some static data in that property which doesn't seem to change, +// we should periodically check for updates: `JSON.stringify(window.app, null, 2)` + const APP_STATIC_DATA = JSON.parse( + ` +{ + "isInstalled": false, + "InstallState": { + "DISABLED": "disabled", + "INSTALLED": "installed", + "NOT_INSTALLED": "not_installed" + }, + "RunningState": { + "CANNOT_RUN": "cannot_run", + "READY_TO_RUN": "ready_to_run", + "RUNNING": "running" + } +} + `.trim() + ) + + window.chrome.app = { + ...APP_STATIC_DATA, + + get isInstalled() { + return false + }, + + getDetails: function getDetails() { + if (arguments.length) { + throw makeError.ErrorInInvocation(`getDetails`) + } + return null + }, + getIsInstalled: function getDetails() { + if (arguments.length) { + throw makeError.ErrorInInvocation(`getIsInstalled`) + } + return false + }, + runningState: function getDetails() { + if (arguments.length) { + throw makeError.ErrorInInvocation(`runningState`) + } + return 'cannot_run' + } + } + utils.patchToStringNested(window.chrome.app) +} \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/chrome.csi.js b/src/Playwright.Stealth/Resources/js/chrome.csi.js new file mode 100644 index 0000000..388e39f --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/chrome.csi.js @@ -0,0 +1,27 @@ +if (!window.chrome) { + // Use the exact property descriptor found in headful Chrome + // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` + Object.defineProperty(window, 'chrome', { + writable: true, + enumerable: true, + configurable: false, // note! + value: {} // We'll extend that later + }) +} + +// Check if we're running headful and don't need to mock anything +// Check that the Navigation Timing API v1 is available, we need that +if (!('csi' in window.chrome) && (window.performance || window.performance.timing)) { + const {csi_timing} = window.performance + + log.info('loading chrome.csi.js') + window.chrome.csi = function () { + return { + onloadT: csi_timing.domContentLoadedEventEnd, + startE: csi_timing.navigationStart, + pageT: Date.now() - csi_timing.navigationStart, + tran: 15 // Transition type or something + } + } + utils.patchToString(window.chrome.csi) +} \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/chrome.hairline.js b/src/Playwright.Stealth/Resources/js/chrome.hairline.js new file mode 100644 index 0000000..4a4a633 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/chrome.hairline.js @@ -0,0 +1,14 @@ +// https://intoli.com/blog/making-chrome-headless-undetectable/ +// store the existing descriptor +const elementDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); + +// redefine the property with a patched descriptor +Object.defineProperty(HTMLDivElement.prototype, 'offsetHeight', { + ...elementDescriptor, + get: function() { + if (this.id === 'modernizr') { + return 1; + } + return elementDescriptor.get.apply(this); + }, +}); \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/chrome.load.times.js b/src/Playwright.Stealth/Resources/js/chrome.load.times.js new file mode 100644 index 0000000..7025665 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/chrome.load.times.js @@ -0,0 +1,122 @@ +if (!window.chrome) { + // Use the exact property descriptor found in headful Chrome + // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` + Object.defineProperty(window, 'chrome', { + writable: true, + enumerable: true, + configurable: false, // note! + value: {} // We'll extend that later + }) +} + +// That means we're running headful and don't need to mock anything +if ('loadTimes' in window.chrome) { + throw new Error('skipping chrome loadtimes update, running in headfull mode') +} + +// Check that the Navigation Timing API v1 + v2 is available, we need that +if ( + window.performance || + window.performance.timing || + window.PerformancePaintTiming +) { + + const {performance} = window + + // Some stuff is not available on about:blank as it requires a navigation to occur, + // let's harden the code to not fail then: + const ntEntryFallback = { + nextHopProtocol: 'h2', + type: 'other' + } + + // The API exposes some funky info regarding the connection + const protocolInfo = { + get connectionInfo() { + const ntEntry = + performance.getEntriesByType('navigation')[0] || ntEntryFallback + return ntEntry.nextHopProtocol + }, + get npnNegotiatedProtocol() { + // NPN is deprecated in favor of ALPN, but this implementation returns the + // HTTP/2 or HTTP2+QUIC/39 requests negotiated via ALPN. + const ntEntry = + performance.getEntriesByType('navigation')[0] || ntEntryFallback + return ['h2', 'hq'].includes(ntEntry.nextHopProtocol) + ? ntEntry.nextHopProtocol + : 'unknown' + }, + get navigationType() { + const ntEntry = + performance.getEntriesByType('navigation')[0] || ntEntryFallback + return ntEntry.type + }, + get wasAlternateProtocolAvailable() { + // The Alternate-Protocol header is deprecated in favor of Alt-Svc + // (https://www.mnot.net/blog/2016/03/09/alt-svc), so technically this + // should always return false. + return false + }, + get wasFetchedViaSpdy() { + // SPDY is deprecated in favor of HTTP/2, but this implementation returns + // true for HTTP/2 or HTTP2+QUIC/39 as well. + const ntEntry = + performance.getEntriesByType('navigation')[0] || ntEntryFallback + return ['h2', 'hq'].includes(ntEntry.nextHopProtocol) + }, + get wasNpnNegotiated() { + // NPN is deprecated in favor of ALPN, but this implementation returns true + // for HTTP/2 or HTTP2+QUIC/39 requests negotiated via ALPN. + const ntEntry = + performance.getEntriesByType('navigation')[0] || ntEntryFallback + return ['h2', 'hq'].includes(ntEntry.nextHopProtocol) + } + } + + const {timing} = window.performance + +// Truncate number to specific number of decimals, most of the `loadTimes` stuff has 3 + function toFixed(num, fixed) { + var re = new RegExp('^-?\\d+(?:.\\d{0,' + (fixed || -1) + '})?') + return num.toString().match(re)[0] + } + + const timingInfo = { + get firstPaintAfterLoadTime() { + // This was never actually implemented and always returns 0. + return 0 + }, + get requestTime() { + return timing.navigationStart / 1000 + }, + get startLoadTime() { + return timing.navigationStart / 1000 + }, + get commitLoadTime() { + return timing.responseStart / 1000 + }, + get finishDocumentLoadTime() { + return timing.domContentLoadedEventEnd / 1000 + }, + get finishLoadTime() { + return timing.loadEventEnd / 1000 + }, + get firstPaintTime() { + const fpEntry = performance.getEntriesByType('paint')[0] || { + startTime: timing.loadEventEnd / 1000 // Fallback if no navigation occured (`about:blank`) + } + return toFixed( + (fpEntry.startTime + performance.timeOrigin) / 1000, + 3 + ) + } + } + + window.chrome.loadTimes = function () { + return { + ...protocolInfo, + ...timingInfo + } + } + utils.patchToString(window.chrome.loadTimes) +} \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/chrome.runtime.js b/src/Playwright.Stealth/Resources/js/chrome.runtime.js new file mode 100644 index 0000000..b47b37e --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/chrome.runtime.js @@ -0,0 +1,262 @@ +const STATIC_DATA = { + "OnInstalledReason": { + "CHROME_UPDATE": "chrome_update", + "INSTALL": "install", + "SHARED_MODULE_UPDATE": "shared_module_update", + "UPDATE": "update" + }, + "OnRestartRequiredReason": { + "APP_UPDATE": "app_update", + "OS_UPDATE": "os_update", + "PERIODIC": "periodic" + }, + "PlatformArch": { + "ARM": "arm", + "ARM64": "arm64", + "MIPS": "mips", + "MIPS64": "mips64", + "X86_32": "x86-32", + "X86_64": "x86-64" + }, + "PlatformNaclArch": { + "ARM": "arm", + "MIPS": "mips", + "MIPS64": "mips64", + "X86_32": "x86-32", + "X86_64": "x86-64" + }, + "PlatformOs": { + "ANDROID": "android", + "CROS": "cros", + "LINUX": "linux", + "MAC": "mac", + "OPENBSD": "openbsd", + "WIN": "win" + }, + "RequestUpdateCheckStatus": { + "NO_UPDATE": "no_update", + "THROTTLED": "throttled", + "UPDATE_AVAILABLE": "update_available" + } +} + +if (!window.chrome) { + // Use the exact property descriptor found in headful Chrome + // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` + Object.defineProperty(window, 'chrome', { + writable: true, + enumerable: true, + configurable: false, // note! + value: {} // We'll extend that later + }) +} + +// That means we're running headfull and don't need to mock anything +const existsAlready = 'runtime' in window.chrome +// `chrome.runtime` is only exposed on secure origins +const isNotSecure = !window.location.protocol.startsWith('https') +if (!(existsAlready || (isNotSecure && !opts.runOnInsecureOrigins))) { + window.chrome.runtime = { + // There's a bunch of static data in that property which doesn't seem to change, + // we should periodically check for updates: `JSON.stringify(window.chrome.runtime, null, 2)` + ...STATIC_DATA, + // `chrome.runtime.id` is extension related and returns undefined in Chrome + get id() { + return undefined + }, + // These two require more sophisticated mocks + connect: null, + sendMessage: null + } + + const makeCustomRuntimeErrors = (preamble, method, extensionId) => ({ + NoMatchingSignature: new TypeError( + preamble + `No matching signature.` + ), + MustSpecifyExtensionID: new TypeError( + preamble + + `${method} called from a webpage must specify an Extension ID (string) for its first argument.` + ), + InvalidExtensionID: new TypeError( + preamble + `Invalid extension id: '${extensionId}'` + ) + }) + + // Valid Extension IDs are 32 characters in length and use the letter `a` to `p`: + // https://source.chromium.org/chromium/chromium/src/+/master:components/crx_file/id_util.cc;drc=14a055ccb17e8c8d5d437fe080faba4c6f07beac;l=90 + const isValidExtensionID = str => + str.length === 32 && str.toLowerCase().match(/^[a-p]+$/) + + /** Mock `chrome.runtime.sendMessage` */ + const sendMessageHandler = { + apply: function (target, ctx, args) { + const [extensionId, options, responseCallback] = args || [] + + // Define custom errors + const errorPreamble = `Error in invocation of runtime.sendMessage(optional string extensionId, any message, optional object options, optional function responseCallback): ` + const Errors = makeCustomRuntimeErrors( + errorPreamble, + `chrome.runtime.sendMessage()`, + extensionId + ) + + // Check if the call signature looks ok + const noArguments = args.length === 0 + const tooManyArguments = args.length > 4 + const incorrectOptions = options && typeof options !== 'object' + const incorrectResponseCallback = + responseCallback && typeof responseCallback !== 'function' + if ( + noArguments || + tooManyArguments || + incorrectOptions || + incorrectResponseCallback + ) { + throw Errors.NoMatchingSignature + } + + // At least 2 arguments are required before we even validate the extension ID + if (args.length < 2) { + throw Errors.MustSpecifyExtensionID + } + + // Now let's make sure we got a string as extension ID + if (typeof extensionId !== 'string') { + throw Errors.NoMatchingSignature + } + + if (!isValidExtensionID(extensionId)) { + throw Errors.InvalidExtensionID + } + + return undefined // Normal behavior + } + } + utils.mockWithProxy( + window.chrome.runtime, + 'sendMessage', + function sendMessage() { + }, + sendMessageHandler + ) + + /** + * Mock `chrome.runtime.connect` + * + * @see https://developer.chrome.com/apps/runtime#method-connect + */ + const connectHandler = { + apply: function (target, ctx, args) { + const [extensionId, connectInfo] = args || [] + + // Define custom errors + const errorPreamble = `Error in invocation of runtime.connect(optional string extensionId, optional object connectInfo): ` + const Errors = makeCustomRuntimeErrors( + errorPreamble, + `chrome.runtime.connect()`, + extensionId + ) + + // Behavior differs a bit from sendMessage: + const noArguments = args.length === 0 + const emptyStringArgument = args.length === 1 && extensionId === '' + if (noArguments || emptyStringArgument) { + throw Errors.MustSpecifyExtensionID + } + + const tooManyArguments = args.length > 2 + const incorrectConnectInfoType = + connectInfo && typeof connectInfo !== 'object' + + if (tooManyArguments || incorrectConnectInfoType) { + throw Errors.NoMatchingSignature + } + + const extensionIdIsString = typeof extensionId === 'string' + if (extensionIdIsString && extensionId === '') { + throw Errors.MustSpecifyExtensionID + } + if (extensionIdIsString && !isValidExtensionID(extensionId)) { + throw Errors.InvalidExtensionID + } + + // There's another edge-case here: extensionId is optional so we might find a connectInfo object as first param, which we need to validate + const validateConnectInfo = ci => { + // More than a first param connectInfo as been provided + if (args.length > 1) { + throw Errors.NoMatchingSignature + } + // An empty connectInfo has been provided + if (Object.keys(ci).length === 0) { + throw Errors.MustSpecifyExtensionID + } + // Loop over all connectInfo props an check them + Object.entries(ci).forEach(([k, v]) => { + const isExpected = ['name', 'includeTlsChannelId'].includes(k) + if (!isExpected) { + throw new TypeError( + errorPreamble + `Unexpected property: '${k}'.` + ) + } + const MismatchError = (propName, expected, found) => + TypeError( + errorPreamble + + `Error at property '${propName}': Invalid type: expected ${expected}, found ${found}.` + ) + if (k === 'name' && typeof v !== 'string') { + throw MismatchError(k, 'string', typeof v) + } + if (k === 'includeTlsChannelId' && typeof v !== 'boolean') { + throw MismatchError(k, 'boolean', typeof v) + } + }) + } + if (typeof extensionId === 'object') { + validateConnectInfo(extensionId) + throw Errors.MustSpecifyExtensionID + } + + // Unfortunately even when the connect fails Chrome will return an object with methods we need to mock as well + return utils.patchToStringNested(makeConnectResponse()) + } + } + utils.mockWithProxy( + window.chrome.runtime, + 'connect', + function connect() { + }, + connectHandler + ) + + function makeConnectResponse() { + const onSomething = () => ({ + addListener: function addListener() { + }, + dispatch: function dispatch() { + }, + hasListener: function hasListener() { + }, + hasListeners: function hasListeners() { + return false + }, + removeListener: function removeListener() { + } + }) + + const response = { + name: '', + sender: undefined, + disconnect: function disconnect() { + }, + onDisconnect: onSomething(), + onMessage: onSomething(), + postMessage: function postMessage() { + if (!arguments.length) { + throw new TypeError(`Insufficient number of arguments.`) + } + throw new Error(`Attempting to use a disconnected port object`) + } + } + return response + } +} diff --git a/src/Playwright.Stealth/Resources/js/generate.magic.arrays.js b/src/Playwright.Stealth/Resources/js/generate.magic.arrays.js new file mode 100644 index 0000000..b9c2811 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/generate.magic.arrays.js @@ -0,0 +1,142 @@ +generateFunctionMocks = ( + proto, + itemMainProp, + dataArray +) => ({ + item: utils.createProxy(proto.item, { + apply(target, ctx, args) { + if (!args.length) { + throw new TypeError( + `Failed to execute 'item' on '${ + proto[Symbol.toStringTag] + }': 1 argument required, but only 0 present.` + ) + } + // Special behavior alert: + // - Vanilla tries to cast strings to Numbers (only integers!) and use them as property index lookup + // - If anything else than an integer (including as string) is provided it will return the first entry + const isInteger = args[0] && Number.isInteger(Number(args[0])) // Cast potential string to number first, then check for integer + // Note: Vanilla never returns `undefined` + return (isInteger ? dataArray[Number(args[0])] : dataArray[0]) || null + } + }), + /** Returns the MimeType object with the specified name. */ + namedItem: utils.createProxy(proto.namedItem, { + apply(target, ctx, args) { + if (!args.length) { + throw new TypeError( + `Failed to execute 'namedItem' on '${ + proto[Symbol.toStringTag] + }': 1 argument required, but only 0 present.` + ) + } + return dataArray.find(mt => mt[itemMainProp] === args[0]) || null // Not `undefined`! + } + }), + /** Does nothing and shall return nothing */ + refresh: proto.refresh + ? utils.createProxy(proto.refresh, { + apply(target, ctx, args) { + return undefined + } + }) + : undefined +}) + +function generateMagicArray( + dataArray = [], + proto = MimeTypeArray.prototype, + itemProto = MimeType.prototype, + itemMainProp = 'type' +) { + // Quick helper to set props with the same descriptors vanilla is using + const defineProp = (obj, prop, value) => + Object.defineProperty(obj, prop, { + value, + writable: false, + enumerable: false, // Important for mimeTypes & plugins: `JSON.stringify(navigator.mimeTypes)` + configurable: false + }) + + // Loop over our fake data and construct items + const makeItem = data => { + const item = {} + for (const prop of Object.keys(data)) { + if (prop.startsWith('__')) { + continue + } + defineProp(item, prop, data[prop]) + } + // navigator.plugins[i].length should always be 1 + if (itemProto === Plugin.prototype) { + defineProp(item, 'length', 1) + } + // We need to spoof a specific `MimeType` or `Plugin` object + return Object.create(itemProto, Object.getOwnPropertyDescriptors(item)) + } + + const magicArray = [] + + // Loop through our fake data and use that to create convincing entities + dataArray.forEach(data => { + magicArray.push(makeItem(data)) + }) + + // Add direct property access based on types (e.g. `obj['application/pdf']`) afterwards + magicArray.forEach(entry => { + defineProp(magicArray, entry[itemMainProp], entry) + }) + + // This is the best way to fake the type to make sure this is false: `Array.isArray(navigator.mimeTypes)` + const magicArrayObj = Object.create(proto, { + ...Object.getOwnPropertyDescriptors(magicArray), + + // There's one ugly quirk we unfortunately need to take care of: + // The `MimeTypeArray` prototype has an enumerable `length` property, + // but headful Chrome will still skip it when running `Object.getOwnPropertyNames(navigator.mimeTypes)`. + // To strip it we need to make it first `configurable` and can then overlay a Proxy with an `ownKeys` trap. + length: { + value: magicArray.length, + writable: false, + enumerable: false, + configurable: true // Important to be able to use the ownKeys trap in a Proxy to strip `length` + } + }) + + // Generate our functional function mocks :-) + const functionMocks = generateFunctionMocks( + proto, + itemMainProp, + magicArray + ) + + // Override custom object with proxy + return new Proxy(magicArrayObj, { + get(target, key = '') { + // Redirect function calls to our custom proxied versions mocking the vanilla behavior + if (key === 'item') { + return functionMocks.item + } + if (key === 'namedItem') { + return functionMocks.namedItem + } + if (proto === PluginArray.prototype && key === 'refresh') { + return functionMocks.refresh + } + // Everything else can pass through as normal + return utils.cache.Reflect.get(...arguments) + }, + ownKeys(target) { + // There are a couple of quirks where the original property demonstrates "magical" behavior that makes no sense + // This can be witnessed when calling `Object.getOwnPropertyNames(navigator.mimeTypes)` and the absense of `length` + // My guess is that it has to do with the recent change of not allowing data enumeration and this being implemented weirdly + // For that reason we just completely fake the available property names based on our data to match what regular Chrome is doing + // Specific issues when not patching this: `length` property is available, direct `types` props (e.g. `obj['application/pdf']`) are missing + const keys = [] + const typeProps = magicArray.map(mt => mt[itemMainProp]) + typeProps.forEach((_, i) => keys.push(`${i}`)) + typeProps.forEach(propName => keys.push(propName)) + return keys + } + }) +} \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/iframe.contentWindow.js b/src/Playwright.Stealth/Resources/js/iframe.contentWindow.js new file mode 100644 index 0000000..9bdae19 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/iframe.contentWindow.js @@ -0,0 +1,97 @@ +try { + // Adds a contentWindow proxy to the provided iframe element + const addContentWindowProxy = iframe => { + const contentWindowProxy = { + get(target, key) { + // Now to the interesting part: + // We actually make this thing behave like a regular iframe window, + // by intercepting calls to e.g. `.self` and redirect it to the correct thing. :) + // That makes it possible for these assertions to be correct: + // iframe.contentWindow.self === window.top // must be false + if (key === 'self') { + return this + } + // iframe.contentWindow.frameElement === iframe // must be true + if (key === 'frameElement') { + return iframe + } + return Reflect.get(target, key) + } + } + + if (!iframe.contentWindow) { + const proxy = new Proxy(window, contentWindowProxy) + Object.defineProperty(iframe, 'contentWindow', { + get() { + return proxy + }, + set(newValue) { + return newValue // contentWindow is immutable + }, + enumerable: true, + configurable: false + }) + } + } + + // Handles iframe element creation, augments `srcdoc` property so we can intercept further + const handleIframeCreation = (target, thisArg, args) => { + const iframe = target.apply(thisArg, args) + + // We need to keep the originals around + const _iframe = iframe + const _srcdoc = _iframe.srcdoc + + // Add hook for the srcdoc property + // We need to be very surgical here to not break other iframes by accident + Object.defineProperty(iframe, 'srcdoc', { + configurable: true, // Important, so we can reset this later + get: function () { + return _iframe.srcdoc + }, + set: function (newValue) { + addContentWindowProxy(this) + // Reset property, the hook is only needed once + Object.defineProperty(iframe, 'srcdoc', { + configurable: false, + writable: false, + value: _srcdoc + }) + _iframe.srcdoc = newValue + } + }) + return iframe + } + + // Adds a hook to intercept iframe creation events + const addIframeCreationSniffer = () => { + /* global document */ + const createElementHandler = { + // Make toString() native + get(target, key) { + return Reflect.get(target, key) + }, + apply: function (target, thisArg, args) { + const isIframe = + args && args.length && `${args[0]}`.toLowerCase() === 'iframe' + if (!isIframe) { + // Everything as usual + return target.apply(thisArg, args) + } else { + return handleIframeCreation(target, thisArg, args) + } + } + } + // All this just due to iframes with srcdoc bug + utils.replaceWithProxy( + document, + 'createElement', + createElementHandler + ) + } + + // Let's go + addIframeCreationSniffer() +} catch (err) { + // console.warn(err) +} \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/media.codecs.js b/src/Playwright.Stealth/Resources/js/media.codecs.js new file mode 100644 index 0000000..7761032 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/media.codecs.js @@ -0,0 +1,63 @@ +/** + * Input might look funky, we need to normalize it so e.g. whitespace isn't an issue for our spoofing. + * + * @example + * video/webm; codecs="vp8, vorbis" + * video/mp4; codecs="avc1.42E01E" + * audio/x-m4a; + * audio/ogg; codecs="vorbis" + * @param {String} arg + */ +const parseInput = arg => { + const [mime, codecStr] = arg.trim().split(';') + let codecs = [] + if (codecStr && codecStr.includes('codecs="')) { + codecs = codecStr + .trim() + .replace(`codecs="`, '') + .replace(`"`, '') + .trim() + .split(',') + .filter(x => !!x) + .map(x => x.trim()) + } + return { + mime, + codecStr, + codecs + } +} + +const canPlayType = { + // Intercept certain requests + apply: function (target, ctx, args) { + if (!args || !args.length) { + return target.apply(ctx, args) + } + const {mime, codecs} = parseInput(args[0]) + // This specific mp4 codec is missing in Chromium + if (mime === 'video/mp4') { + if (codecs.includes('avc1.42E01E')) { + return 'probably' + } + } + // This mimetype is only supported if no codecs are specified + if (mime === 'audio/x-m4a' && !codecs.length) { + return 'maybe' + } + + // This mimetype is only supported if no codecs are specified + if (mime === 'audio/aac' && !codecs.length) { + return 'probably' + } + // Everything else as usual + return target.apply(ctx, args) + } +} + +/* global HTMLMediaElement */ +utils.replaceWithProxy( + HTMLMediaElement.prototype, + 'canPlayType', + canPlayType +) \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/navigator.hardwareConcurrency.js b/src/Playwright.Stealth/Resources/js/navigator.hardwareConcurrency.js new file mode 100644 index 0000000..739c458 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/navigator.hardwareConcurrency.js @@ -0,0 +1,8 @@ +const patchNavigator = (name, value) => + utils.replaceProperty(Object.getPrototypeOf(navigator), name, { + get() { + return value + } + }) + +patchNavigator('hardwareConcurrency', opts.navigator_hardware_concurrency || 4); \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/navigator.languages.js b/src/Playwright.Stealth/Resources/js/navigator.languages.js new file mode 100644 index 0000000..75d6b01 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/navigator.languages.js @@ -0,0 +1,3 @@ +Object.defineProperty(Object.getPrototypeOf(navigator), 'languages', { + get: () => opts.languages || ['en-US', 'en'] +}) diff --git a/src/Playwright.Stealth/Resources/js/navigator.permissions.js b/src/Playwright.Stealth/Resources/js/navigator.permissions.js new file mode 100644 index 0000000..1d90531 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/navigator.permissions.js @@ -0,0 +1,19 @@ +const handler = { + apply: function (target, ctx, args) { + const param = (args || [])[0] + + if (param && param.name && param.name === 'notifications') { + const result = {state: Notification.permission} + Object.setPrototypeOf(result, PermissionStatus.prototype) + return Promise.resolve(result) + } + + return utils.cache.Reflect.apply(...arguments) + } +} + +utils.replaceWithProxy( + window.navigator.permissions.__proto__, // eslint-disable-line no-proto + 'query', + handler +) diff --git a/src/Playwright.Stealth/Resources/js/navigator.platform.js b/src/Playwright.Stealth/Resources/js/navigator.platform.js new file mode 100644 index 0000000..f61e32f --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/navigator.platform.js @@ -0,0 +1,5 @@ +if (opts.navigator_platform) { + Object.defineProperty(Object.getPrototypeOf(navigator), 'platform', { + get: () => opts.navigator_plaftorm, + }) +} \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/navigator.plugins.js b/src/Playwright.Stealth/Resources/js/navigator.plugins.js new file mode 100644 index 0000000..117aeb6 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/navigator.plugins.js @@ -0,0 +1,92 @@ +data = { + "mimeTypes": [ + { + "type": "application/pdf", + "suffixes": "pdf", + "description": "", + "__pluginName": "Chrome PDF Viewer" + }, + { + "type": "application/x-google-chrome-pdf", + "suffixes": "pdf", + "description": "Portable Document Format", + "__pluginName": "Chrome PDF Plugin" + }, + { + "type": "application/x-nacl", + "suffixes": "", + "description": "Native Client Executable", + "__pluginName": "Native Client" + }, + { + "type": "application/x-pnacl", + "suffixes": "", + "description": "Portable Native Client Executable", + "__pluginName": "Native Client" + } + ], + "plugins": [ + { + "name": "Chrome PDF Plugin", + "filename": "internal-pdf-viewer", + "description": "Portable Document Format", + "__mimeTypes": ["application/x-google-chrome-pdf"] + }, + { + "name": "Chrome PDF Viewer", + "filename": "mhjfbmdgcfjbbpaeojofohoefgiehjai", + "description": "", + "__mimeTypes": ["application/pdf"] + }, + { + "name": "Native Client", + "filename": "internal-nacl-plugin", + "description": "", + "__mimeTypes": ["application/x-nacl", "application/x-pnacl"] + } + ] +} + + +// That means we're running headful +const hasPlugins = 'plugins' in navigator && navigator.plugins.length +if (!(hasPlugins)) { + + const mimeTypes = generateMagicArray( + data.mimeTypes, + MimeTypeArray.prototype, + MimeType.prototype, + 'type' + ) + const plugins = generateMagicArray( + data.plugins, + PluginArray.prototype, + Plugin.prototype, + 'name' + ) + + // Plugin and MimeType cross-reference each other, let's do that now + // Note: We're looping through `data.plugins` here, not the generated `plugins` + for (const pluginData of data.plugins) { + pluginData.__mimeTypes.forEach((type, index) => { + plugins[pluginData.name][index] = mimeTypes[type] + plugins[type] = mimeTypes[type] + Object.defineProperty(mimeTypes[type], 'enabledPlugin', { + value: JSON.parse(JSON.stringify(plugins[pluginData.name])), + writable: false, + enumerable: false, // Important: `JSON.stringify(navigator.plugins)` + configurable: false + }) + }) + } + + const patchNavigator = (name, value) => + utils.replaceProperty(Object.getPrototypeOf(navigator), name, { + get() { + return value + } + }) + + patchNavigator('mimeTypes', mimeTypes) + patchNavigator('plugins', plugins) +} \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/navigator.userAgent.js b/src/Playwright.Stealth/Resources/js/navigator.userAgent.js new file mode 100644 index 0000000..f35f07a --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/navigator.userAgent.js @@ -0,0 +1,5 @@ +// replace Headless references in default useragent +const current_ua = navigator.userAgent +Object.defineProperty(Object.getPrototypeOf(navigator), 'userAgent', { + get: () => opts.navigator_user_agent || current_ua.replace('HeadlessChrome/', 'Chrome/') +}) diff --git a/src/Playwright.Stealth/Resources/js/navigator.vendor.js b/src/Playwright.Stealth/Resources/js/navigator.vendor.js new file mode 100644 index 0000000..1349b67 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/navigator.vendor.js @@ -0,0 +1,3 @@ +Object.defineProperty(Object.getPrototypeOf(navigator), 'vendor', { + get: () => opts.navigator_vendor || 'Google Inc.', +}) diff --git a/src/Playwright.Stealth/Resources/js/navigator.webdriver.js b/src/Playwright.Stealth/Resources/js/navigator.webdriver.js new file mode 100644 index 0000000..6f40b85 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/navigator.webdriver.js @@ -0,0 +1,14 @@ +// this is close to the most accurate way to emulate this: https://stackoverflow.com/a/69533548 +Object.defineProperty(Object.getPrototypeOf(navigator), 'webdriver', { + set: undefined, + enumerable: true, + configurable: true, + get: new Proxy( + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(navigator), 'webdriver').get, + { apply: (target, thisArg, args) => { + // emulate getter call validation + Reflect.apply(target, thisArg, args); + return false; + }} + ) +}); \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/utils.js b/src/Playwright.Stealth/Resources/js/utils.js new file mode 100644 index 0000000..5e26ae6 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/utils.js @@ -0,0 +1,456 @@ +/** + * A set of shared utility functions specifically for the purpose of modifying native browser APIs without leaving traces. + * + * Meant to be passed down in puppeteer and used in the context of the page (everything in here runs in NodeJS as well as a browser). + * + * Note: If for whatever reason you need to use this outside of `puppeteer-extra`: + * Just remove the `module.exports` statement at the very bottom, the rest can be copy pasted into any browser context. + * + * Alternatively take a look at the `extract-stealth-evasions` package to create a finished bundle which includes these utilities. + * + */ +const utils = {} + +/** + * Wraps a JS Proxy Handler and strips it's presence from error stacks, in case the traps throw. + * + * The presence of a JS Proxy can be revealed as it shows up in error stack traces. + * + * @param {object} handler - The JS Proxy handler to wrap + */ +utils.stripProxyFromErrors = (handler = {}) => { + const newHandler = {} + // We wrap each trap in the handler in a try/catch and modify the error stack if they throw + const traps = Object.getOwnPropertyNames(handler) + traps.forEach(trap => { + newHandler[trap] = function() { + try { + // Forward the call to the defined proxy handler + return handler[trap].apply(this, arguments || []) + } catch (err) { + // Stack traces differ per browser, we only support chromium based ones currently + if (!err || !err.stack || !err.stack.includes(`at `)) { + throw err + } + + // When something throws within one of our traps the Proxy will show up in error stacks + // An earlier implementation of this code would simply strip lines with a blacklist, + // but it makes sense to be more surgical here and only remove lines related to our Proxy. + // We try to use a known "anchor" line for that and strip it with everything above it. + // If the anchor line cannot be found for some reason we fall back to our blacklist approach. + + const stripWithBlacklist = stack => { + const blacklist = [ + `at Reflect.${trap} `, // e.g. Reflect.get or Reflect.apply + `at Object.${trap} `, // e.g. Object.get or Object.apply + `at Object.newHandler. [as ${trap}] ` // caused by this very wrapper :-) + ] + return ( + err.stack + .split('\n') + // Always remove the first (file) line in the stack (guaranteed to be our proxy) + .filter((line, index) => index !== 1) + // Check if the line starts with one of our blacklisted strings + .filter(line => !blacklist.some(bl => line.trim().startsWith(bl))) + .join('\n') + ) + } + + const stripWithAnchor = stack => { + const stackArr = stack.split('\n') + const anchor = `at Object.newHandler. [as ${trap}] ` // Known first Proxy line in chromium + const anchorIndex = stackArr.findIndex(line => + line.trim().startsWith(anchor) + ) + if (anchorIndex === -1) { + return false // 404, anchor not found + } + // Strip everything from the top until we reach the anchor line + // Note: We're keeping the 1st line (zero index) as it's unrelated (e.g. `TypeError`) + stackArr.splice(1, anchorIndex) + return stackArr.join('\n') + } + + // Try using the anchor method, fallback to blacklist if necessary + err.stack = stripWithAnchor(err.stack) || stripWithBlacklist(err.stack) + + throw err // Re-throw our now sanitized error + } + } + }) + return newHandler +} + +/** + * Strip error lines from stack traces until (and including) a known line the stack. + * + * @param {object} err - The error to sanitize + * @param {string} anchor - The string the anchor line starts with + */ +utils.stripErrorWithAnchor = (err, anchor) => { + const stackArr = err.stack.split('\n') + const anchorIndex = stackArr.findIndex(line => line.trim().startsWith(anchor)) + if (anchorIndex === -1) { + return err // 404, anchor not found + } + // Strip everything from the top until we reach the anchor line (remove anchor line as well) + // Note: We're keeping the 1st line (zero index) as it's unrelated (e.g. `TypeError`) + stackArr.splice(1, anchorIndex) + err.stack = stackArr.join('\n') + return err +} + +/** + * Replace the property of an object in a stealthy way. + * + * Note: You also want to work on the prototype of an object most often, + * as you'd otherwise leave traces (e.g. showing up in Object.getOwnPropertyNames(obj)). + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty + * + * @example + * replaceProperty(WebGLRenderingContext.prototype, 'getParameter', { value: "alice" }) + * // or + * replaceProperty(Object.getPrototypeOf(navigator), 'languages', { get: () => ['en-US', 'en'] }) + * + * @param {object} obj - The object which has the property to replace + * @param {string} propName - The property name to replace + * @param {object} descriptorOverrides - e.g. { value: "alice" } + */ +utils.replaceProperty = (obj, propName, descriptorOverrides = {}) => { + return Object.defineProperty(obj, propName, { + // Copy over the existing descriptors (writable, enumerable, configurable, etc) + ...(Object.getOwnPropertyDescriptor(obj, propName) || {}), + // Add our overrides (e.g. value, get()) + ...descriptorOverrides + }) +} + +/** + * Preload a cache of function copies and data. + * + * For a determined enough observer it would be possible to overwrite and sniff usage of functions + * we use in our internal Proxies, to combat that we use a cached copy of those functions. + * + * This is evaluated once per execution context (e.g. window) + */ +utils.preloadCache = () => { + if (utils.cache) { + return + } + utils.cache = { + // Used in our proxies + Reflect: { + get: Reflect.get.bind(Reflect), + apply: Reflect.apply.bind(Reflect) + }, + // Used in `makeNativeString` + nativeToStringStr: Function.toString + '' // => `function toString() { [native code] }` + } +} + +/** + * Utility function to generate a cross-browser `toString` result representing native code. + * + * There's small differences: Chromium uses a single line, whereas FF & Webkit uses multiline strings. + * To future-proof this we use an existing native toString result as the basis. + * + * The only advantage we have over the other team is that our JS runs first, hence we cache the result + * of the native toString result once, so they cannot spoof it afterwards and reveal that we're using it. + * + * Note: Whenever we add a `Function.prototype.toString` proxy we should preload the cache before, + * by executing `utils.preloadCache()` before the proxy is applied (so we don't cause recursive lookups). + * + * @example + * makeNativeString('foobar') // => `function foobar() { [native code] }` + * + * @param {string} [name] - Optional function name + */ +utils.makeNativeString = (name = '') => { + // Cache (per-window) the original native toString or use that if available + utils.preloadCache() + return utils.cache.nativeToStringStr.replace('toString', name || '') +} + +/** + * Helper function to modify the `toString()` result of the provided object. + * + * Note: Use `utils.redirectToString` instead when possible. + * + * There's a quirk in JS Proxies that will cause the `toString()` result to differ from the vanilla Object. + * If no string is provided we will generate a `[native code]` thing based on the name of the property object. + * + * @example + * patchToString(WebGLRenderingContext.prototype.getParameter, 'function getParameter() { [native code] }') + * + * @param {object} obj - The object for which to modify the `toString()` representation + * @param {string} str - Optional string used as a return value + */ +utils.patchToString = (obj, str = '') => { + utils.preloadCache() + + const toStringProxy = new Proxy(Function.prototype.toString, { + apply: function(target, ctx) { + // This fixes e.g. `HTMLMediaElement.prototype.canPlayType.toString + ""` + if (ctx === Function.prototype.toString) { + return utils.makeNativeString('toString') + } + // `toString` targeted at our proxied Object detected + if (ctx === obj) { + // We either return the optional string verbatim or derive the most desired result automatically + return str || utils.makeNativeString(obj.name) + } + // Check if the toString protype of the context is the same as the global prototype, + // if not indicates that we are doing a check across different windows., e.g. the iframeWithdirect` test case + const hasSameProto = Object.getPrototypeOf( + Function.prototype.toString + ).isPrototypeOf(ctx.toString) // eslint-disable-line no-prototype-builtins + if (!hasSameProto) { + // Pass the call on to the local Function.prototype.toString instead + return ctx.toString() + } + return target.call(ctx) + } + }) + utils.replaceProperty(Function.prototype, 'toString', { + value: toStringProxy + }) +} + +/** + * Make all nested functions of an object native. + * + * @param {object} obj + */ +utils.patchToStringNested = (obj = {}) => { + return utils.execRecursively(obj, ['function'], utils.patchToString) +} + +/** + * Redirect toString requests from one object to another. + * + * @param {object} proxyObj - The object that toString will be called on + * @param {object} originalObj - The object which toString result we wan to return + */ +utils.redirectToString = (proxyObj, originalObj) => { + utils.preloadCache() + + const toStringProxy = new Proxy(Function.prototype.toString, { + apply: function(target, ctx) { + // This fixes e.g. `HTMLMediaElement.prototype.canPlayType.toString + ""` + if (ctx === Function.prototype.toString) { + return utils.makeNativeString('toString') + } + + // `toString` targeted at our proxied Object detected + if (ctx === proxyObj) { + const fallback = () => + originalObj && originalObj.name + ? utils.makeNativeString(originalObj.name) + : utils.makeNativeString(proxyObj.name) + + // Return the toString representation of our original object if possible + return originalObj + '' || fallback() + } + + // Check if the toString protype of the context is the same as the global prototype, + // if not indicates that we are doing a check across different windows., e.g. the iframeWithdirect` test case + const hasSameProto = Object.getPrototypeOf( + Function.prototype.toString + ).isPrototypeOf(ctx.toString) // eslint-disable-line no-prototype-builtins + if (!hasSameProto) { + // Pass the call on to the local Function.prototype.toString instead + return ctx.toString() + } + + return target.call(ctx) + } + }) + utils.replaceProperty(Function.prototype, 'toString', { + value: toStringProxy + }) +} + +/** + * All-in-one method to replace a property with a JS Proxy using the provided Proxy handler with traps. + * + * Will stealthify these aspects (strip error stack traces, redirect toString, etc). + * Note: This is meant to modify native Browser APIs and works best with prototype objects. + * + * @example + * replaceWithProxy(WebGLRenderingContext.prototype, 'getParameter', proxyHandler) + * + * @param {object} obj - The object which has the property to replace + * @param {string} propName - The name of the property to replace + * @param {object} handler - The JS Proxy handler to use + */ +utils.replaceWithProxy = (obj, propName, handler) => { + utils.preloadCache() + const originalObj = obj[propName] + const proxyObj = new Proxy(obj[propName], utils.stripProxyFromErrors(handler)) + + utils.replaceProperty(obj, propName, { value: proxyObj }) + utils.redirectToString(proxyObj, originalObj) + + return true +} + +/** + * All-in-one method to mock a non-existing property with a JS Proxy using the provided Proxy handler with traps. + * + * Will stealthify these aspects (strip error stack traces, redirect toString, etc). + * + * @example + * mockWithProxy(chrome.runtime, 'sendMessage', function sendMessage() {}, proxyHandler) + * + * @param {object} obj - The object which has the property to replace + * @param {string} propName - The name of the property to replace or create + * @param {object} pseudoTarget - The JS Proxy target to use as a basis + * @param {object} handler - The JS Proxy handler to use + */ +utils.mockWithProxy = (obj, propName, pseudoTarget, handler) => { + utils.preloadCache() + const proxyObj = new Proxy(pseudoTarget, utils.stripProxyFromErrors(handler)) + + utils.replaceProperty(obj, propName, { value: proxyObj }) + utils.patchToString(proxyObj) + + return true +} + +/** + * All-in-one method to create a new JS Proxy with stealth tweaks. + * + * This is meant to be used whenever we need a JS Proxy but don't want to replace or mock an existing known property. + * + * Will stealthify certain aspects of the Proxy (strip error stack traces, redirect toString, etc). + * + * @example + * createProxy(navigator.mimeTypes.__proto__.namedItem, proxyHandler) // => Proxy + * + * @param {object} pseudoTarget - The JS Proxy target to use as a basis + * @param {object} handler - The JS Proxy handler to use + */ +utils.createProxy = (pseudoTarget, handler) => { + utils.preloadCache() + const proxyObj = new Proxy(pseudoTarget, utils.stripProxyFromErrors(handler)) + utils.patchToString(proxyObj) + + return proxyObj +} + +/** + * Helper function to split a full path to an Object into the first part and property. + * + * @example + * splitObjPath(`HTMLMediaElement.prototype.canPlayType`) + * // => {objName: "HTMLMediaElement.prototype", propName: "canPlayType"} + * + * @param {string} objPath - The full path to an object as dot notation string + */ +utils.splitObjPath = objPath => ({ + // Remove last dot entry (property) ==> `HTMLMediaElement.prototype` + objName: objPath + .split('.') + .slice(0, -1) + .join('.'), + // Extract last dot entry ==> `canPlayType` + propName: objPath.split('.').slice(-1)[0] +}) + +/** + * Convenience method to replace a property with a JS Proxy using the provided objPath. + * + * Supports a full path (dot notation) to the object as string here, in case that makes it easier. + * + * @example + * replaceObjPathWithProxy('WebGLRenderingContext.prototype.getParameter', proxyHandler) + * + * @param {string} objPath - The full path to an object (dot notation string) to replace + * @param {object} handler - The JS Proxy handler to use + */ +utils.replaceObjPathWithProxy = (objPath, handler) => { + const { objName, propName } = utils.splitObjPath(objPath) + const obj = eval(objName) // eslint-disable-line no-eval + return utils.replaceWithProxy(obj, propName, handler) +} + +/** + * Traverse nested properties of an object recursively and apply the given function on a whitelist of value types. + * + * @param {object} obj + * @param {array} typeFilter - e.g. `['function']` + * @param {Function} fn - e.g. `utils.patchToString` + */ +utils.execRecursively = (obj = {}, typeFilter = [], fn) => { + function recurse(obj) { + for (const key in obj) { + if (obj[key] === undefined) { + continue + } + if (obj[key] && typeof obj[key] === 'object') { + recurse(obj[key]) + } else { + if (obj[key] && typeFilter.includes(typeof obj[key])) { + fn.call(this, obj[key]) + } + } + } + } + recurse(obj) + return obj +} + +/** + * Everything we run through e.g. `page.evaluate` runs in the browser context, not the NodeJS one. + * That means we cannot just use reference variables and functions from outside code, we need to pass everything as a parameter. + * + * Unfortunately the data we can pass is only allowed to be of primitive types, regular functions don't survive the built-in serialization process. + * This utility function will take an object with functions and stringify them, so we can pass them down unharmed as strings. + * + * We use this to pass down our utility functions as well as any other functions (to be able to split up code better). + * + * @see utils.materializeFns + * + * @param {object} fnObj - An object containing functions as properties + */ +utils.stringifyFns = (fnObj = { hello: () => 'world' }) => { + // Object.fromEntries() ponyfill (in 6 lines) - supported only in Node v12+, modern browsers are fine + // https://github.com/feross/fromentries + function fromEntries(iterable) { + return [...iterable].reduce((obj, [key, val]) => { + obj[key] = val + return obj + }, {}) + } + return (Object.fromEntries || fromEntries)( + Object.entries(fnObj) + .filter(([key, value]) => typeof value === 'function') + .map(([key, value]) => [key, value.toString()]) // eslint-disable-line no-eval + ) +} + +/** + * Utility function to reverse the process of `utils.stringifyFns`. + * Will materialize an object with stringified functions (supports classic and fat arrow functions). + * + * @param {object} fnStrObj - An object containing stringified functions as properties + */ +utils.materializeFns = (fnStrObj = { hello: "() => 'world'" }) => { + return Object.fromEntries( + Object.entries(fnStrObj).map(([key, value]) => { + if (value.startsWith('function')) { + // some trickery is needed to make oldschool functions work :-) + return [key, eval(`() => ${value}`)()] // eslint-disable-line no-eval + } else { + // arrow functions just work + return [key, eval(value)] // eslint-disable-line no-eval + } + }) + ) +} + +// -- +// Stuff starting below this line is NodeJS specific. +// -- +// module.exports = utils \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/webgl.vendor.js b/src/Playwright.Stealth/Resources/js/webgl.vendor.js new file mode 100644 index 0000000..6b585a9 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/webgl.vendor.js @@ -0,0 +1,25 @@ +console.log(opts) +const getParameterProxyHandler = { + apply: function (target, ctx, args) { + const param = (args || [])[0] + // UNMASKED_VENDOR_WEBGL + if (param === 37445) { + return opts.webgl_vendor || 'Intel Inc.' // default in headless: Google Inc. + } + // UNMASKED_RENDERER_WEBGL + if (param === 37446) { + return opts.webgl_renderer || 'Intel Iris OpenGL Engine' // default in headless: Google SwiftShader + } + return utils.cache.Reflect.apply(target, ctx, args) + } +} + +// There's more than one WebGL rendering context +// https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext#Browser_compatibility +// To find out the original values here: Object.getOwnPropertyDescriptors(WebGLRenderingContext.prototype.getParameter) +const addProxy = (obj, propName) => { + utils.replaceWithProxy(obj, propName, getParameterProxyHandler) +} +// For whatever weird reason loops don't play nice with Object.defineProperty, here's the next best thing: +addProxy(WebGLRenderingContext.prototype, 'getParameter') +addProxy(WebGL2RenderingContext.prototype, 'getParameter') \ No newline at end of file diff --git a/src/Playwright.Stealth/Resources/js/window.outerdimensions.js b/src/Playwright.Stealth/Resources/js/window.outerdimensions.js new file mode 100644 index 0000000..e9ef868 --- /dev/null +++ b/src/Playwright.Stealth/Resources/js/window.outerdimensions.js @@ -0,0 +1,12 @@ +'use strict' + +try { + if (!!window.outerWidth && !!window.outerHeight) { + const windowFrame = 85 // probably OS and WM dependent + window.outerWidth = window.innerWidth + console.log(`current window outer height ${window.outerHeight}`) + window.outerHeight = window.innerHeight + windowFrame + console.log(`new window outer height ${window.outerHeight}`) + } +} catch (err) { +} diff --git a/src/Playwright.Stealth/StealthConfig.cs b/src/Playwright.Stealth/StealthConfig.cs new file mode 100644 index 0000000..dd67fac --- /dev/null +++ b/src/Playwright.Stealth/StealthConfig.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace Playwright.Stealth; + +public sealed class StealthConfig +{ + public bool WebDriver { get; init; } = true; + public bool WebglVendor { get; init; } = true; + public bool ChromeApp { get; init; } = true; + public bool ChromeCsi { get; init; } = true; + public bool ChromeLoadTimes { get; init; } = true; + public bool ChromeRuntime { get; init; } = true; + public bool IframeContentWindow { get; init; } = true; + public bool MediaCodecs { get; init; } = true; + public int NavigatorHardwareConcurrency { get; init; } = 4; + public bool NavigatorLanguages { get; init; } = true; + public bool NavigatorPermissions { get; init; } = true; + public bool NavigatorPlatform { get; init; } = true; + public bool NavigatorPlugins { get; init; } = true; + public bool NavigatorUserAgent { get; init; } = true; + public bool NavigatorVendor { get; init; } = true; + public bool OuterDimensions { get; init; } = true; + public bool Hairline { get; init; } = true; + + public string Vendor { get; init; } = "Intel Inc."; + public string Renderer { get; init; } = "Intel Iris OpenGL Engine"; + public string NavigatorVendorValue { get; init; } = "Google Inc."; + public string? NavigatorUserAgentValue { get; init; } + public string? NavigatorPlatformValue { get; init; } + public IReadOnlyList Languages { get; init; } = new[] { "en-US", "en" }; + public bool? RunOnInsecureOrigins { get; init; } + + internal string BuildOptionsScript() + { + var payload = new Dictionary + { + ["webgl_vendor"] = Vendor, + ["webgl_renderer"] = Renderer, + ["navigator_vendor"] = NavigatorVendorValue, + ["navigator_platform"] = NavigatorPlatformValue, + ["navigator_user_agent"] = NavigatorUserAgentValue, + ["languages"] = Languages, + ["runOnInsecureOrigins"] = RunOnInsecureOrigins, + ["navigator_hardware_concurrency"] = NavigatorHardwareConcurrency + }; + + return $"const opts = {JsonSerializer.Serialize(payload)}"; + } +} diff --git a/src/Playwright.Stealth/StealthScriptProvider.cs b/src/Playwright.Stealth/StealthScriptProvider.cs new file mode 100644 index 0000000..cd2a4cb --- /dev/null +++ b/src/Playwright.Stealth/StealthScriptProvider.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace Playwright.Stealth; + +internal static class StealthScriptProvider +{ + private static readonly IReadOnlyDictionary Scripts = new Dictionary + { + ["chrome_app"] = LoadScript("chrome.app.js"), + ["chrome_csi"] = LoadScript("chrome.csi.js"), + ["chrome_hairline"] = LoadScript("chrome.hairline.js"), + ["chrome_load_times"] = LoadScript("chrome.load.times.js"), + ["chrome_runtime"] = LoadScript("chrome.runtime.js"), + ["generate_magic_arrays"] = LoadScript("generate.magic.arrays.js"), + ["iframe_content_window"] = LoadScript("iframe.contentWindow.js"), + ["media_codecs"] = LoadScript("media.codecs.js"), + ["navigator_hardware_concurrency"] = LoadScript("navigator.hardwareConcurrency.js"), + ["navigator_languages"] = LoadScript("navigator.languages.js"), + ["navigator_permissions"] = LoadScript("navigator.permissions.js"), + ["navigator_platform"] = LoadScript("navigator.platform.js"), + ["navigator_plugins"] = LoadScript("navigator.plugins.js"), + ["navigator_user_agent"] = LoadScript("navigator.userAgent.js"), + ["navigator_vendor"] = LoadScript("navigator.vendor.js"), + ["outerdimensions"] = LoadScript("window.outerdimensions.js"), + ["utils"] = LoadScript("utils.js"), + ["webdriver"] = LoadScript("navigator.webdriver.js"), + ["webgl_vendor"] = LoadScript("webgl.vendor.js") + }; + + public static IEnumerable BuildScripts(StealthConfig config) + { + yield return config.BuildOptionsScript(); + yield return Scripts["utils"]; + yield return Scripts["generate_magic_arrays"]; + + if (config.ChromeApp) + { + yield return Scripts["chrome_app"]; + } + + if (config.ChromeCsi) + { + yield return Scripts["chrome_csi"]; + } + + if (config.Hairline) + { + yield return Scripts["chrome_hairline"]; + } + + if (config.ChromeLoadTimes) + { + yield return Scripts["chrome_load_times"]; + } + + if (config.ChromeRuntime) + { + yield return Scripts["chrome_runtime"]; + } + + if (config.IframeContentWindow) + { + yield return Scripts["iframe_content_window"]; + } + + if (config.MediaCodecs) + { + yield return Scripts["media_codecs"]; + } + + if (config.NavigatorLanguages) + { + yield return Scripts["navigator_languages"]; + } + + if (config.NavigatorPermissions) + { + yield return Scripts["navigator_permissions"]; + } + + if (config.NavigatorPlatform) + { + yield return Scripts["navigator_platform"]; + } + + if (config.NavigatorPlugins) + { + yield return Scripts["navigator_plugins"]; + } + + if (config.NavigatorUserAgent) + { + yield return Scripts["navigator_user_agent"]; + } + + if (config.NavigatorVendor) + { + yield return Scripts["navigator_vendor"]; + } + + if (config.WebDriver) + { + yield return Scripts["webdriver"]; + } + + if (config.OuterDimensions) + { + yield return Scripts["outerdimensions"]; + } + + if (config.WebglVendor) + { + yield return Scripts["webgl_vendor"]; + } + + if (config.NavigatorHardwareConcurrency > 0) + { + yield return Scripts["navigator_hardware_concurrency"]; + } + } + + private static string LoadScript(string fileName) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = $"{assembly.GetName().Name}.Resources.js.{fileName}"; + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + throw new InvalidOperationException($"Missing embedded script resource: {resourceName}"); + } + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj b/tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj new file mode 100644 index 0000000..8512480 --- /dev/null +++ b/tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj @@ -0,0 +1,17 @@ + + + + true + + + + + + + + + + + + + diff --git a/tests/Playwright.Stealth.Tests/StealthConfigTests.cs b/tests/Playwright.Stealth.Tests/StealthConfigTests.cs new file mode 100644 index 0000000..b7883c2 --- /dev/null +++ b/tests/Playwright.Stealth.Tests/StealthConfigTests.cs @@ -0,0 +1,19 @@ +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +namespace Playwright.Stealth.Tests; + +public sealed class StealthConfigTests +{ + [Test] + public async Task Defaults_Should_Enable_Core_Stealth_Scripts() + { + var config = new StealthConfig(); + + await Assert.That(config.WebDriver).IsTrue(); + await Assert.That(config.WebglVendor).IsTrue(); + await Assert.That(config.NavigatorLanguages).IsTrue(); + await Assert.That(config.NavigatorHardwareConcurrency).IsGreaterThan(0); + } +} diff --git a/tests/Playwright.Stealth.Tests/StealthIntegrationTests.cs b/tests/Playwright.Stealth.Tests/StealthIntegrationTests.cs new file mode 100644 index 0000000..da0f3e0 --- /dev/null +++ b/tests/Playwright.Stealth.Tests/StealthIntegrationTests.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using Microsoft.Playwright; +using Playwright.Stealth; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +namespace Playwright.Stealth.Tests; + +public sealed class StealthIntegrationTests +{ + private static readonly IReadOnlyList Sites = + [ + new("https://www.browserscan.net/bot-detection", "BrowserScan"), + new("https://bot.sannysoft.com/", "SannySoft"), + new("https://www.intoli.com/blog/not-possible-to-block-chrome-headless/chrome-headless-test.html", "Intoli"), + new("https://fingerprint.com/demo", "Fingerprint"), + new("https://nowsecure.nl", "NowSecure") + ]; + + [Test] + [Explicit("Integration test hitting external bot-detection sites. Set RUN_STEALTH_INTEGRATION_TESTS=1 to run intentionally.")] + public async Task Pages_Should_Not_Report_As_Bot() + { + if (!string.Equals(Environment.GetEnvironmentVariable("RUN_STEALTH_INTEGRATION_TESTS"), "1", StringComparison.Ordinal)) + { + return; + } + + using var playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true + }); + + var context = await browser.NewContextAsync(new BrowserNewContextOptions + { + Locale = CultureInfo.CurrentCulture.Name + }); + + await context.ApplyStealthAsync(); + var page = await context.NewPageAsync(); + + foreach (var site in Sites) + { + await page.GotoAsync(site.Url, new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle, + Timeout = 120_000 + }); + + await EnsureNoBotSignalsAsync(page, site); + } + } + + private static async Task EnsureNoBotSignalsAsync(IPage page, StealthSite site) + { + var hasWebDriver = await page.EvaluateAsync("() => navigator.webdriver === true"); + var pluginCount = await page.EvaluateAsync("() => navigator.plugins ? navigator.plugins.length : 0"); + var languageCount = await page.EvaluateAsync("() => navigator.languages ? navigator.languages.length : 0"); + var hasChrome = await page.EvaluateAsync("() => typeof window.chrome !== 'undefined'"); + var bodyText = await page.InnerTextAsync("body"); + + await Assert.That(hasWebDriver).IsFalse(); + await Assert.That(pluginCount).IsGreaterThan(0); + await Assert.That(languageCount).IsGreaterThan(0); + await Assert.That(hasChrome).IsTrue(); + await Assert.That(bodyText.Contains("bot", StringComparison.OrdinalIgnoreCase)).IsFalse(); + await Assert.That(bodyText.Contains("headless", StringComparison.OrdinalIgnoreCase)).IsFalse(); + await Assert.That(bodyText.Contains("automation", StringComparison.OrdinalIgnoreCase)).IsFalse(); + } + + private sealed record StealthSite(string Url, string Name); +} From a2158bd4276b6e69ca48369b960785ddc4575465 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Tue, 3 Feb 2026 20:40:38 +0100 Subject: [PATCH 2/3] Rename namespaces to ManagedCode prefix --- Directory.Build.props | 2 +- Playwright.Stealth.sln | 4 ++-- README.md | 6 +++--- src/Playwright.Stealth/Playwright.Stealth.csproj | 6 ++++-- src/Playwright.Stealth/PlaywrightStealthExtensions.cs | 2 +- src/Playwright.Stealth/StealthConfig.cs | 2 +- src/Playwright.Stealth/StealthScriptProvider.cs | 2 +- .../Playwright.Stealth.Tests.csproj | 1 + tests/Playwright.Stealth.Tests/StealthConfigTests.cs | 2 +- tests/Playwright.Stealth.Tests/StealthIntegrationTests.cs | 4 ++-- 10 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9050686..252af69 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -19,7 +19,7 @@ README.md https://github.com/AtuboDad/playwright_stealth https://github.com/AtuboDad/playwright_stealth - Playwright Stealth + ManagedCode Playwright Stealth 1.0.0 1.0.0 diff --git a/Playwright.Stealth.sln b/Playwright.Stealth.sln index 1c69022..5cc2a05 100644 --- a/Playwright.Stealth.sln +++ b/Playwright.Stealth.sln @@ -5,11 +5,11 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Playwright.Stealth", "src\Playwright.Stealth\Playwright.Stealth.csproj", "{4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Playwright.Stealth", "src\Playwright.Stealth\Playwright.Stealth.csproj", "{4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Playwright.Stealth.Tests", "tests\Playwright.Stealth.Tests\Playwright.Stealth.Tests.csproj", "{E168CA70-F764-4D1F-A424-23C692241414}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Playwright.Stealth.Tests", "tests\Playwright.Stealth.Tests\Playwright.Stealth.Tests.csproj", "{E168CA70-F764-4D1F-A424-23C692241414}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/README.md b/README.md index be954b7..b3d3875 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Playwright.Stealth (.NET) +# ManagedCode.Playwright.Stealth (.NET) A .NET port of the Playwright stealth scripts from [AtuboDad/playwright_stealth](https://github.com/AtuboDad/playwright_stealth). This package adds a single call to apply a collection of stealth init scripts to a Playwright page or browser context. @@ -6,14 +6,14 @@ This package adds a single call to apply a collection of stealth init scripts to ## Install ```bash - dotnet add package Playwright.Stealth + dotnet add package ManagedCode.Playwright.Stealth ``` ## Usage ```csharp using Microsoft.Playwright; -using Playwright.Stealth; +using ManagedCode.Playwright.Stealth; using var playwright = await Playwright.CreateAsync(); await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions diff --git a/src/Playwright.Stealth/Playwright.Stealth.csproj b/src/Playwright.Stealth/Playwright.Stealth.csproj index 4be679e..4d6019c 100644 --- a/src/Playwright.Stealth/Playwright.Stealth.csproj +++ b/src/Playwright.Stealth/Playwright.Stealth.csproj @@ -1,10 +1,12 @@  - Playwright.Stealth - Playwright.Stealth + ManagedCode.Playwright.Stealth + ManagedCode.Playwright.Stealth Stealth tweaks for Microsoft.Playwright. playwright;stealth;bot-detection + ManagedCode.Playwright.Stealth + ManagedCode.Playwright.Stealth diff --git a/src/Playwright.Stealth/PlaywrightStealthExtensions.cs b/src/Playwright.Stealth/PlaywrightStealthExtensions.cs index 702d395..fe2685b 100644 --- a/src/Playwright.Stealth/PlaywrightStealthExtensions.cs +++ b/src/Playwright.Stealth/PlaywrightStealthExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Playwright; -namespace Playwright.Stealth; +namespace ManagedCode.Playwright.Stealth; public static class PlaywrightStealthExtensions { diff --git a/src/Playwright.Stealth/StealthConfig.cs b/src/Playwright.Stealth/StealthConfig.cs index dd67fac..38a5997 100644 --- a/src/Playwright.Stealth/StealthConfig.cs +++ b/src/Playwright.Stealth/StealthConfig.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Text.Json; -namespace Playwright.Stealth; +namespace ManagedCode.Playwright.Stealth; public sealed class StealthConfig { diff --git a/src/Playwright.Stealth/StealthScriptProvider.cs b/src/Playwright.Stealth/StealthScriptProvider.cs index cd2a4cb..a158b46 100644 --- a/src/Playwright.Stealth/StealthScriptProvider.cs +++ b/src/Playwright.Stealth/StealthScriptProvider.cs @@ -2,7 +2,7 @@ using System.IO; using System.Reflection; -namespace Playwright.Stealth; +namespace ManagedCode.Playwright.Stealth; internal static class StealthScriptProvider { diff --git a/tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj b/tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj index 8512480..7a643ce 100644 --- a/tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj +++ b/tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj @@ -2,6 +2,7 @@ true + ManagedCode.Playwright.Stealth.Tests diff --git a/tests/Playwright.Stealth.Tests/StealthConfigTests.cs b/tests/Playwright.Stealth.Tests/StealthConfigTests.cs index b7883c2..3a7f6de 100644 --- a/tests/Playwright.Stealth.Tests/StealthConfigTests.cs +++ b/tests/Playwright.Stealth.Tests/StealthConfigTests.cs @@ -2,7 +2,7 @@ using TUnit.Assertions.Extensions; using TUnit.Core; -namespace Playwright.Stealth.Tests; +namespace ManagedCode.Playwright.Stealth.Tests; public sealed class StealthConfigTests { diff --git a/tests/Playwright.Stealth.Tests/StealthIntegrationTests.cs b/tests/Playwright.Stealth.Tests/StealthIntegrationTests.cs index da0f3e0..a9857cf 100644 --- a/tests/Playwright.Stealth.Tests/StealthIntegrationTests.cs +++ b/tests/Playwright.Stealth.Tests/StealthIntegrationTests.cs @@ -1,11 +1,11 @@ using System.Globalization; using Microsoft.Playwright; -using Playwright.Stealth; +using ManagedCode.Playwright.Stealth; using TUnit.Assertions; using TUnit.Assertions.Extensions; using TUnit.Core; -namespace Playwright.Stealth.Tests; +namespace ManagedCode.Playwright.Stealth.Tests; public sealed class StealthIntegrationTests { From 37db9304e6b6a9a62c8759475693c2c4937d94dc Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Tue, 3 Feb 2026 20:46:18 +0100 Subject: [PATCH 3/3] Fix platform script and rename project files --- .github/workflows/ci.yml | 6 +++--- ...wright.Stealth.sln => ManagedCode.Playwright.Stealth.sln | 4 ++-- README.md | 4 ++-- ...Stealth.csproj => ManagedCode.Playwright.Stealth.csproj} | 2 +- src/Playwright.Stealth/Resources/js/navigator.platform.js | 4 ++-- ...s.csproj => ManagedCode.Playwright.Stealth.Tests.csproj} | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) rename Playwright.Stealth.sln => ManagedCode.Playwright.Stealth.sln (91%) rename src/Playwright.Stealth/{Playwright.Stealth.csproj => ManagedCode.Playwright.Stealth.csproj} (94%) rename tests/Playwright.Stealth.Tests/{Playwright.Stealth.Tests.csproj => ManagedCode.Playwright.Stealth.Tests.csproj} (75%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aad5ec5..111cf31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,10 @@ jobs: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Restore dependencies - run: dotnet restore Playwright.Stealth.sln + run: dotnet restore ManagedCode.Playwright.Stealth.sln - name: Build - run: dotnet build Playwright.Stealth.sln --configuration Release --no-restore + run: dotnet build ManagedCode.Playwright.Stealth.sln --configuration Release --no-restore - name: Test - run: dotnet test --solution Playwright.Stealth.sln --configuration Release --no-build --verbosity normal + run: dotnet test --solution ManagedCode.Playwright.Stealth.sln --configuration Release --no-build --verbosity normal diff --git a/Playwright.Stealth.sln b/ManagedCode.Playwright.Stealth.sln similarity index 91% rename from Playwright.Stealth.sln rename to ManagedCode.Playwright.Stealth.sln index 5cc2a05..2fab6e3 100644 --- a/Playwright.Stealth.sln +++ b/ManagedCode.Playwright.Stealth.sln @@ -5,11 +5,11 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Playwright.Stealth", "src\Playwright.Stealth\Playwright.Stealth.csproj", "{4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Playwright.Stealth", "src\Playwright.Stealth\ManagedCode.Playwright.Stealth.csproj", "{4FEFBA12-C714-433F-BA7E-8BAC95CCB72A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Playwright.Stealth.Tests", "tests\Playwright.Stealth.Tests\Playwright.Stealth.Tests.csproj", "{E168CA70-F764-4D1F-A424-23C692241414}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedCode.Playwright.Stealth.Tests", "tests\Playwright.Stealth.Tests\ManagedCode.Playwright.Stealth.Tests.csproj", "{E168CA70-F764-4D1F-A424-23C692241414}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/README.md b/README.md index b3d3875..f7fe59d 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,13 @@ await page.ApplyStealthAsync(config); 2. Restore dependencies: ```bash - dotnet restore Playwright.Stealth.sln + dotnet restore ManagedCode.Playwright.Stealth.sln ``` 3. Run tests (uses Microsoft.Testing.Platform via `global.json`): ```bash - dotnet test --solution Playwright.Stealth.sln --configuration Release + dotnet test --solution ManagedCode.Playwright.Stealth.sln --configuration Release ``` ## Test Sites diff --git a/src/Playwright.Stealth/Playwright.Stealth.csproj b/src/Playwright.Stealth/ManagedCode.Playwright.Stealth.csproj similarity index 94% rename from src/Playwright.Stealth/Playwright.Stealth.csproj rename to src/Playwright.Stealth/ManagedCode.Playwright.Stealth.csproj index 4d6019c..9569f86 100644 --- a/src/Playwright.Stealth/Playwright.Stealth.csproj +++ b/src/Playwright.Stealth/ManagedCode.Playwright.Stealth.csproj @@ -1,4 +1,4 @@ - + ManagedCode.Playwright.Stealth diff --git a/src/Playwright.Stealth/Resources/js/navigator.platform.js b/src/Playwright.Stealth/Resources/js/navigator.platform.js index f61e32f..3655de0 100644 --- a/src/Playwright.Stealth/Resources/js/navigator.platform.js +++ b/src/Playwright.Stealth/Resources/js/navigator.platform.js @@ -1,5 +1,5 @@ if (opts.navigator_platform) { Object.defineProperty(Object.getPrototypeOf(navigator), 'platform', { - get: () => opts.navigator_plaftorm, + get: () => opts.navigator_platform, }) -} \ No newline at end of file +} diff --git a/tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj b/tests/Playwright.Stealth.Tests/ManagedCode.Playwright.Stealth.Tests.csproj similarity index 75% rename from tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj rename to tests/Playwright.Stealth.Tests/ManagedCode.Playwright.Stealth.Tests.csproj index 7a643ce..fafb97e 100644 --- a/tests/Playwright.Stealth.Tests/Playwright.Stealth.Tests.csproj +++ b/tests/Playwright.Stealth.Tests/ManagedCode.Playwright.Stealth.Tests.csproj @@ -1,4 +1,4 @@ - + true @@ -12,7 +12,7 @@ - +