├── package.json ├── .github └── workflows │ └── npm-publish.yml ├── LICENSE ├── .gitignore ├── README.md └── src ├── index.js └── index.cjs /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kpsdk-solver", 3 | "version": "2.2.0", 4 | "main": "./src/index.js", 5 | "type": "module", 6 | "exports": { 7 | "import": "./src/index.js", 8 | "require": "./src/index.cjs" 9 | }, 10 | "author": "0x6a69616e", 11 | "description": "A Playwright-based solver for Kasada's bot defense platform.", 12 | "keywords": [ 13 | "twitch", 14 | "kick", 15 | "playstation", 16 | "psn", 17 | "nike", 18 | "kasada", 19 | "kpsdk", 20 | "x-kpsdk-ct", 21 | "x-kpsdk-cd", 22 | "kpsdk-cd", 23 | "kpsdk-ct", 24 | "kpsdk-solver" 25 | ], 26 | "license": "MIT", 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/0x6a69616e/kpsdk-solver.git" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Publish Node.js Package to npmjs 5 | on: workflow_dispatch 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 20 15 | - run: npm ci 16 | 17 | publish-npm: 18 | needs: build 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: 20 25 | registry-url: https://registry.npmjs.org/ 26 | - run: npm ci 27 | - run: npm publish 28 | env: 29 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 0x6a69616e 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kpsdk-solver 2 | > A Playwright-based solver for Kasada's bot defense platform. 3 | 4 | Available as a replacement to [`Browser.newPage()`](https://playwright.dev/docs/api/class-browser#browser-new-page) and [`BrowserContext.newPage()`](https://playwright.dev/docs/api/class-browsercontext#browser-context-new-page) 5 | 6 | ## Features 7 | - Extensive manipulation of the Kasada SDK 8 | - Use custom script import 9 | - Use custom configuration 10 | - Inspect SDK messages 11 | - Interact with Kasada's Fetch API 12 | - Use same-page client token regeneration 13 | - Support for CommonJS (CJS) and ECMAScript module (ESM) use 14 | - Seamless integration with the Playwright library 15 | 16 | ## Limitations 17 | - Only compatible with Playwright 18 | - Fails to bypass detection on... (based on common issues - results may vary) 19 | - Chrom(e/ium) browsers; Firefox preferred [[article]](https://substack.thewebscraping.club/i/108229509/playwright-with-firefox) [[article]](https://substack.thewebscraping.club/i/99643353/the-tests-results) [[image]](https://substack-post-media.s3.amazonaws.com/public/images/f178b49a-6646-43f6-abe4-b09e3341f844_1178x225.png) 20 | - Most Linux machines; Windows preferred 21 | 22 | ## Installation 23 | ```sh 24 | $ npm install kpsdk-solver 25 | ``` 26 | 27 | ## Usage 28 | ```js 29 | import playwright from 'playwright'; 30 | import Solver from 'kpsdk-solver'; 31 | 32 | const solver = new Solver(config); 33 | 34 | (async () => { 35 | const browser = await playwright.firefox.launch({ headless: true }); 36 | const context = await browser.newContext(); 37 | 38 | const page = await solver.create(context, page => { 39 | // optional, page callback; access the page instance before the solver uses it 40 | console.log(page.url()); // should return about:blank or smthn 41 | }); 42 | 43 | // retrieve the SDK messages 44 | console.log(page.solver.messages); // KPSDK:DONE:... 45 | 46 | // make a modifiable fetch request 47 | const { route, request } = await page.solver.fetch('/api/kasada-protected-endpoint'); 48 | 49 | /// refer to playwright.dev/docs/api/class-request 50 | console.log(request.headers()); // capture the headers of that request, including x-kpsdk-* 51 | /// refer to playwright.dev/docs/api/class-route 52 | await route.abort(); // abort unless same-page client token regeneration should be used 53 | 54 | await page.close(); 55 | await context.close(); 56 | await browser.close(); 57 | })(); 58 | ``` 59 | 60 | ## Configuration 61 | ```js 62 | { 63 | // `kasada` specifies Kasada-protected endpoints in a parsed format 64 | kasada: [{ 65 | domain: 'some-domain.com', 66 | method: 'POST', 67 | path: '/api/kasada-protected-endpoint', 68 | protocol: 'https:' 69 | }], 70 | 71 | // `load-complete` indicates whether or not to completely load the target page 72 | // Kasada-protected endpoint configurations do not need to be specified when this option is enabled 73 | // when disabled, the target page loads with no content 74 | 'load-complete': false, // default 75 | 76 | // `request-tracing` indicates whether or not to trace Fetch requests initiated by `page.solver.fetch()` 77 | // when enabled, such requests are assigned a unique identifier that can be accessed through the `X-Trace-Id` header 78 | // this option should be enabled in scenarios where numerous requests for the same URL might happen simultaneously within the same page instance when calling `page.solver.fetch()` 79 | 'request-tracing': false, // default 80 | 81 | // `sdk-script` specifies the Kasada SDK script to import 82 | // see available options at playwright.dev/docs/api/class-page#page-add-init-script-option-script 83 | 'sdk-script': { 84 | url: 'https://some-domain.com/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/p.js' 85 | }, 86 | 87 | // `url` specifies the target page URL which the browser will navigate to 88 | // this affects the Referer and Origin headers of requests, as well as other origin-dependant properties 89 | // HTTP redirects are still considered when `load-complete` is disabled 90 | url: 'https://some-domain.com' 91 | } 92 | ``` 93 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // in courtesy of https://stackoverflow.com/a/41854075 2 | function nameFunction(name, body) { 3 | return { 4 | [name](...args) { 5 | return body.apply(this, args) 6 | } 7 | } [name] 8 | } 9 | 10 | function make_id(len = 32) { 11 | return Array.from(Array(len)).map(() => Math.random().toString(16).slice(2, 3)).join(''); 12 | } 13 | 14 | function build(config) { 15 | const rt = config?.['request-tracing']; 16 | const sc = config?.kasada; 17 | const ss = config?.['sdk-script']; 18 | const { 19 | 'load-complete': lc, 20 | url 21 | } = config; 22 | 23 | async function create(context, page_cb) { 24 | const page = await context.newPage(); 25 | typeof page_cb !== 'function' || await page_cb(page); 26 | 27 | async function launch_kasada() { 28 | await page.goto(await (async () => { 29 | if (lc) return url; 30 | 31 | const response = await page.request.get(url); 32 | return await page.route('*/**', async function router(route) { 33 | await route.fulfill({ 34 | response, 35 | status: 200, 36 | body: '' 37 | }); 38 | await page.unroute('*/**', router); 39 | }), 40 | response.url(); 41 | })(), { 42 | waitUntil: 'commit' 43 | }); 44 | 45 | !ss || await page.addScriptTag(ss); 46 | 47 | const fp_regex = /\/149e9513-01fa-4fb0-aad4-566afd725d1b\/2d206a39-8ed7-437e-a3be-862e0f06eea3\/fp/; 48 | const fp_endpoint_listener = res => !fp_regex.test(res.url()) || (async () => { 49 | if (!(await res.body()).length) throw new Error(res.url() + ' responded with no body'); 50 | })(); 51 | 52 | page.on('response', fp_endpoint_listener); 53 | 54 | const messages = await page.evaluate(config => new Promise(resolve => { 55 | let msgs = []; 56 | window.addEventListener('message', ({ 57 | data, 58 | origin 59 | }) => !data.startsWith('KPSDK:DONE:') || msgs.push({ 60 | message: data, 61 | timing: { 62 | ms: performance.now(), 63 | ts: Date.now() 64 | }, 65 | origin 66 | })), window.addEventListener('kpsdk-ready', () => !KPSDK.isReady() || resolve(msgs), { 67 | once: true 68 | }), !config || window.KPSDK.configure(config); 69 | }), sc); 70 | 71 | return page.removeListener('response', fp_endpoint_listener), messages; 72 | } 73 | 74 | function fetch(url, options = {}) { 75 | if (sc) { 76 | const parsed = new URL(url); 77 | const endpoint = sc.find(e => (e.domain == parsed.host) && (e.path == parsed.pathname || e.path.includes('*'))) || (() => { 78 | throw new Error('kasada is not configured to intercept requests to ' + url); 79 | })(); 80 | 81 | options.method || (endpoint.method.includes('*') || (options.method = endpoint.method)); 82 | } 83 | 84 | return new Promise(async (resolve, { 85 | trace_header = 'X-Trace-Id', 86 | trace_id = rt ? make_id() : 0..a, 87 | fn 88 | }) => { 89 | await page.route(url, fn = nameFunction(make_id(), async (route, request) => { 90 | if (trace_id && (request.headers()[trace_header.toLowerCase()] !== trace_id)) return; 91 | resolve({ 92 | request, 93 | route 94 | }); 95 | if (page.isClosed) return; 96 | await request.response(); 97 | await page.unroute(url, fn); 98 | })); 99 | 100 | try { 101 | await page.evaluate(args => window.fetch(...args), [ 102 | url, 103 | trace_id ? { 104 | ...options, 105 | headers: { 106 | ...options.headers, 107 | [trace_header]: trace_id 108 | } 109 | } : options 110 | ]); 111 | } catch (_) {}; 112 | }); 113 | } 114 | 115 | return (page.solver = { 116 | fetch, 117 | messages: await launch_kasada(), 118 | }, page); 119 | } 120 | 121 | return { 122 | create 123 | }; 124 | } 125 | 126 | export { build as default }; 127 | -------------------------------------------------------------------------------- /src/index.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // in courtesy of https://stackoverflow.com/a/41854075 4 | function nameFunction(name, body) { 5 | return { 6 | [name](...args) { 7 | return body.apply(this, args) 8 | } 9 | } [name] 10 | } 11 | 12 | function make_id(len = 32) { 13 | return Array.from(Array(len)).map(() => Math.random().toString(16).slice(2, 3)).join(''); 14 | } 15 | 16 | function build(config) { 17 | const rt = config?.['request-tracing']; 18 | const sc = config?.kasada; 19 | const ss = config?.['sdk-script']; 20 | const { 21 | 'load-complete': lc, 22 | url 23 | } = config; 24 | 25 | async function create(context, page_cb) { 26 | const page = await context.newPage(); 27 | typeof page_cb !== 'function' || await page_cb(page); 28 | 29 | async function launch_kasada() { 30 | await page.goto(await (async () => { 31 | if (lc) return url; 32 | 33 | const response = await page.request.get(url); 34 | return await page.route('*/**', async function router(route) { 35 | await route.fulfill({ 36 | response, 37 | status: 200, 38 | body: '' 39 | }); 40 | await page.unroute('*/**', router); 41 | }), 42 | response.url(); 43 | })(), { 44 | waitUntil: 'commit' 45 | }); 46 | 47 | !ss || await page.addScriptTag(ss); 48 | 49 | const fp_regex = /\/149e9513-01fa-4fb0-aad4-566afd725d1b\/2d206a39-8ed7-437e-a3be-862e0f06eea3\/fp/; 50 | const fp_endpoint_listener = res => !fp_regex.test(res.url()) || (async () => { 51 | if (!(await res.body()).length) throw new Error(res.url() + ' responded with no body'); 52 | })(); 53 | 54 | page.on('response', fp_endpoint_listener); 55 | 56 | const messages = await page.evaluate(config => new Promise(resolve => { 57 | let msgs = []; 58 | window.addEventListener('message', ({ 59 | data, 60 | origin 61 | }) => !data.startsWith('KPSDK:DONE:') || msgs.push({ 62 | message: data, 63 | timing: { 64 | ms: performance.now(), 65 | ts: Date.now() 66 | }, 67 | origin 68 | })), window.addEventListener('kpsdk-ready', () => !KPSDK.isReady() || resolve(msgs), { 69 | once: true 70 | }), !config || window.KPSDK.configure(config); 71 | }), sc); 72 | 73 | return page.removeListener('response', fp_endpoint_listener), messages; 74 | } 75 | 76 | function fetch(url, options = {}) { 77 | if (sc) { 78 | const parsed = new URL(url); 79 | const endpoint = sc.find(e => (e.domain == parsed.host) && (e.path == parsed.pathname || e.path.includes('*'))) || (() => { 80 | throw new Error('kasada is not configured to intercept requests to ' + url); 81 | })(); 82 | 83 | options.method || (endpoint.method.includes('*') || (options.method = endpoint.method)); 84 | } 85 | 86 | return new Promise(async (resolve, { 87 | trace_header = 'X-Trace-Id', 88 | trace_id = rt ? make_id() : 0..a, 89 | fn 90 | }) => { 91 | await page.route(url, fn = nameFunction(make_id(), async (route, request) => { 92 | if (trace_id && (request.headers()[trace_header.toLowerCase()] !== trace_id)) return; 93 | resolve({ 94 | request, 95 | route 96 | }); 97 | if (page.isClosed) return; 98 | await request.response(); 99 | await page.unroute(url, fn); 100 | })); 101 | 102 | try { 103 | await page.evaluate(args => window.fetch(...args), [ 104 | url, 105 | trace_id ? { 106 | ...options, 107 | headers: { 108 | ...options.headers, 109 | [trace_header]: trace_id 110 | } 111 | } : options 112 | ]); 113 | } catch (_) {}; 114 | }); 115 | } 116 | 117 | return (page.solver = { 118 | fetch, 119 | messages: await launch_kasada(), 120 | }, page); 121 | } 122 | 123 | return { 124 | create 125 | }; 126 | } 127 | 128 | module.exports = build; 129 | --------------------------------------------------------------------------------