-
-
Notifications
You must be signed in to change notification settings - Fork 76
fix(mobile): support HTTP proxy after SMS registration #276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,7 +7,7 @@ import type { | |||||||||||||||||||||||||||
| WaRawWebSocketInit, | ||||||||||||||||||||||||||||
| WebSocketEventLike | ||||||||||||||||||||||||||||
| } from '@transport/types' | ||||||||||||||||||||||||||||
| import { TEXT_ENCODER } from '@util/bytes' | ||||||||||||||||||||||||||||
| import { bytesToBase64, concatBytes, TEXT_DECODER, TEXT_ENCODER } from '@util/bytes' | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * `RawWebSocket`-shaped adapter over a raw Node TCP socket. Used by the | ||||||||||||||||||||||||||||
|
|
@@ -28,21 +28,61 @@ export class WaMobileTcpSocket implements RawWebSocket { | |||||||||||||||||||||||||||
| private closedClean = true | ||||||||||||||||||||||||||||
| private forceCloseTimer: NodeJS.Timeout | null = null | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| public constructor(url: string, _protocols?: unknown, _options?: WaRawWebSocketInit) { | ||||||||||||||||||||||||||||
| public constructor(url: string, _protocols?: unknown, options?: WaRawWebSocketInit) { | ||||||||||||||||||||||||||||
| const { host, port } = parseTcpUrl(url) | ||||||||||||||||||||||||||||
| this.socket = netConnect({ host, port }) | ||||||||||||||||||||||||||||
| const proxy = resolveHttpProxy(options?.agent) | ||||||||||||||||||||||||||||
| this.socket = proxy | ||||||||||||||||||||||||||||
| ? netConnect({ host: proxy.hostname, port: proxy.port }) | ||||||||||||||||||||||||||||
| : netConnect({ host, port }) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let tunnelReady = proxy === null | ||||||||||||||||||||||||||||
| let proxyResponse: Uint8Array = new Uint8Array(0) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| this.socket.on('connect', () => { | ||||||||||||||||||||||||||||
| if (this.readyState !== WA_READY_STATES.CONNECTING) return | ||||||||||||||||||||||||||||
| this.readyState = WA_READY_STATES.OPEN | ||||||||||||||||||||||||||||
| this.onopen?.({}) | ||||||||||||||||||||||||||||
| if (proxy) { | ||||||||||||||||||||||||||||
| const authority = `${host}:${port}` | ||||||||||||||||||||||||||||
| const lines = [ | ||||||||||||||||||||||||||||
| `CONNECT ${authority} HTTP/1.1`, | ||||||||||||||||||||||||||||
| `Host: ${authority}`, | ||||||||||||||||||||||||||||
| 'Proxy-Connection: Keep-Alive' | ||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||
| if (proxy.authorization) lines.push(`Proxy-Authorization: ${proxy.authorization}`) | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Do not send Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||
| this.socket.write(TEXT_ENCODER.encode(`${lines.join('\r\n')}\r\n\r\n`)) | ||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| this.markOpen() | ||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| this.socket.on('data', (chunk: Uint8Array) => { | ||||||||||||||||||||||||||||
| if (!this.onmessage || this.readyState !== WA_READY_STATES.OPEN) return | ||||||||||||||||||||||||||||
| const copy = new Uint8Array(chunk.byteLength) | ||||||||||||||||||||||||||||
| copy.set(chunk) | ||||||||||||||||||||||||||||
| this.onmessage({ data: copy }) | ||||||||||||||||||||||||||||
| if (!tunnelReady && proxy) { | ||||||||||||||||||||||||||||
| proxyResponse = concatBytes([proxyResponse, chunk]) | ||||||||||||||||||||||||||||
| const headerEnd = findHttpHeaderEnd(proxyResponse) | ||||||||||||||||||||||||||||
| if (headerEnd === -1) { | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a proxy terminates CONNECT headers after 64 KiB, this branch bypasses the advertised oversized-response rejection. Check the completed header length before accepting the 2xx response. Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||
| if (proxyResponse.byteLength > 65_536) { | ||||||||||||||||||||||||||||
|
Comment on lines
+60
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Apply the header limit after locating the terminator. If Proposed fix const headerEnd = findHttpHeaderEnd(proxyResponse)
+if (headerEnd > 65_536 || (headerEnd === -1 && proxyResponse.byteLength > 65_536)) {
+ this.socket.destroy(
+ new Error('WaMobileTcpSocket: proxy response headers too large')
+ )
+ return
+}
if (headerEnd === -1) {
- if (proxyResponse.byteLength > 65_536) {
- this.socket.destroy(
- new Error('WaMobileTcpSocket: proxy response headers too large')
- )
- }
return
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| this.socket.destroy( | ||||||||||||||||||||||||||||
| new Error('WaMobileTcpSocket: proxy response headers too large') | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| const statusLine = TEXT_DECODER.decode(proxyResponse.subarray(0, headerEnd)).split( | ||||||||||||||||||||||||||||
| '\r\n' | ||||||||||||||||||||||||||||
| )[0] | ||||||||||||||||||||||||||||
| if (!/^HTTP\/1\.[01] 2\d\d(?:\s|$)/.test(statusLine)) { | ||||||||||||||||||||||||||||
| this.socket.destroy( | ||||||||||||||||||||||||||||
| new Error(`WaMobileTcpSocket: proxy CONNECT failed (${statusLine})`) | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| const remaining = proxyResponse.subarray(headerEnd + 4) | ||||||||||||||||||||||||||||
| proxyResponse = new Uint8Array(0) | ||||||||||||||||||||||||||||
| tunnelReady = true | ||||||||||||||||||||||||||||
| this.markOpen() | ||||||||||||||||||||||||||||
| if (remaining.byteLength > 0) this.emitMessage(remaining) | ||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| this.emitMessage(chunk) | ||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| this.socket.on('error', (err: Error) => { | ||||||||||||||||||||||||||||
|
|
@@ -66,6 +106,19 @@ export class WaMobileTcpSocket implements RawWebSocket { | |||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| private markOpen(): void { | ||||||||||||||||||||||||||||
| if (this.readyState !== WA_READY_STATES.CONNECTING) return | ||||||||||||||||||||||||||||
| this.readyState = WA_READY_STATES.OPEN | ||||||||||||||||||||||||||||
| this.onopen?.({}) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| private emitMessage(chunk: Uint8Array): void { | ||||||||||||||||||||||||||||
| if (!this.onmessage || this.readyState !== WA_READY_STATES.OPEN) return | ||||||||||||||||||||||||||||
| const copy = new Uint8Array(chunk.byteLength) | ||||||||||||||||||||||||||||
| copy.set(chunk) | ||||||||||||||||||||||||||||
| this.onmessage({ data: copy }) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| public send(data: string | ArrayBuffer | Uint8Array): void { | ||||||||||||||||||||||||||||
| if (this.readyState !== WA_READY_STATES.OPEN) { | ||||||||||||||||||||||||||||
| throw new Error('WaMobileTcpSocket: send() called on non-OPEN socket') | ||||||||||||||||||||||||||||
|
|
@@ -97,6 +150,41 @@ export class WaMobileTcpSocket implements RawWebSocket { | |||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| interface ResolvedHttpProxy { | ||||||||||||||||||||||||||||
| readonly hostname: string | ||||||||||||||||||||||||||||
| readonly port: number | ||||||||||||||||||||||||||||
| readonly authorization?: string | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| function resolveHttpProxy(agent: WaRawWebSocketInit['agent']): ResolvedHttpProxy | null { | ||||||||||||||||||||||||||||
| const value = (agent as unknown as { readonly proxy?: URL | string } | undefined)?.proxy | ||||||||||||||||||||||||||||
| if (value === undefined) return null | ||||||||||||||||||||||||||||
| const proxy = value instanceof URL ? value : new URL(value) | ||||||||||||||||||||||||||||
| if (proxy.protocol !== 'http:') { | ||||||||||||||||||||||||||||
| throw new Error(`WaMobileTcpSocket: unsupported proxy protocol ${proxy.protocol}`) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| const username = decodeURIComponent(proxy.username) | ||||||||||||||||||||||||||||
| const password = decodeURIComponent(proxy.password) | ||||||||||||||||||||||||||||
| const authorization = | ||||||||||||||||||||||||||||
| username || password | ||||||||||||||||||||||||||||
| ? `Basic ${bytesToBase64(TEXT_ENCODER.encode(`${username}:${password}`))}` | ||||||||||||||||||||||||||||
|
Comment on lines
+163
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🤖 get_repo_knowledge executed:
Length of output: 43621 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- WaMobileTcpSocket.ts ---'
sed -n '1,190p' src/transport/node/WaMobileTcpSocket.ts
printf '%s\n' '--- resolveHttpProxy definitions/usages ---'
rg -n -C 5 'resolveHttpProxy|Proxy-Authorization|proxy\.authorization|agent\.proxy' srcRepository: vinikjkkj/zapo Length of output: 11306 Sensitive Data Exposure Reachability: Internal Do not send proxy credentials over an unencrypted HTTP connection. When 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| : undefined | ||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| hostname: proxy.hostname, | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When the configured HTTP proxy uses an IPv6 literal, Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||
| port: proxy.port ? Number(proxy.port) : 80, | ||||||||||||||||||||||||||||
| authorization | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| function findHttpHeaderEnd(bytes: Uint8Array): number { | ||||||||||||||||||||||||||||
| for (let i = 3; i < bytes.byteLength; i += 1) { | ||||||||||||||||||||||||||||
| if (bytes[i - 3] === 13 && bytes[i - 2] === 10 && bytes[i - 1] === 13 && bytes[i] === 10) { | ||||||||||||||||||||||||||||
| return i - 3 | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| return -1 | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| function parseTcpUrl(url: string): { host: string; port: number } { | ||||||||||||||||||||||||||||
| let work = url | ||||||||||||||||||||||||||||
| if (work.startsWith('tcp://')) { | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| import assert from 'node:assert/strict' | ||
| import type { Agent } from 'node:http' | ||
| import { createServer } from 'node:net' | ||
| import test from 'node:test' | ||
|
|
||
| import { WA_READY_STATES } from '@protocol/constants' | ||
|
|
@@ -57,3 +59,58 @@ test('WaMobileTcpSocket.close is idempotent when already CLOSED', () => { | |
| socket.readyState === WA_READY_STATES.CLOSED | ||
| ) | ||
| }) | ||
|
|
||
| test('WaMobileTcpSocket tunnels mobile TCP through an authenticated HTTP CONNECT proxy', async (t) => { | ||
| let request = '' | ||
| const server = createServer((peer) => { | ||
| let pending = Buffer.alloc(0) | ||
| peer.on('data', (chunk) => { | ||
| if (request) { | ||
| peer.write(chunk) | ||
| return | ||
| } | ||
| pending = Buffer.concat([pending, chunk]) | ||
| const end = pending.indexOf('\r\n\r\n') | ||
| if (end === -1) return | ||
| request = pending.subarray(0, end).toString('latin1') | ||
| const remaining = pending.subarray(end + 4) | ||
| peer.write('HTTP/1.1 200 Connection Established\r\n\r\n') | ||
| if (remaining.byteLength > 0) peer.write(remaining) | ||
| }) | ||
| }) | ||
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)) | ||
| const address = server.address() | ||
| assert.ok(address && typeof address === 'object') | ||
| const agent = { | ||
| proxy: new URL(`http://proxy-user:proxy-pass@127.0.0.1:${address.port}`) | ||
| } as unknown as Agent | ||
| const socket = new WaMobileTcpSocket('tcp://g.whatsapp.net:443', undefined, { agent }) | ||
| t.after(() => { | ||
| socket.close() | ||
| server.close() | ||
| }) | ||
| socket.onerror = (event) => assert.fail(`unexpected proxy socket error: ${event.reason}`) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt for AI agents |
||
|
|
||
| await new Promise<void>((resolve, reject) => { | ||
| const timer = setTimeout(() => { | ||
| socket.close() | ||
| reject(new Error(`proxy open timeout; request=${request}`)) | ||
| }, 2_000) | ||
| socket.onopen = () => { | ||
| clearTimeout(timer) | ||
| resolve() | ||
| } | ||
| }) | ||
| assert.match(request, /^CONNECT g\.whatsapp\.net:443 HTTP\/1\.1/m) | ||
| assert.match(request, /Proxy-Authorization: Basic cHJveHktdXNlcjpwcm94eS1wYXNz/i) | ||
|
|
||
| const received = new Promise<Uint8Array>((resolve, reject) => { | ||
| const timer = setTimeout(() => reject(new Error('proxy echo timeout')), 2_000) | ||
| socket.onmessage = (event) => { | ||
| clearTimeout(timer) | ||
| resolve(event.data as Uint8Array) | ||
| } | ||
| }) | ||
| socket.send(new Uint8Array([1, 2, 3])) | ||
| assert.deepEqual(await received, new Uint8Array([1, 2, 3])) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: When
socketOptions.proxy.wsis an undici-style dispatcher (the preferred proxy form per the type docs),toProxyAgent(wsProxy)returnsundefined, so the mobile session connects directly tog.whatsapp.net:443and silently bypasses the proxy. The previous code threw an explicit error for any mobile proxy, so this is a silent egress-bypass regression. Detect dispatcher proxies in the mobile path and either connect through them or fail loudly instead of dropping the proxy config.Prompt for AI agents