Skip to content

Commit 4411c1c

Browse files
Strip query-param API key after use and add verifyApiKey unit tests
Agent-Logs-Url: https://github.com/geturbackend/urBackend/sessions/60b03a58-af7a-4d2a-81ac-23a79b3a9524 Co-authored-by: yash-pouranik <172860064+yash-pouranik@users.noreply.github.com>
1 parent 4301c6b commit 4411c1c

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
'use strict';
2+
3+
const mockFindOne = jest.fn();
4+
const mockSelect = jest.fn().mockReturnThis();
5+
const mockPopulate = jest.fn().mockReturnThis();
6+
const mockLean = jest.fn();
7+
8+
jest.mock('@urbackend/common', () => ({
9+
Project: {
10+
findOne: jest.fn(() => ({
11+
select: mockSelect,
12+
populate: mockPopulate,
13+
lean: mockLean,
14+
})),
15+
},
16+
hashApiKey: jest.fn((key) => `hashed_${key}`),
17+
getProjectByApiKeyCache: jest.fn().mockResolvedValue(null),
18+
setProjectByApiKeyCache: jest.fn().mockResolvedValue(undefined),
19+
}));
20+
21+
const { hashApiKey, getProjectByApiKeyCache, Project } = require('@urbackend/common');
22+
const verifyApiKey = require('../middlewares/verifyApiKey');
23+
24+
// ---------------------------------------------------------------------------
25+
// Helpers
26+
// ---------------------------------------------------------------------------
27+
28+
function makeProject(overrides = {}) {
29+
return {
30+
_id: 'proj_1',
31+
owner: { isVerified: true },
32+
resources: { db: { isExternal: false }, storage: { isExternal: false } },
33+
allowedDomains: ['*'],
34+
...overrides,
35+
};
36+
}
37+
38+
function makeReq({ headers = {}, query = {} } = {}) {
39+
return {
40+
header: jest.fn((name) => headers[name.toLowerCase()] || undefined),
41+
headers,
42+
query: { ...query },
43+
};
44+
}
45+
46+
function makeRes() {
47+
const res = {
48+
status: jest.fn().mockReturnThis(),
49+
json: jest.fn().mockReturnThis(),
50+
};
51+
return res;
52+
}
53+
54+
// ---------------------------------------------------------------------------
55+
// Tests
56+
// ---------------------------------------------------------------------------
57+
58+
describe('verifyApiKey middleware', () => {
59+
const next = jest.fn();
60+
61+
beforeEach(() => {
62+
jest.clearAllMocks();
63+
getProjectByApiKeyCache.mockResolvedValue(null);
64+
mockLean.mockResolvedValue(makeProject());
65+
});
66+
67+
test('accepts publishable key via x-api-key header', async () => {
68+
const req = makeReq({ headers: { 'x-api-key': 'pk_live_headerkey' } });
69+
const res = makeRes();
70+
71+
await verifyApiKey(req, res, next);
72+
73+
expect(next).toHaveBeenCalled();
74+
expect(req.keyRole).toBe('publishable');
75+
});
76+
77+
test('accepts publishable key via ?key= query param', async () => {
78+
const req = makeReq({ query: { key: 'pk_live_querykey' } });
79+
const res = makeRes();
80+
81+
await verifyApiKey(req, res, next);
82+
83+
expect(next).toHaveBeenCalled();
84+
expect(req.keyRole).toBe('publishable');
85+
});
86+
87+
test('strips ?key= from req.query after reading it', async () => {
88+
const req = makeReq({ query: { key: 'pk_live_querykey', other: 'keep' } });
89+
const res = makeRes();
90+
91+
await verifyApiKey(req, res, next);
92+
93+
expect(req.query.key).toBeUndefined();
94+
expect(req.query.other).toBe('keep');
95+
});
96+
97+
test('rejects secret key supplied via ?key= query param', async () => {
98+
const req = makeReq({ query: { key: 'sk_live_secretkey' } });
99+
const res = makeRes();
100+
101+
await verifyApiKey(req, res, next);
102+
103+
expect(next).not.toHaveBeenCalled();
104+
expect(res.status).toHaveBeenCalledWith(401);
105+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'API key not found' }));
106+
});
107+
108+
test('header takes precedence when both x-api-key header and ?key= query param are present', async () => {
109+
const req = makeReq({
110+
headers: { 'x-api-key': 'pk_live_headerkey' },
111+
query: { key: 'pk_live_querykey' },
112+
});
113+
const res = makeRes();
114+
115+
await verifyApiKey(req, res, next);
116+
117+
expect(next).toHaveBeenCalled();
118+
// hashApiKey is called with the header key, not the query key
119+
expect(hashApiKey).toHaveBeenCalledWith('pk_live_headerkey');
120+
expect(hashApiKey).not.toHaveBeenCalledWith('pk_live_querykey');
121+
});
122+
123+
test('?key= is stripped even when it is not a valid pk_live_ key', async () => {
124+
const req = makeReq({
125+
headers: { 'x-api-key': 'pk_live_headerkey' },
126+
query: { key: 'sk_live_secretkey' },
127+
});
128+
const res = makeRes();
129+
130+
await verifyApiKey(req, res, next);
131+
132+
expect(req.query.key).toBeUndefined();
133+
expect(next).toHaveBeenCalled();
134+
});
135+
136+
test('returns 401 when no key is provided at all', async () => {
137+
const req = makeReq();
138+
const res = makeRes();
139+
140+
await verifyApiKey(req, res, next);
141+
142+
expect(next).not.toHaveBeenCalled();
143+
expect(res.status).toHaveBeenCalledWith(401);
144+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'API key not found' }));
145+
});
146+
147+
test('returns 401 when project is not found in DB', async () => {
148+
mockLean.mockResolvedValueOnce(null);
149+
const req = makeReq({ query: { key: 'pk_live_unknown' } });
150+
const res = makeRes();
151+
152+
await verifyApiKey(req, res, next);
153+
154+
expect(next).not.toHaveBeenCalled();
155+
expect(res.status).toHaveBeenCalledWith(401);
156+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'API key is expired or invalid.' }));
157+
});
158+
159+
test('returns 401 when owner is not verified', async () => {
160+
getProjectByApiKeyCache.mockResolvedValueOnce(makeProject({ owner: { isVerified: false } }));
161+
const req = makeReq({ query: { key: 'pk_live_unverifiedowner' } });
162+
const res = makeRes();
163+
164+
await verifyApiKey(req, res, next);
165+
166+
expect(next).not.toHaveBeenCalled();
167+
expect(res.status).toHaveBeenCalledWith(401);
168+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Owner not verified' }));
169+
});
170+
171+
test('uses cache when available and does not query DB', async () => {
172+
getProjectByApiKeyCache.mockResolvedValueOnce(makeProject());
173+
const req = makeReq({ query: { key: 'pk_live_cached' } });
174+
const res = makeRes();
175+
176+
await verifyApiKey(req, res, next);
177+
178+
expect(next).toHaveBeenCalled();
179+
expect(Project.findOne).not.toHaveBeenCalled();
180+
});
181+
});

‎apps/public-api/src/middlewares/verifyApiKey.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ module.exports = async (req, res, next) => {
1414

1515
// Only allow publishable keys (pk_live_) via query param; secret keys must use the header.
1616
const apiKey = headerKey || (queryKey?.startsWith('pk_live_') ? queryKey : undefined);
17+
18+
// Strip the key from req.query immediately after reading so it is not forwarded to
19+
// downstream middleware, controllers, or access logs.
20+
if (req.query && typeof req.query === 'object') {
21+
delete req.query.key;
22+
}
23+
1724
if (!apiKey) {
1825
return res.status(401).json({ error: 'API key not found' });
1926
}

0 commit comments

Comments
 (0)