Skip to content

Commit dd3b859

Browse files
authored
Merge pull request #701 from Jagadeeshftw/fix/681-idempotency-key-schema
fix: centralize idempotency key validation
2 parents a7d7ffd + e8e41ad commit dd3b859

6 files changed

Lines changed: 57 additions & 15 deletions

File tree

docs/utils/validation.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ exported:
1212
|---|---|---|
1313
| `StarknetAddress` | `z.ZodString` → transform | Parse + normalize a Starknet hex address |
1414
| `AgreementId` | `z.ZodString` | Parse a numeric-string agreement identifier |
15+
| `IdempotencyKeySchema` | `z.ZodString` | Validate bounded ASCII replay keys |
1516
| `parsePagination` | function | Clamp `limit`/`offset` query params to safe defaults |
1617
| `loggedParse` | function | Parse + log structured diagnostics on failure |
1718
| `formatValidationError` | function | Map a caught error to the standard API JSON shape |
@@ -118,6 +119,17 @@ AgreementId.parse("00042") // "00042"
118119
AgreementId.parse("12ab") // throws ZodError
119120
```
120121

122+
### `IdempotencyKeySchema`
123+
124+
Idempotency keys must be 1–255 characters and contain only ASCII letters,
125+
digits, hyphens, or underscores. The same schema is used by rate-limit,
126+
billing, and diagnostics middleware so invalid keys bypass replay caching.
127+
128+
```typescript
129+
IdempotencyKeySchema.parse("checkout_2026-07-30"); // accepted
130+
IdempotencyKeySchema.safeParse("key with spaces").success; // false
131+
```
132+
121133
## Pagination
122134

123135
### `parsePagination(query)``{ limit, offset }`

src/middleware/rate-limit.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import rateLimit, {
44
ipKeyGenerator,
55
} from "express-rate-limit";
66
import type { Request, Response } from "express";
7+
import { IdempotencyKeySchema } from "../utils/validation.js";
78

89
// ---------------------------------------------------------------------------
910
// Idempotency-Key support
@@ -29,11 +30,7 @@ export const X_IDEMPOTENT_REPLAYED_HEADER = "X-Idempotent-Replayed";
2930
export function getIdempotencyKey(req: Request): string | undefined {
3031
const value = req.headers[IDEMPOTENCY_KEY_HEADER.toLowerCase()];
3132
if (typeof value !== "string" || value.length === 0) return undefined;
32-
// Enforce a safe character set for idempotency keys: alphanumerics, hyphen, underscore.
33-
// Reject keys containing whitespace or control characters to avoid injection risks.
34-
const IDEMPOTENCY_KEY_REGEX = /^[A-Za-z0-9_-]{1,255}$/;
35-
if (!IDEMPOTENCY_KEY_REGEX.test(value)) return undefined;
36-
return value;
33+
return IdempotencyKeySchema.safeParse(value).success ? value : undefined;
3734
}
3835

3936
// ---------------------------------------------------------------------------

src/routes/billing.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { z } from "zod";
2424
import { eq } from "drizzle-orm";
2525
import { db, schema } from "../db/index.js";
2626
import { env } from "../config.js";
27+
import { IdempotencyKeySchema } from "../utils/validation.js";
2728

2829
export const billingRouter = express.Router();
2930

@@ -117,7 +118,12 @@ export function withBillingIdempotency(
117118
handler: (req: Request, res: Response, next: NextFunction) => Promise<void> | void,
118119
) {
119120
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
120-
const idempotencyKey = getHeader(req, "Idempotency-Key") ?? getHeader(req, "idempotency-key");
121+
const rawIdempotencyKey =
122+
getHeader(req, "Idempotency-Key") ?? getHeader(req, "idempotency-key");
123+
const idempotencyKey =
124+
rawIdempotencyKey && IdempotencyKeySchema.safeParse(rawIdempotencyKey).success
125+
? rawIdempotencyKey
126+
: undefined;
121127
const method = req.method.toUpperCase();
122128

123129
if (!idempotencyKey || ["GET", "HEAD", "OPTIONS"].includes(method)) {
@@ -230,9 +236,7 @@ export function computeBillingSummary(
230236
* @param parts - Ordered address parts: [street, city, state, zipCode, country].
231237
* @returns Comma-joined address string, or `null` if all parts are absent.
232238
*/
233-
export function buildFullAddress(
234-
parts: Array<string | null | undefined>,
235-
): string | null {
239+
export function buildFullAddress(parts: Array<string | null | undefined>): string | null {
236240
const present = parts.filter(Boolean) as string[];
237241
return present.length > 0 ? present.join(", ") : null;
238242
}

src/routes/diagnostics.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { requireAuth, requireAdmin } from "../auth/middleware.js";
44
import { db, getPoolStats, checkDbHealth } from "../db/index.js";
55
import { sql } from "drizzle-orm";
66
import { getCircuitBreakerSnapshots, provider } from "../starknet/client.js";
7+
import { IdempotencyKeySchema } from "../utils/validation.js";
78
import {
89
logDiagnosticsEvent,
910
incDiagnosticsMetric,
@@ -50,8 +51,12 @@ export function withDiagnosticsIdempotency(
5051
handler: (req: Request, res: Response, next: NextFunction) => Promise<void> | void,
5152
) {
5253
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
54+
const rawIdempotencyKey = req.headers["idempotency-key"] || req.headers["Idempotency-Key"];
5355
const idempotencyKey =
54-
req.headers["idempotency-key"] || req.headers["Idempotency-Key"];
56+
typeof rawIdempotencyKey === "string" &&
57+
IdempotencyKeySchema.safeParse(rawIdempotencyKey).success
58+
? rawIdempotencyKey
59+
: undefined;
5560

5661
if (!idempotencyKey || Array.isArray(idempotencyKey)) {
5762
await handler(req, res, next);

src/utils/validation.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from "zod";
33
import {
44
StarknetAddress,
55
AgreementId,
6+
IdempotencyKeySchema,
67
parsePagination,
78
MAX_PAGE_LIMIT,
89
DEFAULT_PAGE_LIMIT,
@@ -25,6 +26,20 @@ import {
2526
} from "./validation";
2627
import type { ValidationErrorMetric } from "./validation";
2728

29+
describe("IdempotencyKeySchema", () => {
30+
it("accepts bounded ASCII keys", () => {
31+
expect(IdempotencyKeySchema.parse("checkout_2026-07-30")).toBe("checkout_2026-07-30");
32+
expect(IdempotencyKeySchema.parse("a".repeat(255))).toHaveLength(255);
33+
});
34+
35+
it.each(["", " ", "key with spaces", "key/slash", "a".repeat(256)])(
36+
"rejects unsafe key %j",
37+
(value) => {
38+
expect(IdempotencyKeySchema.safeParse(value).success).toBe(false);
39+
},
40+
);
41+
});
42+
2843
// --------------------------------------------------------------------------
2944
// ValidationError
3045
// --------------------------------------------------------------------------
@@ -41,9 +56,7 @@ describe("ValidationError", () => {
4156
expect(err.name).toBe("ValidationError");
4257
expect(err.validator).toBe("test");
4358
expect(err.message).toBe("something went wrong");
44-
expect(err.issues).toEqual([
45-
{ path: ["name"], message: "too short", code: "too_small" },
46-
]);
59+
expect(err.issues).toEqual([{ path: ["name"], message: "too short", code: "too_small" }]);
4760
expect(err.input).toBe("ab");
4861
});
4962

@@ -802,8 +815,8 @@ describe("formatValidationError", () => {
802815
// ---- mapZodError ----
803816

804817
describe("mapZodError", () => {
805-
it("returns custom code for checksum fail",()=>{
806-
try{
818+
it("returns custom code for checksum fail", () => {
819+
try {
807820
StarknetAddress.parse("0x4718F5a0FC34Cc1AF16A1cdee98ffB20C31f5cd61d6ab07201858f4287c938d");
808821
} catch (e) {
809822
const mapped = mapZodError(e);

src/utils/validation.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,17 @@ export const AgreementId = z
435435
.trim()
436436
.regex(/^\d+$/, "agreement_id must be a numeric string");
437437

438+
/**
439+
* Shared schema for request idempotency keys.
440+
*
441+
* Keeping this constraint in one place prevents middleware from accepting
442+
* different key formats and avoids storing whitespace or control characters
443+
* in replay caches.
444+
*/
445+
export const IdempotencyKeySchema = z
446+
.string()
447+
.regex(/^[A-Za-z0-9_-]{1,255}$/, "Invalid idempotency key");
448+
438449
export const MAX_PAGE_LIMIT = 100;
439450

440451
export const DEFAULT_PAGE_LIMIT = 50;

0 commit comments

Comments
 (0)