Skip to content

Commit 10cedb5

Browse files
authored
Merge pull request #851 from Jayy4rl/fix/api-gateway-security-audit-logger-765-766-767-768
feat(backend): optimize and harden API Gateway Security + Audit Logge…
2 parents 8a88b79 + 5b88234 commit 10cedb5

5 files changed

Lines changed: 353 additions & 183 deletions

File tree

backend/src/lib/api-gateway-signature.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import crypto from "node:crypto";
22

33
const DEFAULT_SIGNATURE_WINDOW_SECONDS = 300;
4+
// Minimum HMAC secret length to prevent signing with trivially weak keys (#767)
5+
const MIN_SECRET_LENGTH = 16;
46

57
function normalizeSignatureHeader(signatureHeader) {
68
if (typeof signatureHeader !== "string") return null;
@@ -48,7 +50,7 @@ export function signApiGatewayRequest({
4850
timestamp,
4951
body,
5052
}) {
51-
if (!secret || !timestamp) {
53+
if (!secret || secret.length < MIN_SECRET_LENGTH || !timestamp) {
5254
return null;
5355
}
5456

@@ -68,8 +70,8 @@ export function verifyApiGatewayRequestSignature({
6870
process.env.API_GATEWAY_SIGNATURE_TOLERANCE_SECONDS || DEFAULT_SIGNATURE_WINDOW_SECONDS,
6971
),
7072
}) {
71-
if (!secret) {
72-
return { valid: false, reason: "Missing signature secret" };
73+
if (!secret || secret.length < MIN_SECRET_LENGTH) {
74+
return { valid: false, reason: "Missing or insufficient signature secret" };
7375
}
7476

7577
const timestamp = Number.parseInt(String(timestampHeader || ""), 10);

backend/src/lib/api-gateway-signature.test.js

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,23 @@ import {
44
verifyApiGatewayRequestSignature,
55
} from "./api-gateway-signature.js";
66

7+
// All secrets must be >= 16 characters (MIN_SECRET_LENGTH enforcement, issue #767)
8+
const VALID_SECRET = "test-api-key-secure-32chars-padded";
9+
710
describe("api-gateway-signature", () => {
811
it("signs and verifies request payloads", () => {
912
const timestamp = 1713916800;
10-
const secret = "test-api-key";
1113

1214
const signature = signApiGatewayRequest({
13-
secret,
15+
secret: VALID_SECRET,
1416
method: "POST",
1517
path: "/api/payments",
1618
timestamp,
1719
body: { amount: 12.5, asset: "USDC" },
1820
});
1921

2022
const result = verifyApiGatewayRequestSignature({
21-
secret,
23+
secret: VALID_SECRET,
2224
method: "POST",
2325
path: "/api/payments",
2426
timestampHeader: String(timestamp),
@@ -32,18 +34,17 @@ describe("api-gateway-signature", () => {
3234

3335
it("rejects signatures outside timestamp tolerance", () => {
3436
const timestamp = 1713916800;
35-
const secret = "test-api-key";
3637

3738
const signature = signApiGatewayRequest({
38-
secret,
39+
secret: VALID_SECRET,
3940
method: "GET",
4041
path: "/api/metrics/summary",
4142
timestamp,
4243
body: {},
4344
});
4445

4546
const result = verifyApiGatewayRequestSignature({
46-
secret,
47+
secret: VALID_SECRET,
4748
method: "GET",
4849
path: "/api/metrics/summary",
4950
timestampHeader: String(timestamp),
@@ -59,7 +60,7 @@ describe("api-gateway-signature", () => {
5960

6061
it("rejects malformed signature headers", () => {
6162
const result = verifyApiGatewayRequestSignature({
62-
secret: "abc",
63+
secret: VALID_SECRET,
6364
method: "GET",
6465
path: "/health",
6566
timestampHeader: "1713916800",
@@ -71,4 +72,73 @@ describe("api-gateway-signature", () => {
7172
expect(result.valid).toBe(false);
7273
expect(result.reason).toMatch(/invalid x-api-signature/i);
7374
});
75+
76+
// ── Security audit: minimum secret length (#767) ──────────────────────────
77+
78+
it("rejects signing with a secret shorter than the minimum length", () => {
79+
const result = signApiGatewayRequest({
80+
secret: "short",
81+
method: "GET",
82+
path: "/health",
83+
timestamp: 1713916800,
84+
body: {},
85+
});
86+
87+
expect(result).toBeNull();
88+
});
89+
90+
it("rejects verification with a secret shorter than the minimum length", () => {
91+
const result = verifyApiGatewayRequestSignature({
92+
secret: "tooshort",
93+
method: "GET",
94+
path: "/health",
95+
timestampHeader: "1713916800",
96+
signatureHeader: "sha256=" + "a".repeat(64),
97+
body: {},
98+
now: 1713916800 * 1000,
99+
});
100+
101+
expect(result.valid).toBe(false);
102+
expect(result.reason).toMatch(/insufficient.*secret/i);
103+
});
104+
105+
it("rejects verification with a missing secret", () => {
106+
const result = verifyApiGatewayRequestSignature({
107+
secret: "",
108+
method: "GET",
109+
path: "/health",
110+
timestampHeader: "1713916800",
111+
signatureHeader: "sha256=" + "a".repeat(64),
112+
body: {},
113+
now: 1713916800 * 1000,
114+
});
115+
116+
expect(result.valid).toBe(false);
117+
expect(result.reason).toMatch(/insufficient.*secret/i);
118+
});
119+
120+
it("detects a tampered body by producing a different signature", () => {
121+
const timestamp = 1713916800;
122+
123+
const signature = signApiGatewayRequest({
124+
secret: VALID_SECRET,
125+
method: "POST",
126+
path: "/api/payments",
127+
timestamp,
128+
body: { amount: 10 },
129+
});
130+
131+
const result = verifyApiGatewayRequestSignature({
132+
secret: VALID_SECRET,
133+
method: "POST",
134+
path: "/api/payments",
135+
timestampHeader: String(timestamp),
136+
signatureHeader: `sha256=${signature}`,
137+
body: { amount: 99 }, // tampered
138+
now: timestamp * 1000,
139+
});
140+
141+
expect(result.valid).toBe(false);
142+
expect(result.reason).toMatch(/verification failed/i);
143+
});
74144
});

backend/src/lib/auth.js

Lines changed: 88 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,54 @@
11
import bcrypt from "bcryptjs";
22
import { recordMerchantApiUsage } from "./api-usage.js";
33
import { verifyApiGatewayRequestSignature } from "./api-gateway-signature.js";
4+
import { queryWithRetry } from "./db.js";
45

56
const SALT_ROUNDS = 12;
67

8+
const MERCHANT_SELECT_COLUMNS =
9+
"id, email, business_name, notification_email, branding_config, merchant_settings, webhook_secret, webhook_secret_old, webhook_secret_expiry, webhook_version, payment_limits, api_key, api_key_expires_at, api_key_old, api_key_old_expires_at";
10+
11+
// Auth failure rate limiting per client IP (issue #767)
12+
const AUTH_FAIL_RATE_LIMIT_MAX = Number(process.env.AUTH_FAIL_RATE_LIMIT_MAX || 10);
13+
const AUTH_FAIL_RATE_LIMIT_WINDOW_MS = Number(process.env.AUTH_FAIL_RATE_LIMIT_WINDOW_MS || 60_000);
14+
const _authFailState = new Map();
15+
16+
export function _resetAuthFailStateForTests() {
17+
_authFailState.clear();
18+
}
19+
20+
function isAuthRateLimited(ip, now = Date.now()) {
21+
const state = _authFailState.get(ip);
22+
if (!state || now >= state.windowStart + AUTH_FAIL_RATE_LIMIT_WINDOW_MS) return false;
23+
return state.count >= AUTH_FAIL_RATE_LIMIT_MAX;
24+
}
25+
26+
function recordAuthFailure(ip, now = Date.now()) {
27+
const state = _authFailState.get(ip);
28+
if (!state || now >= state.windowStart + AUTH_FAIL_RATE_LIMIT_WINDOW_MS) {
29+
_authFailState.set(ip, { count: 1, windowStart: now });
30+
} else {
31+
state.count += 1;
32+
}
33+
}
34+
35+
// Single-query lookup covering both current and rotated API keys.
36+
// deleted_at IS NULL applies to both paths — previously missing on the old-key
37+
// path which allowed deleted merchants to authenticate via a rotated key (#767).
38+
// queryWithRetry handles transient DB failures automatically (#766).
39+
async function defaultMerchantLookup(apiKey) {
40+
const result = await queryWithRetry(
41+
`SELECT ${MERCHANT_SELECT_COLUMNS}
42+
FROM merchants
43+
WHERE deleted_at IS NULL
44+
AND (api_key = $1 OR api_key_old = $1)
45+
LIMIT 1`,
46+
[apiKey],
47+
{ label: "auth-merchant-lookup" },
48+
);
49+
return result.rows[0] || null;
50+
}
51+
752
/**
853
* Hash a plain-text merchant password with bcrypt.
954
* @param {string} plaintext
@@ -24,28 +69,25 @@ export async function verifyPassword(plaintext, hash) {
2469
}
2570

2671
export function createApiKeyAuth({
27-
supabaseClient = null,
72+
supabaseClient = null, // unused for API key auth; retained for session-auth compat
2873
usageRecorder = recordMerchantApiUsage,
2974
verifyGatewaySignature = verifyApiGatewayRequestSignature,
3075
requireSignature = false,
76+
merchantLookup = defaultMerchantLookup,
3177
} = {}) {
3278
return async function requireApiKeyAuth(req, res, next) {
3379
try {
34-
// Another auth layer (for example x402 token bridge) may have already
35-
// attached a merchant context. If so, honor it and continue.
80+
// Another auth layer (e.g. x402 token bridge) may have already attached a
81+
// merchant context. If so, honor it and continue.
3682
if (req.merchant?.id) {
3783
try {
38-
await usageRecorder({
39-
merchantId: req.merchant.id,
40-
req,
41-
});
84+
await usageRecorder({ merchantId: req.merchant.id, req });
4285
} catch (usageError) {
4386
console.warn("Failed to record merchant API usage:", usageError.message);
4487
}
4588
return next();
4689
}
4790

48-
const client = supabaseClient || (await import("./supabase.js")).supabase;
4991
const headerValue = req.get("x-api-key");
5092
const apiKey = typeof headerValue === "string" ? headerValue.trim() : "";
5193
const signatureHeader = req.get("x-api-signature");
@@ -55,11 +97,10 @@ export function createApiKeyAuth({
5597
return res.status(401).json({ error: "Missing x-api-key header" });
5698
}
5799

58-
// Backward-compatible API gateway hardening: verify request HMAC
59-
// signature only when signature headers are supplied by the client.
60100
const hasSignatureHeader =
61101
typeof signatureHeader === "string" && signatureHeader.trim().startsWith("sha256=");
62-
const hasTimestampHeader = typeof timestampHeader === "string" && timestampHeader.trim().length > 0;
102+
const hasTimestampHeader =
103+
typeof timestampHeader === "string" && timestampHeader.trim().length > 0;
63104
const signatureProvided = hasSignatureHeader && hasTimestampHeader;
64105

65106
if (requireSignature && !signatureProvided) {
@@ -88,74 +129,49 @@ export function createApiKeyAuth({
88129
}
89130
}
90131

91-
// First try to find merchant by current API key
92-
let { data: merchant, error } = await client
93-
.from("merchants")
94-
.select(
95-
"id, email, business_name, notification_email, branding_config, merchant_settings, webhook_secret, webhook_secret_old, webhook_secret_expiry, webhook_version, payment_limits, api_key, api_key_expires_at, api_key_old, api_key_old_expires_at"
96-
)
97-
.eq("api_key", apiKey)
98-
.is("deleted_at", null)
99-
.maybeSingle();
100-
101-
if (error) {
102-
error.status = 500;
103-
throw error;
104-
}
132+
// Block IPs that have exceeded the failed-attempt threshold (#767)
133+
const clientIp = req.ip || "unknown";
134+
if (isAuthRateLimited(clientIp)) {
135+
return res.status(429).json({
136+
error: "Too many failed authentication attempts",
137+
code: "AUTH_RATE_LIMITED",
138+
});
139+
}
105140

106-
// If not found by current key, check if it's the old key during rotation overlap
107-
if (!merchant) {
108-
const { data: oldKeyMerchant, error: oldKeyError } = await client
109-
.from("merchants")
110-
.select(
111-
"id, email, business_name, notification_email, branding_config, merchant_settings, webhook_secret, webhook_secret_old, webhook_secret_expiry, webhook_version, payment_limits, api_key, api_key_expires_at, api_key_old, api_key_old_expires_at"
112-
)
113-
.eq("api_key_old", apiKey)
114-
.maybeSingle();
115-
116-
if (oldKeyError) {
117-
oldKeyError.status = 500;
118-
throw oldKeyError;
119-
}
141+
// Single combined query for current and rotated keys (#765).
142+
// Retry logic is provided by queryWithRetry (#766).
143+
let merchant;
144+
try {
145+
merchant = await merchantLookup(apiKey);
146+
} catch (err) {
147+
err.status = 500;
148+
throw err;
149+
}
120150

121-
if (!oldKeyMerchant) {
122-
return res.status(401).json({ error: "Invalid API key" });
123-
}
151+
if (!merchant) {
152+
recordAuthFailure(clientIp);
153+
return res.status(401).json({ error: "Invalid API key" });
154+
}
124155

125-
// Check if old key has expired (overlap period ended)
126-
const now = new Date();
127-
if (
128-
oldKeyMerchant.api_key_old_expires_at &&
129-
new Date(oldKeyMerchant.api_key_old_expires_at) < now
130-
) {
131-
return res.status(401).json({
132-
error: "API key has expired. Please rotate to a new key.",
133-
code: "API_KEY_EXPIRED"
134-
});
135-
}
156+
// Determine which key matched to validate the correct expiry field
157+
const now = new Date();
158+
const usedCurrentKey = merchant.api_key === apiKey;
159+
const expiresAt = usedCurrentKey
160+
? merchant.api_key_expires_at
161+
: merchant.api_key_old_expires_at;
136162

137-
merchant = oldKeyMerchant;
138-
} else {
139-
// Check if current API key has expired
140-
const now = new Date();
141-
if (
142-
merchant.api_key_expires_at &&
143-
new Date(merchant.api_key_expires_at) < now
144-
) {
145-
return res.status(401).json({
146-
error: "API key has expired. Please rotate to a new key.",
147-
code: "API_KEY_EXPIRED"
148-
});
149-
}
150-
}
163+
if (expiresAt && new Date(expiresAt) < now) {
164+
recordAuthFailure(clientIp);
165+
return res.status(401).json({
166+
error: "API key has expired. Please rotate to a new key.",
167+
code: "API_KEY_EXPIRED",
168+
});
169+
}
151170

152171
req.merchant = merchant;
153172

154173
try {
155-
await usageRecorder({
156-
merchantId: merchant.id,
157-
req,
158-
});
174+
await usageRecorder({ merchantId: merchant.id, req });
159175
} catch (usageError) {
160176
// Usage metrics should never block API traffic.
161177
console.warn("Failed to record merchant API usage:", usageError.message);
@@ -190,7 +206,7 @@ export function requireSessionAuth() {
190206

191207
const client = (await import("./supabase.js")).supabase;
192208
const merchantId = payload.id || payload.merchant_id;
193-
209+
194210
if (!merchantId) {
195211
return res.status(401).json({ error: "Invalid token payload: missing merchant identification" });
196212
}

0 commit comments

Comments
 (0)