From 0308973711cd944f254c5efde73ac57d70a69b3b Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 18 Oct 2026 11:34:15 -0700 Subject: [PATCH 1/2] fix(dsh): bound account profile response reads Use the shared bounded reader for the fixed HTTPS profile validation during explicit DSH import. Keep the existing request budget, cap decoded input at 64 KiB and request best-effort cancellation; add synthetic regressions and contract documentation. --- src/oauth/dsh.ts | 45 ++++++++++++++++- structure/providers/dsh-account.md | 1 + tests/oauth/dsh-login.test.ts | 81 ++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/src/oauth/dsh.ts b/src/oauth/dsh.ts index 6dbf5e29cff..29995ca7a0e 100644 --- a/src/oauth/dsh.ts +++ b/src/oauth/dsh.ts @@ -9,6 +9,7 @@ */ import type { OAuthController, OAuthCredentials } from "./types"; +import { readBoundedResponseBody } from "../lib/bounded-body"; import { homedir } from "node:os"; import { join } from "node:path"; import { existsSync, readFileSync } from "node:fs"; @@ -16,6 +17,20 @@ import { existsSync, readFileSync } from "node:fs"; export const DSH_PLATFORM_ORIGIN = "https://platform.deepseek.com"; export const DSH_INFERENCE_ORIGIN = "https://api.deepseek.com/anthropic"; +/** + * Byte ceiling for the profile document returned by /auth-api/v0/users/current. + * + * A legitimate profile is a small fixed-shape JSON object (code, data.biz_code, + * biz_data.id/email/id_profile{name,picture}) well under a few KiB; 64 KiB matches + * the shared bounded-body ceiling and still leaves orders of magnitude of + * headroom. Anything larger is rejected before parsing so a hostile or broken + * upstream cannot turn an import validation into unbounded memory work. + */ +export const DSH_PROFILE_MAX_BYTES = 64 * 1024; + +/** Wall-clock budget for the profile request and its body read, in milliseconds. */ +const DSH_PROFILE_FETCH_TIMEOUT_MS = 10_000; + export interface DshAccountDetectionResult { detected: boolean; token?: string; @@ -80,7 +95,7 @@ export async function validateDshAccountToken( signal?: AbortSignal, ): Promise { try { - const timeoutSignal = AbortSignal.timeout(10_000); + const timeoutSignal = AbortSignal.timeout(DSH_PROFILE_FETCH_TIMEOUT_MS); const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; const response = await fetch(`${DSH_PLATFORM_ORIGIN}/auth-api/v0/users/current`, { method: "GET", @@ -99,7 +114,33 @@ export async function validateDshAccountToken( return { valid: false, error: `Platform returned HTTP ${response.status}` }; } - const body = (await response.json()) as { + // A declared length past the cap is rejected without touching the stream. + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > DSH_PROFILE_MAX_BYTES) { + try { + void response.body?.cancel( + new DOMException("DSH profile response is too large", "QuotaExceededError"), + ).catch(() => undefined); + } catch { + // Best-effort cancellation only. + } + return { valid: false, error: "PROFILE_VALIDATION_FAILED" }; + } + + // Missing or understated Content-Length still meets the cap on the decoded + // stream itself: once an over-limit byte is observed the retained buffer is + // discarded and the body is cancelled. + const bounded = await readBoundedResponseBody(response, { + signal: combinedSignal, + maxBytes: DSH_PROFILE_MAX_BYTES, + totalTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS, + inactivityTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS, + }); + if (bounded.oversized || bounded.truncated || !bounded.displaySafe) { + return { valid: false, error: "PROFILE_VALIDATION_FAILED" }; + } + + const body = JSON.parse(bounded.text) as { code?: number; data?: { biz_code?: number; diff --git a/structure/providers/dsh-account.md b/structure/providers/dsh-account.md index 083c5df2a15..38c69914ef0 100644 --- a/structure/providers/dsh-account.md +++ b/structure/providers/dsh-account.md @@ -7,6 +7,7 @@ `src/oauth/dsh.ts` reads `$DSH_HOME/.credentials.yaml` (default `~/.dsh/.credentials.yaml`) only inside `loginDshAccount`, which runs on `ocx login dsh-account` or the dashboard's **Log in** action for the provider. Nothing else reads the file, and nothing in opencodex writes, deletes or changes the permissions of it. The `deepseek-account-platform/default` grant record is validated against `https://platform.deepseek.com/auth-api/v0/users/current` with manual redirects before anything is stored. Only a successful business response with a nonempty authoritative account ID imports. A missing ID fails closed; email, phone and display name never supply a slot identity, so two DSH accounts sharing an email land in different slots and reauthentication uses the shared account-ID comparison in `src/oauth/index.ts`. Parser and transport failures surface fixed error codes instead of credential or profile fragments. +The profile body is consumed under a fixed 64 KiB decoded-byte ceiling (`DSH_PROFILE_MAX_BYTES`) with the same bounded-read helper the quota path uses: a Content-Length above the cap is rejected without reading, and the streamed body is cancelled once it exceeds the cap, so a hostile or broken upstream cannot turn an import validation into unbounded memory work. The imported copy lives in the opencodex credential store. Removing the account deletes only that copy and never signs DSH Desktop out. diff --git a/tests/oauth/dsh-login.test.ts b/tests/oauth/dsh-login.test.ts index 8c84d97a884..4ef331e02ea 100644 --- a/tests/oauth/dsh-login.test.ts +++ b/tests/oauth/dsh-login.test.ts @@ -8,6 +8,7 @@ import { loginDshAccount, refreshDshAccountToken, resolveDshCredentialsPath, + DSH_PROFILE_MAX_BYTES, } from "../../src/oauth/dsh"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -199,6 +200,86 @@ describe("DSH Account Validation", () => { expect(res.valid).toBe(false); expect(evilTargetReceivedToken).toBe(false); }); + + test("rejects a declared oversized profile body without reading the stream", async () => { + let cancelReason: unknown; + const body = new ReadableStream({ + cancel(reason) { + cancelReason = reason; + }, + }); + globalThis.fetch = async () => new Response(body, { + status: 200, + headers: { "content-length": String(DSH_PROFILE_MAX_BYTES + 1) }, + }); + + const res = await validateDshAccountToken("mock-token"); + expect(res).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); + expect(cancelReason).toBeInstanceOf(DOMException); + expect((cancelReason as DOMException).name).toBe("QuotaExceededError"); + }); + + test("rejects an oversized profile stream when content-length is missing or understated", async () => { + for (const headers of [undefined, { "content-length": "8" }]) { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(DSH_PROFILE_MAX_BYTES + 1)); + }, + cancel() { + cancelled = true; + }, + }); + globalThis.fetch = async () => new Response(body, { status: 200, headers }); + + const res = await validateDshAccountToken("mock-token"); + expect(res).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); + expect(cancelled).toBe(true); + } + }); + + test("accepts a profile document at the byte cap and rejects one byte over a chunked boundary", async () => { + const base = JSON.stringify({ + code: 0, + data: { biz_code: 0, biz_data: { id: "user-cap", email: "cap@example.com" } }, + }); + const encoder = new TextEncoder(); + const atCap = base + " ".repeat(DSH_PROFILE_MAX_BYTES - base.length); + expect(encoder.encode(atCap).byteLength).toBe(DSH_PROFILE_MAX_BYTES); + + globalThis.fetch = async () => new Response(atCap, { status: 200 }); + const ok = await validateDshAccountToken("mock-token"); + expect(ok.valid).toBe(true); + expect(ok.accountId).toBe("user-cap"); + + const overCap = encoder.encode(atCap + " "); + const chunked = new ReadableStream({ + start(controller) { + controller.enqueue(overCap.subarray(0, DSH_PROFILE_MAX_BYTES)); + controller.enqueue(overCap.subarray(DSH_PROFILE_MAX_BYTES)); + }, + }); + globalThis.fetch = async () => new Response(chunked, { status: 200 }); + expect(await validateDshAccountToken("mock-token")).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); + }); + + test("rejects with the caller's abort reason while the profile body is still streaming", async () => { + const body = new ReadableStream({ start() { /* open stream, no data */ } }); + globalThis.fetch = async () => new Response(body, { status: 200 }); + const controller = new AbortController(); + const reason = new DOMException("operator cancelled", "AbortError"); + const pending = validateDshAccountToken("mock-token", controller.signal); + await new Promise(resolve => setTimeout(resolve, 10)); + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + }); + + test("surfaces a stalled profile body as PROFILE_VALIDATION_FAILED instead of hanging", async () => { + const body = new ReadableStream({ start() { /* open stream, no data */ } }); + globalThis.fetch = async () => new Response(body, { status: 200 }); + expect(await validateDshAccountToken("mock-token")) + .toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); + }, 30_000); }); describe("DSH Account Login Flow", () => { From e859e404868e5f2a815043dbdf4a95359a8c55a6 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 18 Oct 2026 12:24:26 -0700 Subject: [PATCH 2/2] fix(dsh): reject malformed profile encoding Use fatal UTF-8 decoding for account-profile validation, clarify best-effort cancellation and make the declared-length fast-path test observe reads directly. Preserve the existing size and time limits. --- src/oauth/dsh.ts | 4 +++- structure/providers/dsh-account.md | 2 +- tests/oauth/dsh-login.test.ts | 24 +++++++++++++++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/oauth/dsh.ts b/src/oauth/dsh.ts index 29995ca7a0e..b1ea8b3e8c3 100644 --- a/src/oauth/dsh.ts +++ b/src/oauth/dsh.ts @@ -129,12 +129,14 @@ export async function validateDshAccountToken( // Missing or understated Content-Length still meets the cap on the decoded // stream itself: once an over-limit byte is observed the retained buffer is - // discarded and the body is cancelled. + // discarded and cancellation is requested on the body. Malformed UTF-8 fails + // closed so a replacement character can never alter the authoritative ID. const bounded = await readBoundedResponseBody(response, { signal: combinedSignal, maxBytes: DSH_PROFILE_MAX_BYTES, totalTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS, inactivityTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS, + fatalUtf8: true, }); if (bounded.oversized || bounded.truncated || !bounded.displaySafe) { return { valid: false, error: "PROFILE_VALIDATION_FAILED" }; diff --git a/structure/providers/dsh-account.md b/structure/providers/dsh-account.md index 38c69914ef0..e7285f8cedf 100644 --- a/structure/providers/dsh-account.md +++ b/structure/providers/dsh-account.md @@ -7,7 +7,7 @@ `src/oauth/dsh.ts` reads `$DSH_HOME/.credentials.yaml` (default `~/.dsh/.credentials.yaml`) only inside `loginDshAccount`, which runs on `ocx login dsh-account` or the dashboard's **Log in** action for the provider. Nothing else reads the file, and nothing in opencodex writes, deletes or changes the permissions of it. The `deepseek-account-platform/default` grant record is validated against `https://platform.deepseek.com/auth-api/v0/users/current` with manual redirects before anything is stored. Only a successful business response with a nonempty authoritative account ID imports. A missing ID fails closed; email, phone and display name never supply a slot identity, so two DSH accounts sharing an email land in different slots and reauthentication uses the shared account-ID comparison in `src/oauth/index.ts`. Parser and transport failures surface fixed error codes instead of credential or profile fragments. -The profile body is consumed under a fixed 64 KiB decoded-byte ceiling (`DSH_PROFILE_MAX_BYTES`) with the same bounded-read helper the quota path uses: a Content-Length above the cap is rejected without reading, and the streamed body is cancelled once it exceeds the cap, so a hostile or broken upstream cannot turn an import validation into unbounded memory work. +The profile body is consumed under a fixed 64 KiB decoded-byte ceiling (`DSH_PROFILE_MAX_BYTES`) with the same bounded-read helper the quota path uses: a Content-Length above the cap is rejected without reading, and cancellation is requested on the streamed body once it exceeds the cap, so a hostile or broken upstream cannot turn an import validation into unbounded memory work. Malformed UTF-8 in the body fails validation instead of letting a replacement character alter the authoritative account ID. The imported copy lives in the opencodex credential store. Removing the account deletes only that copy and never signs DSH Desktop out. diff --git a/tests/oauth/dsh-login.test.ts b/tests/oauth/dsh-login.test.ts index 4ef331e02ea..dcaed6acf24 100644 --- a/tests/oauth/dsh-login.test.ts +++ b/tests/oauth/dsh-login.test.ts @@ -164,6 +164,19 @@ describe("DSH Account Validation", () => { expect(await validateDshAccountToken("mock-token")).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); }); + test("rejects malformed UTF-8 inside the authoritative account ID instead of accepting an altered ID", async () => { + const encoder = new TextEncoder(); + const prefix = encoder.encode('{"code":0,"data":{"biz_code":0,"biz_data":{"id":"user-'); + const suffix = encoder.encode('","email":"u@example.com"}}}'); + const malformed = new Uint8Array(prefix.length + 1 + suffix.length); + malformed.set(prefix); + malformed.set([0xff], prefix.length); + malformed.set(suffix, prefix.length + 1); + globalThis.fetch = async () => new Response(malformed, { status: 200 }); + + expect(await validateDshAccountToken("mock-token")).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); + }); + test("returns ACCOUNT_TOKEN_INVALID when upstream returns 401", async () => { globalThis.fetch = async (input, init) => { const url = String(input); @@ -203,11 +216,19 @@ describe("DSH Account Validation", () => { test("rejects a declared oversized profile body without reading the stream", async () => { let cancelReason: unknown; + let pulled = false; const body = new ReadableStream({ + pull(controller) { + // A regression that reads the body closes it instead of stalling on an + // open stream, so this asserts the pull flag directly instead of + // failing through an indirect timeout path. + pulled = true; + controller.close(); + }, cancel(reason) { cancelReason = reason; }, - }); + }, { highWaterMark: 0 }); globalThis.fetch = async () => new Response(body, { status: 200, headers: { "content-length": String(DSH_PROFILE_MAX_BYTES + 1) }, @@ -217,6 +238,7 @@ describe("DSH Account Validation", () => { expect(res).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); expect(cancelReason).toBeInstanceOf(DOMException); expect((cancelReason as DOMException).name).toBe("QuotaExceededError"); + expect(pulled).toBe(false); }); test("rejects an oversized profile stream when content-length is missing or understated", async () => {