diff --git a/end2end/server/src/handlers/lists.js b/end2end/server/src/handlers/lists.js index 9334a1f2e..617ac2ff0 100644 --- a/end2end/server/src/handlers/lists.js +++ b/end2end/server/src/handlers/lists.js @@ -1,6 +1,7 @@ const { getBlockedIPAddresses, getBlockedUserAgents, + getAllowedIPAddresses, } = require("../zen/config"); module.exports = function lists(req, res) { @@ -10,6 +11,7 @@ module.exports = function lists(req, res) { const blockedIps = getBlockedIPAddresses(req.app); const blockedUserAgents = getBlockedUserAgents(req.app); + const allowedIps = getAllowedIPAddresses(req.app); res.json({ success: true, @@ -25,5 +27,15 @@ module.exports = function lists(req, res) { ] : [], blockedUserAgents: blockedUserAgents, + allowedIPAddresses: + allowedIps.length > 0 + ? [ + { + source: "geoip", + description: "geo restrictions", + ips: allowedIps, + }, + ] + : [], }); }; diff --git a/end2end/server/src/handlers/updateLists.js b/end2end/server/src/handlers/updateLists.js index e0c7d9080..4b0a02164 100644 --- a/end2end/server/src/handlers/updateLists.js +++ b/end2end/server/src/handlers/updateLists.js @@ -1,6 +1,7 @@ const { updateBlockedIPAddresses, updateBlockedUserAgents, + updateAllowedIPAddresses, } = require("../zen/config"); module.exports = function updateIPLists(req, res) { @@ -38,5 +39,12 @@ module.exports = function updateIPLists(req, res) { updateBlockedUserAgents(req.app, req.body.blockedUserAgents); } + if ( + req.body.allowedIPAddresses && + Array.isArray(req.body.allowedIPAddresses) + ) { + updateAllowedIPAddresses(req.app, req.body.allowedIPAddresses); + } + res.json({ success: true }); }; diff --git a/end2end/server/src/zen/config.js b/end2end/server/src/zen/config.js index 437386a7b..87797b8e3 100644 --- a/end2end/server/src/zen/config.js +++ b/end2end/server/src/zen/config.js @@ -39,6 +39,7 @@ function updateAppConfig(app, newConfig) { const blockedIPAddresses = []; const blockedUserAgents = []; +const allowedIPAddresses = []; function updateBlockedIPAddresses(app, ips) { let entry = blockedIPAddresses.find((ip) => ip.serviceId === app.serviceId); @@ -64,6 +65,30 @@ function getBlockedIPAddresses(app) { return { serviceId: app.serviceId, ipAddresses: [] }; } +function updateAllowedIPAddresses(app, ips) { + let entry = allowedIPAddresses.find((ip) => ip.serviceId === app.serviceId); + + if (entry) { + entry.ipAddresses = ips; + } else { + entry = { serviceId: app.serviceId, ipAddresses: ips }; + allowedIPAddresses.push(entry); + } + + // Bump lastUpdatedAt + updateAppConfig(app, {}); +} + +function getAllowedIPAddresses(app) { + const entry = allowedIPAddresses.find((ip) => ip.serviceId === app.serviceId); + + if (entry) { + return entry.ipAddresses; + } + + return { serviceId: app.serviceId, ipAddresses: [] }; +} + function updateBlockedUserAgents(app, uas) { let entry = blockedUserAgents.find((e) => e.serviceId === e.serviceId); @@ -95,4 +120,6 @@ module.exports = { getBlockedIPAddresses, updateBlockedUserAgents, getBlockedUserAgents, + getAllowedIPAddresses, + updateAllowedIPAddresses, }; diff --git a/end2end/tests/hono-xml-allowlists.test.ts b/end2end/tests/hono-xml-allowlists.test.ts new file mode 100644 index 000000000..56a175055 --- /dev/null +++ b/end2end/tests/hono-xml-allowlists.test.ts @@ -0,0 +1,144 @@ +const t = require("tap"); +const { spawn } = require("child_process"); +const { resolve } = require("path"); +const timeout = require("../timeout"); + +const pathToApp = resolve(__dirname, "../../sample-apps/hono-xml", "app.js"); +const testServerUrl = "http://localhost:5874"; + +let token; +t.beforeEach(async () => { + const response = await fetch(`${testServerUrl}/api/runtime/apps`, { + method: "POST", + }); + const body = await response.json(); + token = body.token; + + const config = await fetch(`${testServerUrl}/api/runtime/config`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token, + }, + body: JSON.stringify({ + allowedIPAddresses: ["5.6.7.8"], + endpoints: [ + { + route: "/admin", + method: "GET", + forceProtectionOff: false, + allowedIPAddresses: [], + rateLimiting: { + enabled: false, + }, + }, + ], + }), + }); + t.same(config.status, 200); + + const lists = await fetch(`${testServerUrl}/api/runtime/firewall/lists`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token, + }, + body: JSON.stringify({ + allowedIPAddresses: ["4.3.2.1/32", "fe80::1234:5678:abcd:ef12/64"], + blockedIPAddresses: [], + blockedUserAgents: "hacker|attacker|GPTBot", + }), + }); + t.same(lists.status, 200); +}); + +t.test("it blocks non-allowed IP addresses", (t) => { + const server = spawn(`node`, [pathToApp, "4002"], { + env: { + ...process.env, + AIKIDO_DEBUG: "true", + AIKIDO_BLOCK: "true", + AIKIDO_TOKEN: token, + AIKIDO_URL: testServerUrl, + }, + }); + + server.on("close", () => { + t.end(); + }); + + server.on("error", (err) => { + t.fail(err); + }); + + let stdout = ""; + server.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + let stderr = ""; + server.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + // Wait for the server to start + timeout(2000) + .then(async () => { + const resp1 = await fetch("http://127.0.0.1:4002/add", { + method: "POST", + body: "Njuska", + headers: { + "Content-Type": "application/xml", + "X-Forwarded-For": "1.3.2.4", + }, + signal: AbortSignal.timeout(5000), + }); + t.same(resp1.status, 403); + t.same( + await resp1.text(), + "Your IP address is not allowed to access this resource. (Your IP: 1.3.2.4)" + ); + + const resp2 = await fetch("http://127.0.0.1:4002/add", { + method: "POST", + body: "Harry", + headers: { + "Content-Type": "application/xml", + "X-Forwarded-For": "fe80::1234:5678:abcd:ef12", + }, + signal: AbortSignal.timeout(5000), + }); + t.same(resp2.status, 200); + t.same(await resp2.text(), JSON.stringify({ success: true })); + + const resp3 = await fetch("http://127.0.0.1:4002/add", { + method: "POST", + body: "Harry", + headers: { + "Content-Type": "application/xml", + "X-Forwarded-For": "4.3.2.1", + }, + signal: AbortSignal.timeout(5000), + }); + t.same(resp3.status, 200); + t.same(await resp3.text(), JSON.stringify({ success: true })); + + const resp4 = await fetch("http://127.0.0.1:4002/add", { + method: "POST", + body: "Harry2", + headers: { + "Content-Type": "application/xml", + "X-Forwarded-For": "5.6.7.8", + }, + signal: AbortSignal.timeout(5000), + }); + t.same(resp4.status, 200); + t.same(await resp4.text(), JSON.stringify({ success: true })); + }) + .catch((error) => { + t.fail(error); + }) + .finally(() => { + server.kill(); + }); +}); diff --git a/library/agent/Agent.test.ts b/library/agent/Agent.test.ts index 358b9fc6b..6f1898db6 100644 --- a/library/agent/Agent.test.ts +++ b/library/agent/Agent.test.ts @@ -19,6 +19,8 @@ import { Context } from "./Context"; import { createTestAgent } from "../helpers/createTestAgent"; import { setTimeout } from "node:timers/promises"; +let shouldOnlyAllowSomeIPAddresses = false; + wrap(fetch, "fetch", function mock() { return async function mock() { return { @@ -32,6 +34,15 @@ wrap(fetch, "fetch", function mock() { }, ], blockedUserAgents: "AI2Bot|Bytespider", + allowedIPAddresses: shouldOnlyAllowSomeIPAddresses + ? [ + { + source: "name", + description: "Description", + ips: ["4.3.2.1"], + }, + ] + : [], }), }; }; @@ -1099,6 +1110,10 @@ t.test("it does not fetch blocked IPs if serverless", async () => { blocked: false, }); + t.same(agent.getConfig().isAllowedIPAddress("1.3.2.4"), { + allowed: true, + }); + t.same( agent .getConfig() @@ -1110,3 +1125,31 @@ t.test("it does not fetch blocked IPs if serverless", async () => { } ); }); + +t.test("it only allows some IP addresses", async () => { + shouldOnlyAllowSomeIPAddresses = true; + const agent = createTestAgent({ + token: new Token("123"), + suppressConsoleLog: false, + }); + + agent.start([]); + + await setTimeout(0); + + t.same(agent.getConfig().isIPAddressBlocked("1.3.2.4"), { + blocked: true, + reason: "Description", + }); + t.same(agent.getConfig().isIPAddressBlocked("fe80::1234:5678:abcd:ef12"), { + blocked: true, + reason: "Description", + }); + + t.same(agent.getConfig().isAllowedIPAddress("1.2.3.4"), { + allowed: false, + }); + t.same(agent.getConfig().isAllowedIPAddress("4.3.2.1"), { + allowed: true, + }); +}); diff --git a/library/agent/Agent.ts b/library/agent/Agent.ts index 5698419d0..c3a596331 100644 --- a/library/agent/Agent.ts +++ b/library/agent/Agent.ts @@ -41,7 +41,15 @@ export class Agent { private timeoutInMS = 10000; private hostnames = new Hostnames(200); private users = new Users(1000); - private serviceConfig = new ServiceConfig([], Date.now(), [], [], true, []); + private serviceConfig = new ServiceConfig( + [], + Date.now(), + [], + [], + true, + [], + [] + ); private routes: Routes = new Routes(200); private rateLimiter: RateLimiter = new RateLimiter(5000, 120 * 60 * 1000); private statistics = new InspectionStatistics({ @@ -363,11 +371,11 @@ export class Agent { } try { - const { blockedIPAddresses, blockedUserAgents } = await fetchBlockedLists( - this.token - ); + const { blockedIPAddresses, blockedUserAgents, allowedIPAddresses } = + await fetchBlockedLists(this.token); this.serviceConfig.updateBlockedIPAddresses(blockedIPAddresses); this.serviceConfig.updateBlockedUserAgents(blockedUserAgents); + this.serviceConfig.updateAllowedIPAddresses(allowedIPAddresses); } catch (error: any) { console.error(`Aikido: Failed to update blocked lists: ${error.message}`); } diff --git a/library/agent/ServiceConfig.test.ts b/library/agent/ServiceConfig.test.ts index b4144cb26..cf8e192f8 100644 --- a/library/agent/ServiceConfig.test.ts +++ b/library/agent/ServiceConfig.test.ts @@ -2,7 +2,7 @@ import * as t from "tap"; import { ServiceConfig } from "./ServiceConfig"; t.test("it returns false if empty rules", async () => { - const config = new ServiceConfig([], 0, [], [], false, []); + const config = new ServiceConfig([], 0, [], [], false, [], []); t.same(config.getLastUpdatedAt(), 0); t.same(config.isUserBlocked("id"), false); t.same(config.isBypassedIP("1.2.3.4"), false); @@ -54,6 +54,7 @@ t.test("it works", async () => { ["123"], [], false, + [], [] ); @@ -81,25 +82,33 @@ t.test("it works", async () => { }); t.test("it checks if IP is bypassed", async () => { - const config = new ServiceConfig([], 0, [], ["1.2.3.4"], false, []); + const config = new ServiceConfig([], 0, [], ["1.2.3.4"], false, [], []); t.same(config.isBypassedIP("1.2.3.4"), true); t.same(config.isBypassedIP("1.2.3.5"), false); }); t.test("ip blocking works", async () => { - const config = new ServiceConfig([], 0, [], [], false, [ - { - source: "geoip", - description: "description", - ips: [ - "1.2.3.4", - "192.168.2.1/24", - "fd00:1234:5678:9abc::1", - "fd00:3234:5678:9abc::1/64", - "5.6.7.8/32", - ], - }, - ]); + const config = new ServiceConfig( + [], + 0, + [], + [], + false, + [ + { + source: "geoip", + description: "description", + ips: [ + "1.2.3.4", + "192.168.2.1/24", + "fd00:1234:5678:9abc::1", + "fd00:3234:5678:9abc::1/64", + "5.6.7.8/32", + ], + }, + ], + [] + ); t.same(config.isIPAddressBlocked("1.2.3.4"), { blocked: true, reason: "description", @@ -132,7 +141,7 @@ t.test("ip blocking works", async () => { }); t.test("it blocks bots", async () => { - const config = new ServiceConfig([], 0, [], [], true, []); + const config = new ServiceConfig([], 0, [], [], true, [], []); config.updateBlockedUserAgents("googlebot|bingbot"); t.same(config.isUserAgentBlocked("googlebot"), { blocked: true }); @@ -143,3 +152,51 @@ t.test("it blocks bots", async () => { t.same(config.isUserAgentBlocked("googlebot"), { blocked: false }); }); + +t.test("restricting access to some ips", async () => { + const config = new ServiceConfig( + [], + 0, + [], + [], + true, + [], + [ + { + source: "geoip", + description: "description", + ips: ["1.2.3.4"], + }, + ] + ); + + t.same(config.isAllowedIPAddress("1.2.3.4").allowed, true); + t.same(config.isAllowedIPAddress("4.3.2.1").allowed, false); + t.same(config.isAllowedIPAddress("127.0.0.1").allowed, true); // Always allow private ips + + config.updateAllowedIPAddresses([]); + t.same(config.isAllowedIPAddress("1.2.3.4").allowed, true); + t.same(config.isAllowedIPAddress("127.0.0.1").allowed, true); + t.same(config.isAllowedIPAddress("4.3.2.1").allowed, true); +}); + +t.test("only allow some ips: empty list", async () => { + const config = new ServiceConfig( + [], + 0, + [], + [], + true, + [], + [ + { + source: "geoip", + description: "description", + ips: [], + }, + ] + ); + + t.same(config.isAllowedIPAddress("1.2.3.4").allowed, true); + t.same(config.isAllowedIPAddress("4.3.2.1").allowed, true); +}); diff --git a/library/agent/ServiceConfig.ts b/library/agent/ServiceConfig.ts index d10cf96d3..b770fa0bd 100644 --- a/library/agent/ServiceConfig.ts +++ b/library/agent/ServiceConfig.ts @@ -1,16 +1,24 @@ import { IPMatcher } from "../helpers/ip-matcher/IPMatcher"; import { LimitedContext, matchEndpoints } from "../helpers/matchEndpoints"; +import { isPrivateIP } from "../vulnerabilities/ssrf/isPrivateIP"; import { Endpoint } from "./Config"; -import { Blocklist as BlocklistType } from "./api/fetchBlockedLists"; +import { IPList } from "./api/fetchBlockedLists"; export class ServiceConfig { private blockedUserIds: Map = new Map(); + // IP addresses that are allowed to bypass rate limiting, attack blocking, etc. private bypassedIPAddresses: Set = new Set(); private nonGraphQLEndpoints: Endpoint[] = []; private graphqlFields: Endpoint[] = []; private blockedIPAddresses: { blocklist: IPMatcher; description: string }[] = []; private blockedUserAgentRegex: RegExp | undefined; + // If not empty, only ips in this list are allowed to access the service + // e.g. for country allowlists + private allowedIPAddresses: { + allowlist: IPMatcher; + description: string; + }[] = []; constructor( endpoints: Endpoint[], @@ -18,12 +26,14 @@ export class ServiceConfig { blockedUserIds: string[], bypassedIPAddresses: string[], private receivedAnyStats: boolean, - blockedIPAddresses: BlocklistType[] + blockedIPAddresses: IPList[], + allowedIPAddresses: IPList[] ) { this.setBlockedUserIds(blockedUserIds); this.setBypassedIPAddresses(bypassedIPAddresses); this.setEndpoints(endpoints); this.setBlockedIPAddresses(blockedIPAddresses); + this.setAllowedIPAddresses(allowedIPAddresses); } private setEndpoints(endpoints: Endpoint[]) { @@ -96,7 +106,7 @@ export class ServiceConfig { return { blocked: false }; } - private setBlockedIPAddresses(blockedIPAddresses: BlocklistType[]) { + private setBlockedIPAddresses(blockedIPAddresses: IPList[]) { this.blockedIPAddresses = []; for (const source of blockedIPAddresses) { @@ -107,7 +117,7 @@ export class ServiceConfig { } } - updateBlockedIPAddresses(blockedIPAddresses: BlocklistType[]) { + updateBlockedIPAddresses(blockedIPAddresses: IPList[]) { this.setBlockedIPAddresses(blockedIPAddresses); } @@ -126,6 +136,42 @@ export class ServiceConfig { return { blocked: false }; } + private setAllowedIPAddresses(ipAddresses: IPList[]) { + this.allowedIPAddresses = []; + + for (const source of ipAddresses) { + // Skip empty allowlists + if (source.ips.length === 0) { + continue; + } + this.allowedIPAddresses.push({ + allowlist: new IPMatcher(source.ips), + description: source.description, + }); + } + } + + updateAllowedIPAddresses(ipAddresses: IPList[]) { + this.setAllowedIPAddresses(ipAddresses); + } + + isAllowedIPAddress(ip: string): { allowed: boolean } { + if (this.allowedIPAddresses.length < 1) { + return { allowed: true }; + } + + // Always allow access from local IP addresses + if (isPrivateIP(ip)) { + return { allowed: true }; + } + + const allowlist = this.allowedIPAddresses.find((list) => + list.allowlist.has(ip) + ); + + return { allowed: !!allowlist }; + } + updateConfig( endpoints: Endpoint[], lastUpdatedAt: number, diff --git a/library/agent/api-discovery/getApiInfo.ts b/library/agent/api-discovery/getApiInfo.ts index bc1d76d1f..202a13e8f 100644 --- a/library/agent/api-discovery/getApiInfo.ts +++ b/library/agent/api-discovery/getApiInfo.ts @@ -9,7 +9,7 @@ export type APISpec = { auth?: APIAuthType[]; }; -export type APIBodyInfo = { +type APIBodyInfo = { type: BodyDataType; schema: DataSchema; }; diff --git a/library/agent/api/fetchBlockedLists.ts b/library/agent/api/fetchBlockedLists.ts index 3e2db2719..91473b5d6 100644 --- a/library/agent/api/fetchBlockedLists.ts +++ b/library/agent/api/fetchBlockedLists.ts @@ -2,14 +2,15 @@ import { fetch } from "../../helpers/fetch"; import { getAPIURL } from "../getAPIURL"; import { Token } from "./Token"; -export type Blocklist = { +export type IPList = { source: string; description: string; ips: string[]; }; export async function fetchBlockedLists(token: Token): Promise<{ - blockedIPAddresses: Blocklist[]; + blockedIPAddresses: IPList[]; + allowedIPAddresses: IPList[]; blockedUserAgents: string; }> { const baseUrl = getAPIURL(); @@ -34,7 +35,8 @@ export async function fetchBlockedLists(token: Token): Promise<{ } const result: { - blockedIPAddresses: Blocklist[]; + blockedIPAddresses: IPList[]; + allowedIPAddresses: IPList[]; blockedUserAgents: string; } = JSON.parse(body); @@ -43,6 +45,10 @@ export async function fetchBlockedLists(token: Token): Promise<{ result && Array.isArray(result.blockedIPAddresses) ? result.blockedIPAddresses : [], + allowedIPAddresses: + result && Array.isArray(result.allowedIPAddresses) + ? result.allowedIPAddresses + : [], // Blocked user agents are stored as a string pattern for usage in a regex (e.g. "Googlebot|Bingbot") blockedUserAgents: result && typeof result.blockedUserAgents === "string" diff --git a/library/ratelimiting/getRateLimitedEndpoint.test.ts b/library/ratelimiting/getRateLimitedEndpoint.test.ts index c5fcec0ce..fd85762a4 100644 --- a/library/ratelimiting/getRateLimitedEndpoint.test.ts +++ b/library/ratelimiting/getRateLimitedEndpoint.test.ts @@ -18,7 +18,10 @@ const context: Context = { t.test("it returns undefined if no endpoints", async () => { t.same( - getRateLimitedEndpoint(context, new ServiceConfig([], 0, [], [], true, [])), + getRateLimitedEndpoint( + context, + new ServiceConfig([], 0, [], [], true, [], []) + ), undefined ); }); @@ -45,6 +48,7 @@ t.test("it returns undefined if no matching endpoints", async () => { [], [], false, + [], [] ) ), @@ -74,6 +78,7 @@ t.test("it returns undefined if matching but not enabled", async () => { [], [], false, + [], [] ) ), @@ -103,6 +108,7 @@ t.test("it returns endpoint if matching and enabled", async () => { [], [], false, + [], [] ) ), @@ -153,6 +159,7 @@ t.test("it returns endpoint with lowest max requests", async () => { [], [], false, + [], [] ) ), @@ -203,6 +210,7 @@ t.test("it returns endpoint with smallest window size", async () => { [], [], false, + [], [] ) ), @@ -253,6 +261,7 @@ t.test("it always returns exact matches first", async () => { [], [], false, + [], [] ) ), diff --git a/library/sources/HTTPServer.test.ts b/library/sources/HTTPServer.test.ts index f41edad7a..922b902b8 100644 --- a/library/sources/HTTPServer.test.ts +++ b/library/sources/HTTPServer.test.ts @@ -8,7 +8,7 @@ import { wrap } from "../helpers/wrap"; import { HTTPServer } from "./HTTPServer"; import { join } from "path"; import { createTestAgent } from "../helpers/createTestAgent"; -import type { Blocklist } from "../agent/api/fetchBlockedLists"; +import type { IPList } from "../agent/api/fetchBlockedLists"; import * as fetchBlockedLists from "../agent/api/fetchBlockedLists"; import { mkdtemp, writeFile, unlink } from "fs/promises"; import { exec } from "child_process"; @@ -52,7 +52,7 @@ agent.start([new HTTPServer()]); wrap(fetchBlockedLists, "fetchBlockedLists", function fetchBlockedLists() { return async function fetchBlockedLists(): Promise<{ - blockedIPAddresses: Blocklist[]; + blockedIPAddresses: IPList[]; blockedUserAgents: string; }> { return { diff --git a/library/sources/Hono.onlyAllowIPAddresses.test.ts b/library/sources/Hono.onlyAllowIPAddresses.test.ts new file mode 100644 index 000000000..e6e5fd0d5 --- /dev/null +++ b/library/sources/Hono.onlyAllowIPAddresses.test.ts @@ -0,0 +1,128 @@ +/* eslint-disable prefer-rest-params */ +import * as t from "tap"; +import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting"; +import { Token } from "../agent/api/Token"; +import { wrap } from "../helpers/wrap"; +import { Hono as HonoInternal } from "./Hono"; +import { HTTPServer } from "./HTTPServer"; +import { getMajorNodeVersion } from "../helpers/getNodeVersion"; +import { createTestAgent } from "../helpers/createTestAgent"; +import * as fetch from "../helpers/fetch"; + +wrap(fetch, "fetch", function mock(original) { + return async function mock(this: typeof fetch) { + if ( + arguments.length > 0 && + arguments[0] && + arguments[0].url.toString().includes("firewall") + ) { + return { + statusCode: 200, + body: JSON.stringify({ + blockedIPAddresses: [ + { + source: "geoip", + description: "geo restrictions", + ips: ["1.3.2.0/24", "fe80::1234:5678:abcd:ef12/64"], + }, + ], + blockedUserAgents: "hacker|attacker", + allowedIPAddresses: [ + { + source: "geoip", + description: "geo restrictions", + ips: ["4.3.2.1"], + }, + ], + }), + }; + } + + return await original.apply(this, arguments); + }; +}); + +const agent = createTestAgent({ + token: new Token("123"), + api: new ReportingAPIForTesting({ + success: true, + endpoints: [ + { + method: "GET", + route: "/rate-limited", + forceProtectionOff: false, + rateLimiting: { + windowSizeInMS: 2000, + maxRequests: 2, + enabled: true, + }, + }, + ], + blockedUserIds: ["567"], + configUpdatedAt: 0, + heartbeatIntervalInMS: 10 * 60 * 1000, + allowedIPAddresses: ["4.3.2.1"], + }), +}); +agent.start([new HonoInternal(), new HTTPServer()]); +const opts = { + skip: + getMajorNodeVersion() < 18 ? "Hono does not support Node.js < 18" : false, +}; + +t.test("test access only allowed for some IP addresses", opts, async (t) => { + const { Hono } = require("hono") as typeof import("hono"); + const { serve } = + require("@hono/node-server") as typeof import("@hono/node-server"); + + const app = new Hono(); + + app.get("/", (c) => { + return c.text("Hello, world!"); + }); + + const server = serve({ + fetch: app.fetch, + port: 8768, + }); + + const response = await fetch.fetch({ + url: new URL("http://127.0.0.1:8768/"), + headers: { + "X-Forwarded-For": "1.3.2.4", + }, + }); + t.equal(response.statusCode, 403); + t.equal( + response.body, + "Your IP address is not allowed to access this resource. (Your IP: 1.3.2.4)" + ); + + const response2 = await fetch.fetch({ + url: new URL("http://127.0.0.1:8768/"), + headers: { + "X-Forwarded-For": "4.3.2.1", + }, + }); + t.equal(response2.statusCode, 200); + + // Always allow localhost + const response3 = await fetch.fetch({ + url: new URL("http://127.0.0.1:8768/"), + headers: { + "X-Forwarded-For": "127.0.0.1", + }, + }); + t.equal(response3.statusCode, 200); + + // Allow private IP ranges + const response4 = await fetch.fetch({ + url: new URL("http://127.0.0.1:8768/"), + headers: { + "X-Forwarded-For": "10.0.2.4", + }, + }); + t.equal(response4.statusCode, 200); + + server.close(); +}); diff --git a/library/sources/Hono.test.ts b/library/sources/Hono.test.ts index b6a8a9120..155e929a6 100644 --- a/library/sources/Hono.test.ts +++ b/library/sources/Hono.test.ts @@ -31,6 +31,7 @@ wrap(fetch, "fetch", function mock(original) { }, ], blockedUserAgents: "hacker|attacker", + allowedIPAddresses: [], }), }; } diff --git a/library/sources/http-server/checkIfRequestIsBlocked.ts b/library/sources/http-server/checkIfRequestIsBlocked.ts index 0f3be8732..527808c4c 100644 --- a/library/sources/http-server/checkIfRequestIsBlocked.ts +++ b/library/sources/http-server/checkIfRequestIsBlocked.ts @@ -48,6 +48,23 @@ export function checkIfRequestIsBlocked( return false; } + if ( + context.remoteAddress && + !agent.getConfig().isAllowedIPAddress(context.remoteAddress).allowed + ) { + res.statusCode = 403; + res.setHeader("Content-Type", "text/plain"); + + let message = "Your IP address is not allowed to access this resource."; + if (context.remoteAddress) { + message += ` (Your IP: ${escapeHTML(context.remoteAddress)})`; + } + + res.end(message); + + return true; + } + const result = context.remoteAddress ? agent.getConfig().isIPAddressBlocked(context.remoteAddress) : ({ blocked: false } as const);