browse: Layer C stealth fakes chrome.runtime.connect/sendMessage, sites detect a phantom companion extension and break login
- Dominant language
- TypeScript
- Stars
- 133k
- Forks
- 19.9k
- Avg merge
- 18h 46m
- Merged PRs (30d)
- 26
Description
## Summary
The always-on Layer C stealth script (`browse/src/stealth.ts`, `buildStealthScript`) injects a fake `window.chrome.runtime` that includes callable `connect` and `sendMessage` stubs. Real Chrome never exposes those methods to an ordinary web page. Web apps that ship a companion Chrome extension commonly probe `chrome.runtime.sendMessage` to decide whether their extension is installed. In GStack Browser that probe returns a function, so the app concludes the extension is present and routes its auth/session flow through the extension. The flow fails and the user is bounced back to the login page in a loop. The same site logs in fine in stock Chrome, Edge, and incognito.
There is no opt-out: Layer C runs on every context-creation path and only the *extended* mode is gated by `GSTACK_STEALTH`.
## Why this is a fingerprint inconsistency, not just a site bug
In real Chrome, `chrome.runtime` on a web page is:
- **undefined** in the vast majority of cases (no connectable extension), or
- **present with `connect` / `sendMessage`** only when an installed extension declares that origin in `externally_connectable`.
Layer C's shape is a third thing real Chrome never produces: `chrome.runtime` present, with `connect` / `sendMessage` that throw `TypeError: Error in invocation of runtime.sendMessage: No matching signature.` for any argument. v1.28's own rationale (PR #1363) was that inconsistent fakes make detection easier, not harder. The enum shape (`OnInstalledReason`, `PlatformOs`, ...) is what the Cloudflare/DataDome presence checks look at, and it can stay.
## Reproduction
In GStack Browser (headed or headless), on any page:
```
$ browse goto https://
$ browse js "JSON.stringify({hasRuntime: !!chrome.runtime, hasSendMessage: typeof chrome.runtime.sendMessage, ua: navigator.userAgent})"
{"hasRuntime":true,"hasSendMessage":"function","ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.7922.34 Safari/537.36"}
```
Same check in stock Chrome / Edge / incognito without a connectable extension:
```
> typeof chrome.runtime
'undefined'
```
Observed effect on a real SaaS login (redacted): login POST succeeds, tenant callback loads, app calls its `isExtensionInstalled()` helper, `chrome.runtime.sendMessage(...)` throws the fake TypeError (visible in `.gstack/browse-console.log`), app then POSTs `/logout` and redirects to `/logout?...&client_name=extension` which returns 403, user lands on the login page again.
## Proposed fix
Remove `connect` and `sendMessage` from the injected `chrome.runtime` object and keep the enum shape. That matches what real Chrome shows a page with no connectable extension, and removes the only callable surface a detector could poke at and get a wrong answer from.
Tests to update: `stealth-layer-c.test.ts` ("ships full enum shape" currently asserts the two methods exist; markNative count drops by 2) and `stealth-webdriver.test.ts` (asserts `runtime.connect()` throws "No matching signature").
PR with the change and updated tests: linked in the comments / PR reference.
## Environment
- gstack v1.79.0.0 (commit 0d1bd56)
- Windows 11, Playwright Chromium 151 (bundled), headed mode via `browse connect`
Contributor guide
Research direction
Start in browse/src/stealth.ts at buildStealthScript, then read stealth-layer-c.test.ts and stealth-webdriver.test.ts for the current runtime assertions. Run those tests first, update expectations for the runtime shape and markNative count, and confirm the fake connect/sendMessage surface is no longer present.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- playwright, typescript
- Domain
- devtools
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 82/100