Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions src/oauth/dsh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,28 @@
*/

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";

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;
Expand Down Expand Up @@ -80,7 +95,7 @@ export async function validateDshAccountToken(
signal?: AbortSignal,
): Promise<DshValidatedAccountIdentity> {
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",
Expand All @@ -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,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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;
Expand Down
1 change: 1 addition & 0 deletions structure/providers/dsh-account.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
103 changes: 103 additions & 0 deletions tests/oauth/dsh-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
loginDshAccount,
refreshDshAccountToken,
resolveDshCredentialsPath,
DSH_PROFILE_MAX_BYTES,
} from "../../src/oauth/dsh";
import { removeTreeWithRetry } from "../helpers/remove-tree";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Uint8Array>({
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<Uint8Array>({
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<Uint8Array>({
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<Uint8Array>({ 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<Uint8Array>({ 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", () => {
Expand Down
Loading