diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index e906c07..1ffcc34 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -132,11 +132,21 @@ func (h *Handlers) solveRotation(ctx context.Context, _ *mcp.CallToolRequest, in // ---- stealth tools ---- type StealthScriptIn struct { - Engine string `json:"engine,omitempty" jsonschema:"playwright | cdp | generic"` + Seed uint64 `json:"seed,omitempty" jsonschema:"use the same seed as generate_fingerprint to keep the injected script and applied profile consistent"` + OS string `json:"os,omitempty" jsonschema:"windows | macos | linux — use the same os/browser/locale/seed as generate_fingerprint, or read the returned fingerprint, to keep the injected script and applied profile consistent"` + Browser string `json:"browser,omitempty" jsonschema:"chrome"` + Locale string `json:"locale,omitempty" jsonschema:"BCP-47 locale e.g. en-US, en-GB — use the same locale as generate_fingerprint for a coherent profile"` + Engine string `json:"engine,omitempty" jsonschema:"playwright | cdp | generic"` } func (h *Handlers) stealthScript(_ context.Context, _ *mcp.CallToolRequest, in StealthScriptIn) (*mcp.CallToolResult, stealth.ScriptResult, error) { - return nil, stealth.StealthScript(stealth.ScriptParams{Engine: in.Engine}), nil + return nil, stealth.StealthScript(stealth.ScriptParams{ + OS: in.OS, + Browser: in.Browser, + Locale: in.Locale, + Seed: in.Seed, + Engine: in.Engine, + }), nil } type FingerprintIn struct { diff --git a/internal/stealth/assets/evasions.js.tmpl b/internal/stealth/assets/evasions.js.tmpl new file mode 100644 index 0000000..b6412d4 --- /dev/null +++ b/internal/stealth/assets/evasions.js.tmpl @@ -0,0 +1,27 @@ +(() => { + // navigator.webdriver + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + // languages + Object.defineProperty(navigator, 'languages', { get: () => [{{.Languages}}] }); + // navigator.platform + Object.defineProperty(navigator, 'platform', { get: () => '{{.Platform}}' }); + // plugins (non-empty) + Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); + // chrome runtime stub + window.chrome = window.chrome || { runtime: {} }; + // permissions query consistency + const origQuery = window.navigator.permissions && window.navigator.permissions.query; + if (origQuery) { + window.navigator.permissions.query = (params) => + params && params.name === 'notifications' + ? Promise.resolve({ state: Notification.permission }) + : origQuery(params); + } + // WebGL vendor/renderer spoof + const getParam = WebGLRenderingContext.prototype.getParameter; + WebGLRenderingContext.prototype.getParameter = function (p) { + if (p === 37445) return '{{.WebGLVendor}}'; // UNMASKED_VENDOR_WEBGL + if (p === 37446) return '{{.WebGLRenderer}}'; // UNMASKED_RENDERER_WEBGL + return getParam.call(this, p); + }; +})(); diff --git a/internal/stealth/script.go b/internal/stealth/script.go index 5b10e3b..1acd5d6 100644 --- a/internal/stealth/script.go +++ b/internal/stealth/script.go @@ -1,13 +1,27 @@ package stealth -import _ "embed" +import ( + _ "embed" + "bytes" + "fmt" + "strings" + "text/template" +) -//go:embed assets/evasions.js -var evasionsJS string +//go:embed assets/evasions.js.tmpl +var evasionsTmplSrc string -// ScriptParams selects the target engine for apply instructions. +// evasionTmpl is parsed once at startup; template.Must panics on bad syntax. +var evasionTmpl = template.Must(template.New("evasions").Parse(evasionsTmplSrc)) + +// ScriptParams selects identity and target engine for apply instructions. +// Fields ordered for fieldalignment (largest to smallest, then strings). type ScriptParams struct { - Engine string // playwright | cdp | generic + Seed uint64 // deterministic generation; 0 → random + OS string // windows | macos | linux + Browser string // chrome + Locale string // e.g. en-US, en-GB + Engine string // playwright | cdp | generic } // ApplyInfo tells the caller how to inject the script. @@ -18,13 +32,59 @@ type ApplyInfo struct { // ScriptResult is the injectable evasion bundle plus metadata. type ScriptResult struct { - Script string `json:"script"` - Apply ApplyInfo `json:"apply"` - IncludedEvasions []string `json:"included_evasions"` + Script string `json:"script"` + Apply ApplyInfo `json:"apply"` + Fingerprint Fingerprint `json:"fingerprint"` + IncludedEvasions []string `json:"included_evasions"` } -// StealthScript returns the evasion init script and engine-specific apply hints. +// evasionData is the template data bag. +type evasionData struct { + WebGLVendor string + WebGLRenderer string + Platform string + Languages string // pre-rendered JS list, e.g. 'en-GB', 'en' +} + +// languagesJS converts a locale string to a JS list of quoted language tags. +// "en-GB" → `'en-GB', 'en'`; "en" → `'en'`; "" → `'en-US', 'en'` +func languagesJS(locale string) string { + if locale == "" { + locale = "en-US" + } + parts := strings.SplitN(locale, "-", 2) + if len(parts) == 2 && parts[0] != locale { + return fmt.Sprintf("'%s', '%s'", locale, parts[0]) + } + return fmt.Sprintf("'%s'", locale) +} + +// StealthScript returns an evasion init script rendered from a coherent +// Fingerprint, plus engine-specific apply hints. The Fingerprint used for +// rendering is included in ScriptResult so callers can apply it without a +// second call. Empty params produce valid output (windows/chrome/en-US defaults). func StealthScript(p ScriptParams) ScriptResult { + fp := GenerateFingerprint(FingerprintParams{ + OS: p.OS, + Browser: p.Browser, + Locale: p.Locale, + Seed: p.Seed, + }) + + var buf bytes.Buffer + // Execute errors are non-fatal — the template is static aside from the data + // substitution; if it somehow fails, fall back to a descriptive comment. + if err := evasionTmpl.Execute(&buf, evasionData{ + WebGLVendor: fp.WebGLVendor, + WebGLRenderer: fp.WebGLRenderer, + Platform: fp.Platform, + Languages: languagesJS(fp.Locale), + }); err != nil { + // Fallback: return an empty-IIFE comment so the script is valid JS. + buf.Reset() + fmt.Fprintf(&buf, "(() => { /* evasion render error: %v */ })();", err) + } + apply := ApplyInfo{How: "Inject before any page script runs.", API: "page.addInitScript(script)"} switch p.Engine { case "cdp": @@ -32,12 +92,15 @@ func StealthScript(p ScriptParams) ScriptResult { case "generic", "": apply.API = "evaluate the script on every new document before navigation" } + return ScriptResult{ - Script: evasionsJS, - Apply: apply, + Script: buf.String(), + Apply: apply, + Fingerprint: fp, IncludedEvasions: []string{ - "navigator.webdriver", "navigator.languages", "navigator.plugins", - "chrome.runtime", "permissions.query", "WebGL vendor/renderer", + "navigator.webdriver", "navigator.languages", "navigator.platform", + "navigator.plugins", "chrome.runtime", "permissions.query", + "WebGL vendor/renderer", }, } } diff --git a/internal/stealth/script_test.go b/internal/stealth/script_test.go index 2ccf7b9..751dbdf 100644 --- a/internal/stealth/script_test.go +++ b/internal/stealth/script_test.go @@ -19,3 +19,30 @@ func TestStealthScript(t *testing.T) { t.Fatalf("missing apply metadata: %+v", res) } } + +func TestStealthScriptCoherence(t *testing.T) { + p := ScriptParams{OS: "macos", Locale: "en-GB", Seed: 7} + res := StealthScript(p) + + // Independently generate the same fingerprint. + fp := GenerateFingerprint(FingerprintParams{OS: "macos", Locale: "en-GB", Seed: 7}) + + // Script must contain the fingerprint's WebGL and platform values. + for _, want := range []string{fp.WebGLVendor, fp.WebGLRenderer, fp.Platform} { + if !strings.Contains(res.Script, want) { + t.Errorf("script missing %q", want) + } + } + // Script must contain navigator.platform spoof. + if !strings.Contains(res.Script, "navigator.platform") { + t.Error("script missing navigator.platform spoof") + } + // ScriptResult.Fingerprint must equal independently generated fingerprint. + if res.Fingerprint != fp { + t.Errorf("fingerprint mismatch:\n got %+v\n want %+v", res.Fingerprint, fp) + } + // Template must not produce empty script. + if res.Script == "" { + t.Fatal("empty rendered script") + } +}