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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,7 @@ SENDGRID_API_KEY=SG.xxxxxxxxxxxxx
FROM_EMAIL=noreply@stellarsettle.com

#dev-secret-key
ADMIN_API_KEY=dev-secret-key
ADMIN_API_KEY=dev-secret-key

# IP Whitelist for admin routes (comma-separated CIDR blocks)
ADMIN_IP_WHITELIST=127.0.0.1,::1
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"express": "^5.2.1",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"ip-range-check": "^0.2.0",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
Expand Down
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createInvoiceRouter } from "./routes/invoice.routes";
import { createInvestmentRouter } from "./routes/investment.routes";
import { createSettlementRouter } from "./routes/settlement.routes";
import { createMarketplaceRouter } from "./routes/marketplace.routes";
import { createAdminRouter } from "./routes/admin/admin.routes";

import type { AuthService } from "./services/auth.service";
import type { NotificationService } from "./services/notification.service";
Expand Down Expand Up @@ -189,6 +190,10 @@ export function createApp({
app.use("/api/v1/marketplace", createMarketplaceRouter({ marketplaceService }));
}

if (config?.admin.ipWhitelist.length) {
app.use("/api/v1/admin", createAdminRouter({ dataSource, allowedCidrs: config.admin.ipWhitelist }));
}

app.use(notFoundMiddleware);
app.use(createErrorMiddleware(appLogger));

Expand Down
7 changes: 7 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export interface AppConfig {
kyc: {
skipVerification: boolean;
};
admin: {
ipWhitelist: string[];
};
}


Expand Down Expand Up @@ -287,5 +290,9 @@ export function getConfig(): AppConfig {
"SKIP_KYC_VERIFICATION"
),
},

admin: {
ipWhitelist: parseCsv(process.env.ADMIN_IP_WHITELIST),
},
};
}
19 changes: 19 additions & 0 deletions src/middleware/ip-whitelist.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { NextFunction, Request, Response } from "express";
import ipRangeCheck from "ip-range-check";

export function ipWhitelistMiddleware(allowedCidrs: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
const clientIp =
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ||
req.ip ||
req.socket.remoteAddress;

if (!clientIp || !ipRangeCheck(clientIp, allowedCidrs)) {
return res.status(403).json({
error: "Access denied: IP address not authorized.",
});
}

next();
};
}
31 changes: 31 additions & 0 deletions src/routes/admin/admin.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Router } from "express";
import { DataSource } from "typeorm";

import { ipWhitelistMiddleware } from "@/middleware/ip-whitelist.middleware";
import { approveKYC } from "./approve-kyc";
import { rejectKYC } from "./reject-kyc";

export interface AdminRouterDependencies {
dataSource: DataSource;
allowedCidrs: string[];
}

export function createAdminRouter({
dataSource,
allowedCidrs,
}: AdminRouterDependencies): Router {
const router = Router();
const ipWhitelist = ipWhitelistMiddleware(allowedCidrs);

router.use(ipWhitelist);

router.post("/approve-kyc", (req, res) => {
approveKYC(req, res, dataSource);
});

router.post("/reject-kyc", (req, res) => {
rejectKYC(req, res, dataSource);
});

return router;
}
166 changes: 166 additions & 0 deletions tests/unit/ip-whitelist.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { ipWhitelistMiddleware } from "@/middleware/ip-whitelist.middleware";

describe("ipWhitelistMiddleware", () => {
const allowedCidrs = ["127.0.0.0/8", "::1/128", "10.0.0.0/8"];

function makeReq(overrides: Record<string, unknown> = {}) {
return {
ip: undefined as string | undefined,
headers: {} as Record<string, string>,
socket: { remoteAddress: undefined as string | undefined },
...overrides,
};
}

function makeRes() {
return {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
}

function makeNext() {
return jest.fn();
}

it("calls next() when client IP is in the whitelist CIDR", () => {
const middleware = ipWhitelistMiddleware(allowedCidrs);
const req = makeReq({ ip: "127.0.0.1" });
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});

it("calls next() when client IP matches a CIDR in the whitelist", () => {
const middleware = ipWhitelistMiddleware(["192.168.1.0/24"]);
const req = makeReq({ ip: "192.168.1.42" });
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).toHaveBeenCalledTimes(1);
});

it("returns 403 when client IP is not in any whitelisted CIDR", () => {
const middleware = ipWhitelistMiddleware(allowedCidrs);
const req = makeReq({ ip: "203.0.113.50" });
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: "Access denied: IP address not authorized.",
});
});

it("returns 403 when client IP is undefined and no fallback is available", () => {
const middleware = ipWhitelistMiddleware(allowedCidrs);
const req = makeReq({ ip: undefined, socket: { remoteAddress: undefined } });
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});

it("extracts client IP from X-Forwarded-For header", () => {
const middleware = ipWhitelistMiddleware(allowedCidrs);
const req = makeReq({
headers: { "x-forwarded-for": "127.0.0.1, 70.41.3.18, 150.172.238.178" },
});
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).toHaveBeenCalledTimes(1);
});

it("returns 403 when the first IP in X-Forwarded-For is not whitelisted", () => {
const middleware = ipWhitelistMiddleware(allowedCidrs);
const req = makeReq({
headers: { "x-forwarded-for": "203.0.113.50, 127.0.0.1" },
});
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});

it("falls back to req.socket.remoteAddress when req.ip is undefined", () => {
const middleware = ipWhitelistMiddleware(allowedCidrs);
const req = makeReq({
ip: undefined,
socket: { remoteAddress: "::1" },
});
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).toHaveBeenCalledTimes(1);
});

it("returns 403 when req.socket.remoteAddress is not in whitelist", () => {
const middleware = ipWhitelistMiddleware(allowedCidrs);
const req = makeReq({
ip: undefined,
socket: { remoteAddress: "203.0.113.50" },
});
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});

it("returns 403 for an empty allowed CIDRs list", () => {
const middleware = ipWhitelistMiddleware([]);
const req = makeReq({ ip: "127.0.0.1" });
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});

it("works with IPv6 whitelisted addresses", () => {
const middleware = ipWhitelistMiddleware(["::1/128"]);
const req = makeReq({ ip: "::1" });
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).toHaveBeenCalledTimes(1);
});

it("returns 403 for IPv6 addresses not in the whitelist", () => {
const middleware = ipWhitelistMiddleware(["::1/128"]);
const req = makeReq({ ip: "2001:db8::1" });
const res = makeRes();
const next = makeNext();

middleware(req as never, res as never, next as never);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
Loading