Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions __tests__/submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
51 changes: 51 additions & 0 deletions src/middleware/job-contract-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,57 @@ export function resetJobWhitelistRateLimitBuckets(): void {
whitelistBuckets.clear();
}

const whitelistUpdateBuckets = new Map<string, RateBucket>();

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
// ---------------------------------------------------------------------------
Expand Down
43 changes: 43 additions & 0 deletions src/middleware/job-contract-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 5 additions & 0 deletions src/routes/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getJobsByWallet, getEventsByContract } from "../indexer/db.js";
import {
jobContractRateLimit,
jobWhitelistRateLimit,
whitelistUpdateRateLimit,
partialReleaseRateLimit,
buildTxRateLimit,
timeRemainingRateLimit,
Expand All @@ -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";
Expand All @@ -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";
Expand Down
49 changes: 49 additions & 0 deletions src/schemas/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,55 @@ export const createJobDraftLegacyBodySchema = z.object({

export type CreateJobDraftLegacyBody = z.infer<typeof createJobDraftLegacyBodySchema>;

/**
* 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<typeof updateWhitelistBodySchema>;

export type ContractIdParams = z.infer<typeof contractIdParamsSchema>;
export type ContractMilestoneParams = z.infer<typeof contractMilestoneParamsSchema>;
export type BuildTxBody = z.infer<typeof buildTxBodySchema>;
Expand Down
Loading