diff --git a/src/oauth/dsh.ts b/src/oauth/dsh.ts index 6dbf5e29cff..b1ea8b3e8c3 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,35 @@ 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 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" }; + } + + 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..e7285f8cedf 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 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 8c84d97a884..dcaed6acf24 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"; @@ -163,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); @@ -199,6 +213,95 @@ 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; + 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) }, + }); + + 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"); + expect(pulled).toBe(false); + }); + + 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", () => {