diff --git a/__tests__/submit.test.ts b/__tests__/submit.test.ts index f5129ab..541638c 100644 --- a/__tests__/submit.test.ts +++ b/__tests__/submit.test.ts @@ -18,11 +18,13 @@ jest.unstable_mockModule("../src/middleware/job-contract-rate-limit.js", () => ( jobContractRateLimit: (_req: any, _res: any, next: any) => next(), partialReleaseRateLimit: (_req: any, _res: any, next: any) => next(), jobWhitelistRateLimit: (_req: any, _res: any, next: any) => next(), + whitelistUpdateRateLimit: (_req: any, _res: any, next: any) => next(), buildTxRateLimit: (_req: any, _res: any, next: any) => next(), timeRemainingRateLimit: (_req: any, _res: any, next: any) => next(), createJobDraftRateLimit: (_req: any, _res: any, next: any) => next(), claimAutoReleaseRateLimit: (_req: any, _res: any, next: any) => next(), resetSubmitRateLimitBuckets: () => {}, + resetWhitelistUpdateRateLimitBuckets: () => {}, })); jest.unstable_mockModule("@stellar/stellar-sdk/rpc", () => ({ diff --git a/src/middleware/job-contract-rate-limit.ts b/src/middleware/job-contract-rate-limit.ts index 8721e5f..c8b265d 100644 --- a/src/middleware/job-contract-rate-limit.ts +++ b/src/middleware/job-contract-rate-limit.ts @@ -113,6 +113,57 @@ export function resetJobWhitelistRateLimitBuckets(): void { whitelistBuckets.clear(); } +const whitelistUpdateBuckets = new Map(); + +export function resetWhitelistUpdateRateLimitBuckets(): void { + whitelistUpdateBuckets.clear(); +} + +function resolveWhitelistUpdateWindowMs(): number { + const configured = Number(process.env.JOB_WHITELIST_UPDATE_RATE_WINDOW_MS ?? "60000"); + return Number.isFinite(configured) && configured > 0 ? configured : 60000; +} + +function resolveWhitelistUpdateMaxRequests(): number { + const configured = Number(process.env.JOB_WHITELIST_UPDATE_RATE_MAX ?? "10"); + return Number.isFinite(configured) && configured > 0 ? configured : 10; +} + +/** Dedicated rate limiter for POST /api/jobs/:contractId/whitelist/update. */ +export function whitelistUpdateRateLimit( + req: Request, + res: Response, + next: NextFunction +): void { + const windowMs = resolveWhitelistUpdateWindowMs(); + const maxRequests = resolveWhitelistUpdateMaxRequests(); + const key = req.ip || req.socket.remoteAddress || "unknown"; + const now = Date.now(); + + let bucket = whitelistUpdateBuckets.get(key); + if (!bucket || now >= bucket.resetAt) { + bucket = { count: 0, resetAt: now + windowMs }; + whitelistUpdateBuckets.set(key, bucket); + } + + bucket.count += 1; + + const remaining = Math.max(0, maxRequests - bucket.count); + res.setHeader("X-RateLimit-Limit", String(maxRequests)); + res.setHeader("X-RateLimit-Remaining", String(remaining)); + res.setHeader("X-RateLimit-Reset", String(Math.ceil(bucket.resetAt / 1000))); + + if (bucket.count > maxRequests) { + res.status(429).json({ + success: false, + error: "Too many requests, please try again later", + }); + return; + } + + next(); +} + // --------------------------------------------------------------------------- // createJobDraft rate limiter // --------------------------------------------------------------------------- diff --git a/src/middleware/job-contract-security.ts b/src/middleware/job-contract-security.ts index 47d9c68..4d810bf 100644 --- a/src/middleware/job-contract-security.ts +++ b/src/middleware/job-contract-security.ts @@ -283,3 +283,46 @@ export function claimAutoReleaseCors( /** Security headers applied to claim-auto-release responses. */ export const claimAutoReleaseSecurityHeaders = jobContractSecurityHeaders; + +/** Strict CORS gate for POST /api/jobs/:contractId/whitelist/update. */ +export function updateWhitelistCors( + req: Request, + res: Response, + next: NextFunction +): void { + const origin = req.header("Origin"); + const allowedOrigins = getAllowedOrigins(); + + if (!origin) { + if (req.method === "OPTIONS") { + res.status(204).end(); + return; + } + next(); + return; + } + + if (allowedOrigins.includes(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, X-API-Key" + ); + if (req.method === "OPTIONS") { + res.status(204).end(); + return; + } + next(); + return; + } + + res.status(403).json({ + success: false, + error: "Origin not allowed by CORS policy", + }); +} + +/** Security headers applied to whitelist update responses. */ +export const updateWhitelistSecurityHeaders = jobContractSecurityHeaders; diff --git a/src/routes/jobs.ts b/src/routes/jobs.ts index 33403f9..f834280 100644 --- a/src/routes/jobs.ts +++ b/src/routes/jobs.ts @@ -14,6 +14,7 @@ import { getJobsByWallet, getEventsByContract } from "../indexer/db.js"; import { jobContractRateLimit, jobWhitelistRateLimit, + whitelistUpdateRateLimit, partialReleaseRateLimit, buildTxRateLimit, timeRemainingRateLimit, @@ -34,6 +35,8 @@ import { byWalletSecurityHeaders, claimAutoReleaseCors, claimAutoReleaseSecurityHeaders, + updateWhitelistCors, + updateWhitelistSecurityHeaders, } from "../middleware/job-contract-security.js"; import { sendError, sendSuccess } from "../utils/api-response.js"; import { validate, validateWithFields } from "../middleware/validate.js"; @@ -51,9 +54,11 @@ import { byWalletQuerySchema, createJobDraftBodySchema, createJobDraftLegacyBodySchema, + updateWhitelistBodySchema, type ByWalletQuery, type CreateJobDraftBody, type CreateJobDraftLegacyBody, + type UpdateWhitelistBody, } from "../schemas/jobs.js"; import { strictLimiter, walletLookupLimiter } from "../middleware/rateLimiter.js"; import logger from "../utils/logger.js"; diff --git a/src/schemas/jobs.ts b/src/schemas/jobs.ts index 8821ba4..92a81e8 100644 --- a/src/schemas/jobs.ts +++ b/src/schemas/jobs.ts @@ -295,6 +295,55 @@ export const createJobDraftLegacyBodySchema = z.object({ export type CreateJobDraftLegacyBody = z.infer; +/** + * Validates a Stellar address that can be either a public key account address (G...) + * or a Soroban contract address (C...). + */ +export const stellarAddressOrContractSchema = z + .string({ required_error: "Address is required" }) + .refine((v) => isValidStellarAddress(v) || isValidStellarContractId(v), { + message: "Invalid Stellar address", + }); + +/** + * POST /:contractId/whitelist/update body schema. + * Accepts `addresses` (or `tokens` fallback) array containing valid Stellar addresses. + */ +export const updateWhitelistBodySchema = z + .object({ + addresses: z + .array(stellarAddressOrContractSchema, { + required_error: "addresses array is required", + invalid_type_error: "addresses must be an array", + }) + .optional(), + tokens: z + .array(stellarAddressOrContractSchema, { + invalid_type_error: "tokens must be an array", + }) + .optional(), + }) + .superRefine((data, ctx) => { + const addresses = data.addresses ?? data.tokens; + if (!addresses) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "addresses array is required", + path: ["addresses"], + }); + return; + } + if (addresses.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "addresses array cannot be empty", + path: ["addresses"], + }); + } + }); + +export type UpdateWhitelistBody = z.infer; + export type ContractIdParams = z.infer; export type ContractMilestoneParams = z.infer; export type BuildTxBody = z.infer;