File size: 6,139 Bytes
20f83d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
// @vitest-environment node

/**
 * #5379 β€” `validateUserApiKey` trusted two things it never checked.
 *
 * Gap 2 (state corruption / elevation of privilege): the value returned by
 *   `cachedFetchJson<UserKeyResult>` was CAST, never validated. A poisoned
 *   cache entry or a Convex response shape drift (e.g. `{}`) produced a truthy
 *   object, and every caller β€” server/gateway.ts, server/_shared/premium-check.ts,
 *   api/mcp/auth.ts β€” treats truthy as "authenticated principal", reading
 *   `.userId` as `undefined`.
 *
 * Gap 3 (malformed-key amplification): the only guard was `startsWith('wm_')`,
 *   so `wm_x` reached SHA-256, the Redis cache, and the Convex backend. The
 *   real key contract is `wm_` + 40 lowercase hex, already enforced by the
 *   sibling module api/_user-api-key.js. The two modules disagreed.
 *
 * The amplification assertions deliberately check that the backend was NEVER
 * INVOKED β€” a null return alone would still have burned a Convex round-trip.
 */

import { describe, test, expect, vi, beforeEach } from "vitest";

const cachedFetchJson = vi.fn();
const deleteRedisKey = vi.fn();
vi.mock("../_shared/redis", () => ({
  cachedFetchJson: (...a: unknown[]) => cachedFetchJson(...a),
  deleteRedisKey: (...a: unknown[]) => deleteRedisKey(...a),
}));

import { validateUserApiKey } from "../_shared/user-api-key";

const VALID_KEY = `wm_${"a1b2c3d4e5".repeat(4)}`; // wm_ + 40 lowercase hex
const VALID_RESULT = { userId: "user_123", keyId: "k1", name: "prod" };

beforeEach(() => {
  cachedFetchJson.mockReset();
  deleteRedisKey.mockReset();
});

describe("validateUserApiKey β€” positive control", () => {
  test("a canonical key with a conforming payload resolves to the principal", async () => {
    cachedFetchJson.mockResolvedValue(VALID_RESULT);
    await expect(validateUserApiKey(VALID_KEY)).resolves.toEqual(VALID_RESULT);
    expect(cachedFetchJson).toHaveBeenCalledTimes(1);
  });

  test("a legitimate negative-cache hit (null) stays null, not an error", async () => {
    cachedFetchJson.mockResolvedValue(null);
    await expect(validateUserApiKey(VALID_KEY)).resolves.toBeNull();
    expect(cachedFetchJson).toHaveBeenCalledTimes(1);
  });
});

describe("Gap 2 β€” non-conforming backend/cache payloads must not authenticate", () => {
  const POISONED: Array<[string, unknown]> = [
    ["empty object", {}],
    ["empty userId", { userId: "" }],
    ["numeric userId", { userId: 123 }],
    ["null userId", { userId: null }],
    ["array", []],
    ["bare string", "string"],
    ["number", 7],
    ["true", true],
    ["userId is an object", { userId: {} }],
    ["userId only on the prototype", Object.create({ userId: "u1" })],
  ];

  for (const [label, payload] of POISONED) {
    test(`${label} β†’ null`, async () => {
      cachedFetchJson.mockResolvedValue(payload);
      await expect(validateUserApiKey(VALID_KEY)).resolves.toBeNull();
    });
  }
});

/**
 * The guard must require ONLY `userId`. Two producers write the shared
 * `user-api-key:<hash>` entry with different shapes, and Convex's
 * validateKeyByHash (convex/apiKeys.ts) returns `id` β€” NOT `keyId`. A guard
 * demanding `keyId: string` would 401 every fresh Convex validation in
 * production while every mock-shaped unit test stayed green.
 */
describe("Gap 2 β€” shapes that MUST still authenticate (fail-closed guard)", () => {
  const ACCEPTED: Array<[string, unknown]> = [
    ["real Convex validateKeyByHash row (id, not keyId)", { id: "j97xyz", userId: "u1", name: "prod" }],
    ["api/_user-api-key.js cache write ({userId, keyId, name})", { userId: "u1", keyId: "j97xyz", name: "prod" }],
    ["userId only", { userId: "u1" }],
    ["keyId/name undefined", { userId: "u1", keyId: undefined, name: undefined }],
  ];

  for (const [label, payload] of ACCEPTED) {
    test(`${label} β†’ authenticates`, async () => {
      cachedFetchJson.mockResolvedValue(payload);
      await expect(validateUserApiKey(VALID_KEY)).resolves.toEqual(payload);
    });
  }
});

describe("Gap 3 β€” malformed keys are rejected without amplification", () => {
  const MALFORMED: Array<[string, string]> = [
    ["too short (wm_x)", "wm_x"],
    ["39 hex", `wm_${"a".repeat(39)}`],
    ["41 hex", `wm_${"a".repeat(41)}`],
    ["40 UPPERCASE hex", `wm_${"A1B2C3D4E5".repeat(4)}`],
    ["40 non-hex chars", `wm_${"z".repeat(40)}`],
    ["prefix only", "wm_"],
    ["trailing whitespace", `wm_${"a".repeat(40)} `],
    ["leading whitespace", ` wm_${"a".repeat(40)}`],
    ["embedded newline", `wm_${"a".repeat(40)}\n`],
    ["64 hex (enterprise-shaped, not a user key)", `wm_${"a".repeat(64)}`],
  ];

  for (const [label, key] of MALFORMED) {
    test(`${label} β†’ null AND no hashing, no cache, no Convex call`, async () => {
      const digest = vi.spyOn(crypto.subtle, "digest");
      try {
        cachedFetchJson.mockResolvedValue(VALID_RESULT); // would authenticate if reached
        await expect(validateUserApiKey(key)).resolves.toBeNull();
        expect(cachedFetchJson).not.toHaveBeenCalled();
        expect(digest).not.toHaveBeenCalled();
      } finally {
        digest.mockRestore();
      }
    });
  }

  test("non-wm_ and empty inputs still short-circuit", async () => {
    cachedFetchJson.mockResolvedValue(VALID_RESULT);
    for (const key of ["", "sk_live_abc", "wms_session"]) {
      await expect(validateUserApiKey(key)).resolves.toBeNull();
    }
    expect(cachedFetchJson).not.toHaveBeenCalled();
  });
});

describe("format contract agrees with the sibling module", () => {
  test("api/_user-api-key.js USER_API_KEY_RE and this module's regex are identical", async () => {
    const { readFileSync } = await import("node:fs");
    const extract = (path: string, name: string) => {
      const src = readFileSync(new URL(path, import.meta.url), "utf8");
      const m = src.match(new RegExp(`${name}\\s*=\\s*(/[^\\n]*?/)\\s*;`));
      if (!m) throw new Error(`${name} not found in ${path}`);
      return m[1];
    };
    expect(extract("../_shared/user-api-key.ts", "USER_API_KEY_RE")).toBe(
      extract("../../api/_user-api-key.js", "USER_API_KEY_RE"),
    );
  });
});