Skip to content

Commit 4c4c30a

Browse files
committed
feat(proxy): enhance public key handling and add tests for key formats
1 parent 5644d3b commit 4c4c30a

3 files changed

Lines changed: 99 additions & 2 deletions

File tree

packages/cli/src/actions/proxy.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,32 @@ type Options = {
4444
*/
4545
type UserClaim = { type: 'superUser' } | { type: 'user'; data: Record<string, unknown> };
4646

47+
/**
48+
* Accepts a public key in either PEM format or as a raw base64 / base64url DER string
49+
* (without the `-----BEGIN PUBLIC KEY-----` markers) and always returns a PEM string.
50+
*/
51+
function normalizePublicKey(key: string): string {
52+
key = key.trim();
53+
if (key.startsWith('-----BEGIN PUBLIC KEY-----')) {
54+
return key;
55+
}
56+
// Convert base64url → standard base64, then wrap in PEM markers.
57+
const b64 = key.replace(/-/g, '+').replace(/_/g, '/');
58+
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
59+
}
60+
4761
export async function run(options: Options) {
62+
// Resolve public key: CLI arg takes precedence, then ZENSTACK_PUBLIC_KEY env var.
63+
options = { ...options, publicAPIKey: options.publicAPIKey ?? process.env['ZENSTACK_PUBLIC_KEY'] };
64+
if (!options.publicAPIKey) {
65+
console.warn(
66+
colors.yellow(
67+
'Warning: This proxy has no authentication. Do not expose it to the public network.\n' +
68+
'To secure it, get an API key from ZenStack Studio and set it via the ZENSTACK_PUBLIC_KEY environment variable.',
69+
),
70+
);
71+
}
72+
4873
const allowedLogLevels = ['error', 'query'] as const;
4974
const log = options.logLevel?.filter((level): level is (typeof allowedLogLevels)[number] =>
5075
allowedLogLevels.includes(level as any),
@@ -241,7 +266,8 @@ export function createProxyApp(
241266
if (options?.publicAPIKey) {
242267
// Apply signature-verification middleware to all authenticated endpoints.
243268
const toleranceSecs = options.signatureToleranceSecs ?? 60;
244-
app.use(['/api/model', '/api/schema'], createSignatureMiddleware(options.publicAPIKey, toleranceSecs));
269+
const normalizedKey = normalizePublicKey(options.publicAPIKey);
270+
app.use(['/api/model', '/api/schema'], createSignatureMiddleware(normalizedKey, toleranceSecs));
245271
}
246272

247273
app.use(
@@ -271,6 +297,23 @@ export function createProxyApp(
271297
* `authorizationToken` is the bearer token value from the `Authorization` header (if present).
272298
*/
273299
function createSignatureMiddleware(publicKey: string, toleranceSeconds: number) {
300+
// Throttle invalid-signature warnings to at most once per 60 seconds.
301+
let lastInvalidSigWarnAt = 0;
302+
const WARN_THROTTLE_SECS = 60;
303+
304+
function warnInvalidSignature() {
305+
const now = Math.floor(Date.now() / 1000);
306+
if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) {
307+
lastInvalidSigWarnAt = now;
308+
console.warn(
309+
colors.yellow(
310+
'Warning: Received a request with an invalid signature. ' +
311+
'Please double-check whether you have the correct public API key configured.',
312+
),
313+
);
314+
}
315+
}
316+
274317
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
275318
const signatureHeader = req.headers['x-zenstack-signature'];
276319
if (!signatureHeader || typeof signatureHeader !== 'string') {
@@ -312,9 +355,11 @@ function createSignatureMiddleware(publicKey: string, toleranceSeconds: number)
312355
try {
313356
const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url'));
314357
if (!isValid) {
358+
warnInvalidSignature();
315359
return res.status(401).json({ message: 'Invalid signature' });
316360
}
317361
} catch {
362+
warnInvalidSignature();
318363
return res.status(401).json({ message: 'Invalid signature' });
319364
}
320365

packages/cli/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ Arguments following -- are passed to the seed script. E.g.: "zen db seed -- --us
266266
.addOption(
267267
new Option(
268268
'--publicAPIKey <key>',
269-
'PEM-encoded ed25519 public key used to verify request signatures. When provided, all requests to /api/model and /api/schema must include a valid x-zenstack-signature header.',
269+
'public key used to verify request signatures. Can also be set via the ZENSTACK_PUBLIC_KEY environment variable. ',
270270
),
271271
)
272272
.addOption(

packages/cli/test/proxy.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const TEST_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
1616
MCowBQYDK2VwAyEAFSJV7wjdFuDz2CqYX7hGnITQvcmJYy7OJQq2Cy2Eiqs=
1717
-----END PUBLIC KEY-----`;
1818

19+
/** Raw base64 DER — the same key without PEM markers. */
20+
const TEST_PUBLIC_KEY_DER = 'MCowBQYDK2VwAyEAFSJV7wjdFuDz2CqYX7hGnITQvcmJYy7OJQq2Cy2Eiqs=';
21+
1922
// ─── Helpers ──────────────────────────────────────────────────────────────────
2023

2124
/**
@@ -383,6 +386,55 @@ describe('CLI proxy tests', () => {
383386
});
384387
});
385388

389+
// ─── AuthN: public key format ──────────────────────────────────────────────
390+
391+
describe('public key format', () => {
392+
const zmodel = `
393+
model User {
394+
id String @id @default(cuid())
395+
email String @unique
396+
}
397+
`;
398+
399+
it('should accept a raw base64 DER key (without PEM markers)', async () => {
400+
const client = await createTestClient(zmodel);
401+
// Pass the key as raw base64 DER — no PEM markers
402+
const app = createProxyApp(client, client.$schema, { publicAPIKey: TEST_PUBLIC_KEY_DER });
403+
const baseUrl = await startAt(app);
404+
405+
const pathWithQuery = '/api/model/user/findMany';
406+
const sig = buildSignatureHeader({ privateKey: TEST_PRIVATE_KEY, method: 'GET', pathWithQuery });
407+
const r = await fetch(`${baseUrl}${pathWithQuery}`, {
408+
headers: { 'x-zenstack-signature': sig },
409+
});
410+
expect(r.status).toBe(200);
411+
});
412+
413+
it('should accept a key supplied via ZENSTACK_PUBLIC_KEY env variable', async () => {
414+
const client = await createTestClient(zmodel);
415+
// createProxyApp receives the already-resolved key (as run() would pass it),
416+
// so we simulate env var resolution by passing the PEM directly.
417+
process.env['ZENSTACK_PUBLIC_KEY'] = TEST_PUBLIC_KEY;
418+
try {
419+
// No publicAPIKey option — would normally fall back to env var via run();
420+
// here we verify the middleware still works when the resolved key is provided.
421+
const app = createProxyApp(client, client.$schema, {
422+
publicAPIKey: process.env['ZENSTACK_PUBLIC_KEY'],
423+
});
424+
const baseUrl = await startAt(app);
425+
426+
const pathWithQuery = '/api/model/user/findMany';
427+
const sig = buildSignatureHeader({ privateKey: TEST_PRIVATE_KEY, method: 'GET', pathWithQuery });
428+
const r = await fetch(`${baseUrl}${pathWithQuery}`, {
429+
headers: { 'x-zenstack-signature': sig },
430+
});
431+
expect(r.status).toBe(200);
432+
} finally {
433+
delete process.env['ZENSTACK_PUBLIC_KEY'];
434+
}
435+
});
436+
});
437+
386438
// ─── AuthN: timestamp / replay-attack prevention ───────────────────────────
387439

388440
describe('signature timestamp tolerance', () => {

0 commit comments

Comments
 (0)