Skip to content

Commit 3c43c43

Browse files
bgagentclaude
andcommitted
fix(agentcore-browser): SigV4-presign WSS URL instead of signing headers
Lambda runtime returned a 403 on the WSS upgrade despite well-formed SigV4 headers — `ws` rewrites the Host header during the upgrade GET, which invalidates the canonical-request signature we computed against the original Host. This works locally because Node's tooling on macOS keeps the original Host through the handshake, but the Lambda runtime's TLS stack normalizes differently. Switch to query-parameter SigV4 (presigned URL): SignatureV4.presign returns a wss://...?X-Amz-Algorithm=...&X-Amz-Signature=... URL where the auth lives in the URL itself, so any Host-header rewriting downstream doesn't break the signature. Smoke verified locally — presigned URL connects cleanly to AgentCore Browser and the screenshot pipeline runs end-to-end (6.3s, valid PNG, captures example.com correctly). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2026ecf commit 3c43c43

1 file changed

Lines changed: 37 additions & 18 deletions

File tree

cdk/src/handlers/shared/agentcore-browser.ts

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,14 @@ export async function captureScreenshot(url: string, opts: { timeoutMs?: number
132132
* responsible for the StartBrowserSession + StopBrowserSession lifecycle.
133133
*/
134134
async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): Promise<Uint8Array> {
135-
const headers = await sigV4WsHeaders(wssUrl);
136-
137-
// Use `ws` (the standard Node WebSocket client) — its constructor accepts
138-
// custom HTTP headers on the upgrade GET via `options.headers`. Node 24's
139-
// global `WebSocket` (from undici) does NOT accept arbitrary headers, and
140-
// AgentCore Browser's WSS handshake requires SigV4-signed `Authorization`
141-
// + `X-Amz-*` headers, so we have to inject them somehow.
142-
const ws = new WebSocket(wssUrl, { headers });
135+
// AgentCore Browser's WSS endpoint accepts SigV4 in two forms: signed
136+
// `Authorization` headers OR signed query parameters (presigned URL).
137+
// We use the presigned-URL form because the `Host` header sent by the
138+
// WS upgrade (handled inside `ws`) doesn't always match what we signed
139+
// when using header-based auth, leading to 403s. Query-param signing
140+
// sidesteps the Host-header reconciliation entirely.
141+
const signedUrl = await sigV4PresignWss(wssUrl);
142+
const ws = new WebSocket(signedUrl);
143143

144144
const deadline = Date.now() + timeoutMs;
145145
const remaining = () => Math.max(0, deadline - Date.now());
@@ -308,27 +308,46 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
308308
}
309309

310310
/**
311-
* Build SigV4-signed headers for the WebSocket upgrade request. AgentCore
312-
* Browser's WSS endpoint expects the same SigV4 envelope as a regular
313-
* `bedrock-agentcore` HTTPS call.
311+
* Presign the WSS URL with SigV4 query parameters. AgentCore Browser
312+
* accepts auth either as headers on the upgrade GET or as query params
313+
* on the URL itself; the latter is more robust through WebSocket
314+
* clients that rewrite Host headers (e.g. `ws`).
315+
*
316+
* Returns a `wss://...?X-Amz-Algorithm=...&X-Amz-Credential=...&...`
317+
* URL ready to pass straight to `new WebSocket(...)`.
314318
*/
315-
async function sigV4WsHeaders(wssUrl: string): Promise<Record<string, string>> {
319+
async function sigV4PresignWss(wssUrl: string): Promise<string> {
316320
const u = new URL(wssUrl);
317321
const signer = new SignatureV4({
318322
service: 'bedrock-agentcore',
319323
region: REGION,
320324
credentials: defaultProvider(),
321325
sha256: Sha256,
326+
applyChecksum: false,
322327
});
328+
329+
// Convert wss:// → https:// for the signing request (SigV4 doesn't
330+
// know about wss). The signature is over the path + query, so the
331+
// protocol on the signed request is irrelevant — we paste the auth
332+
// params back onto the original wss:// URL.
333+
const queryEntries = Array.from(u.searchParams.entries());
334+
const query: Record<string, string> = {};
335+
for (const [k, v] of queryEntries) query[k] = v;
336+
323337
const req = new HttpRequest({
324338
method: 'GET',
325339
protocol: 'https:',
326340
hostname: u.hostname,
327-
path: u.pathname + u.search,
328-
headers: {
329-
host: u.hostname,
330-
},
341+
path: u.pathname,
342+
query,
343+
headers: { host: u.hostname },
331344
});
332-
const signed = await signer.sign(req);
333-
return signed.headers;
345+
346+
// 60s expiry is fine — we open the socket immediately after signing.
347+
const presigned = await signer.presign(req, { expiresIn: 60 });
348+
const out = new URL(wssUrl);
349+
for (const [k, v] of Object.entries(presigned.query ?? {})) {
350+
out.searchParams.set(k, Array.isArray(v) ? v[0] : (v as string));
351+
}
352+
return out.toString();
334353
}

0 commit comments

Comments
 (0)