Skip to content
Merged
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
18 changes: 10 additions & 8 deletions src/app/api/cli-tools/antigravity-mitm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,23 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { apiKey: rawApiKey, sudoPassword } = validation.data;
// (#523) Extract keyId BEFORE validation — Zod strips unknown fields!
const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
const { apiKey: rawApiKey, keyId: rawKeyId, sudoPassword } = validation.data;
const apiKeyId = rawKeyId ?? null;
const apiKey = await resolveApiKey(apiKeyId, rawApiKey);
if (!apiKey || apiKey === "sk_omniroute") {
return NextResponse.json(
{ error: "Missing apiKey: provide a valid apiKey or a resolvable keyId" },
{ status: 400 }
);
}
const { startMitm, getCachedPassword, setCachedPassword } =
await import("@/mitm/manager.runtime");
const isWin = process.platform === "win32";
const isRootUser = !isWin && isRoot();
const pwd = sudoPassword || getCachedPassword() || "";

if (!apiKey || (!isWin && !pwd && !isRootUser)) {
return NextResponse.json(
{ error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" },
{ status: 400 }
);
if (!isWin && !pwd && !isRootUser) {
return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 });
}

const result = await startMitm(apiKey, pwd);
Expand Down
3 changes: 2 additions & 1 deletion src/shared/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1996,7 +1996,8 @@ export const v1betaGeminiGenerateSchema = z
});

export const cliMitmStartSchema = z.object({
apiKey: z.string().trim().min(1, "Missing apiKey"),
apiKey: z.string().trim().min(1).nullable().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since Zod's z.object() strips unrecognized keys by default during parsing, any keyId sent by the frontend will be stripped from the validated output. To allow the backend to receive and resolve the keyId as described in the PR description, keyId should be explicitly defined in the schema.

Suggested change
apiKey: z.string().trim().min(1).nullable().optional(),
apiKey: z.string().trim().min(1).nullable().optional(),
keyId: z.string().trim().min(1).optional(),

keyId: z.string().trim().min(1).nullable().optional(),
sudoPassword: z.string().optional(),
});

Expand Down
85 changes: 85 additions & 0 deletions tests/unit/cli-mitm-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import { cliMitmStartSchema } from "../../src/shared/validation/schemas.ts";
import { validateBody } from "../../src/shared/validation/helpers.ts";
import { resolveApiKey } from "../../src/shared/services/apiKeyResolver.ts";

test("cliMitmStartSchema accepts a non-empty string apiKey", () => {
const result = validateBody(cliMitmStartSchema, {
apiKey: "sk-test-key-value",
sudoPassword: "password123",
});
assert.equal(result.success, true);
if (result.success) {
assert.equal(result.data.apiKey, "sk-test-key-value");
assert.equal(result.data.sudoPassword, "password123");
}
});

test("cliMitmStartSchema accepts a null apiKey", () => {
const result = validateBody(cliMitmStartSchema, {
apiKey: null,
sudoPassword: "",
});
assert.equal(result.success, true);
if (result.success) {
assert.equal(result.data.apiKey, null);
assert.equal(result.data.sudoPassword, "");
}
});

test("cliMitmStartSchema accepts an omitted apiKey", () => {
const result = validateBody(cliMitmStartSchema, {
sudoPassword: "",
});
assert.equal(result.success, true);
if (result.success) {
assert.equal(result.data.apiKey, undefined);
}
});
Comment on lines +31 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure the new keyId field is properly validated and not stripped, we should add a unit test verifying that keyId is accepted and correctly parsed by the schema.

test("cliMitmStartSchema accepts an omitted apiKey", () => {
  const result = validateBody(cliMitmStartSchema, {
    sudoPassword: "",
  });
  assert.equal(result.success, true);
  if (result.success) {
    assert.equal(result.data.apiKey, undefined);
  }
});

test("cliMitmStartSchema accepts a keyId instead of apiKey", () => {
  const result = validateBody(cliMitmStartSchema, {
    keyId: "key_12345",
    sudoPassword: "",
  });
  assert.equal(result.success, true);
  if (result.success) {
    assert.equal(result.data.keyId, "key_12345");
    assert.equal(result.data.apiKey, undefined);
  }
});


test("cliMitmStartSchema accepts and parses keyId correctly", () => {
const result = validateBody(cliMitmStartSchema, {
keyId: "api-key-id-123",
sudoPassword: "password",
});
assert.equal(result.success, true);
if (result.success) {
assert.equal(result.data.keyId, "api-key-id-123");
assert.equal(result.data.apiKey, undefined);
}
});

test("cliMitmStartSchema accepts null keyId", () => {
const result = validateBody(cliMitmStartSchema, {
keyId: null,
sudoPassword: "",
});
assert.equal(result.success, true);
if (result.success) {
assert.equal(result.data.keyId, null);
}
});

// Regression test: null apiKey + unresolvable keyId must yield the sentinel 'sk_omniroute',
// which the route guard must reject with a 400 rather than letting it pass to startMitm.
test("resolveApiKey returns sentinel when apiKey is null and keyId is null", async () => {
const result = await resolveApiKey(null, null);
assert.equal(
result,
"sk_omniroute",
"resolveApiKey should return the sentinel when no real key is available"
);
});

test("sentinel guard condition catches sk_omniroute and null", () => {
const SENTINEL = "sk_omniroute";
// Simulate what the route guard checks: (!apiKey || apiKey === 'sk_omniroute')
const shouldReject = (apiKey: string | null | undefined): boolean =>
!apiKey || apiKey === SENTINEL;

assert.equal(shouldReject(null), true, "null apiKey must be rejected");
assert.equal(shouldReject(undefined), true, "undefined apiKey must be rejected");
assert.equal(shouldReject("sk_omniroute"), true, "sentinel must be rejected");
assert.equal(shouldReject("sk-real-key-abc"), false, "real key must be allowed");
});