From aa87a80d1ccd976269e1ede667638686102615a0 Mon Sep 17 00:00:00 2001 From: wajeht <58354193+wajeht@users.noreply.github.com> Date: Thu, 21 Nov 2024 22:33:11 -0600 Subject: [PATCH 1/2] feat(config): Add Cloudflare configuration variables and functionality for IP blocking --- .env.example | 3 +++ src/config.ts | 15 +++++++++++++++ src/middleware.ts | 8 +++++--- src/router.ts | 8 ++++++-- src/util.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index dd79847..5819520 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ IPS="420.69.247.365" NODE_ENV="development" OPENAI_API_KEY="DEEZ" CLAUDE_API_KEY="NUTZ" +CF_ZONE_ID="deez" +CF_AUTH_EMAIL="deeznutz@gmail.com" +CF_AUTH_KEY="nutz" # notify NOTIFY_URL="https://notify.jaw.dev" diff --git a/src/config.ts b/src/config.ts index 1fdc289..4412f2c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,6 +36,21 @@ export const appConfig = validateConfig({ required: true, type: (value: any) => String(value), }, + CF_ZONE_ID: { + value: process.env.CF_ZONE_ID, + required: true, + type: (value: any) => String(value), + }, + CF_AUTH_EMAIL: { + value: process.env.CF_AUTH_EMAIL, + required: true, + type: (value: any) => String(value), + }, + CF_AUTH_KEY: { + value: process.env.CF_AUTH_KEY, + required: true, + type: (value: any) => String(value), + }, NODE_ENV: { value: process.env.NODE_ENV, default: 'development', diff --git a/src/middleware.ts b/src/middleware.ts index 924451f..ea61ead 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -31,11 +31,12 @@ export function helmet() { } export function limitIPsMiddleware( - appConfig: { IPS: string }, + config: typeof appConfig, getIpAddress: (req: Request) => string, + blockIPInCloudflare: (ip: string, config: typeof appConfig) => Promise, ) { - const ips = appConfig.IPS.split(', '); - return (req: Request, res: Response, next: NextFunction) => { + const ips = config.IPS.split(', '); + return async (req: Request, res: Response, next: NextFunction) => { try { if (req.body?.apiKey && req.body?.apiKey?.length) { return next(); @@ -44,6 +45,7 @@ export function limitIPsMiddleware( const ip = getIpAddress(req); if (!ips.includes(ip)) { + await blockIPInCloudflare(ip, appConfig); throw new ForbiddenError(); } diff --git a/src/router.ts b/src/router.ts index 540f2c6..7a66656 100644 --- a/src/router.ts +++ b/src/router.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import express from 'express'; import fs from 'node:fs/promises'; import { appConfig } from './config'; -import { cache, extractDomain, getIpAddress, html } from './util'; +import { blockIPInCloudflare, cache, extractDomain, getIpAddress, html } from './util'; import { limitIPsMiddleware } from './middleware'; const commitDotSh = 'commit.sh'; @@ -24,7 +24,11 @@ router.get('/healthz', getHealthzHandler(html)); router.get('/', getIndexHandler(fs, cache, commitDotSh, commitDotShPath, html, extractDomain)); -router.post('/', limitIPsMiddleware(appConfig, getIpAddress), postGenerateCommitMessageHandler(ai)); +router.post( + '/', + limitIPsMiddleware(appConfig, getIpAddress, blockIPInCloudflare), + postGenerateCommitMessageHandler(ai), +); router.get( '/install.sh', diff --git a/src/util.ts b/src/util.ts index 6fe1e1a..4dbb50b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -211,3 +211,29 @@ export const html = (content: string, title: string = 'commit.jaw.dev'): string `; }; + +export async function blockIPInCloudflare(ip: string, config: typeof appConfig): Promise { + try { + await fetch( + `https://api.cloudflare.com/client/v4/zones/${config.CF_ZONE_ID}/firewall/access_rules/rules`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Auth-Email': config.CF_AUTH_EMAIL, + 'X-Auth-Key': config.CF_AUTH_KEY, + }, + body: JSON.stringify({ + mode: 'block', + configuration: { + target: 'ip', + value: ip, + }, + notes: `Automatically blocked: ${ip}`, + }), + }, + ); + } catch (error) { + logger.error(`Error blocking IP ${ip} in Cloudflare:`, error); + } +} From bba01ef80d6f6ed35179ee930834618bbd44b871 Mon Sep 17 00:00:00 2001 From: wajeht <58354193+wajeht@users.noreply.github.com> Date: Thu, 21 Nov 2024 22:40:13 -0600 Subject: [PATCH 2/2] test: scaffold temp tests --- src/middleware.test.ts | 64 +++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/src/middleware.test.ts b/src/middleware.test.ts index d330fd2..949b0c7 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -10,66 +10,104 @@ import { errorMiddleware, limitIPsMiddleware, notFoundMiddleware } from './middl import assert from 'assert'; import { describe, it, mock } from 'node:test'; import { Request, Response, NextFunction } from 'express'; +import { appConfig } from './config'; describe('limitIPsMiddleware', { concurrency: true }, () => { - it('should call next() if IP is allowed', () => { + const mockConfig = { + NOTIFY_URL: 'http://example.com', + NOTIFY_X_API_KEY: 'test-key', + IPS: '127.0.0.1, 192.168.1.1', + PORT: 80, + OPENAI_API_KEY: 'test-openai-key', + CLAUDE_API_KEY: 'test-claude-key', + CF_ZONE_ID: 'test-zone-id', + CF_AUTH_EMAIL: 'test@example.com', + CF_AUTH_KEY: 'test-cf-key', + NODE_ENV: 'development', + } as typeof appConfig; + + it('should call next() if IP is allowed', async () => { const req = {} as Request; const res = {} as Response; const nextMock = mock.fn(); + const blockIPMock = mock.fn<(ip: string, config: typeof appConfig) => Promise>(); - const appConfig = { IPS: '127.0.0.1, 192.168.1.1' }; const getIpAddressMock = mock.fn<(req: Request) => string>(() => '127.0.0.1'); - const middleware = limitIPsMiddleware(appConfig, getIpAddressMock); - middleware(req, res, nextMock); + const middleware = limitIPsMiddleware(mockConfig, getIpAddressMock, blockIPMock); + await middleware(req, res, nextMock); assert.strictEqual(getIpAddressMock.mock.calls.length, 1); assert.strictEqual(getIpAddressMock.mock.calls[0].arguments[0], req); - + assert.strictEqual(blockIPMock.mock.calls.length, 0); assert.strictEqual(nextMock.mock.calls.length, 1); assert.strictEqual(nextMock.mock.calls[0].arguments.length, 0); }); - it('should call next() with ForbiddenError if IP is not allowed', () => { + it('should call blockIP and throw ForbiddenError if IP is not allowed', async () => { const req = {} as Request; const res = {} as Response; const nextMock = mock.fn(); + const blockIPMock = mock.fn<(ip: string, config: typeof appConfig) => Promise>(); - const appConfig = { IPS: '127.0.0.1, 192.168.1.1' }; const getIpAddressMock = mock.fn<(req: Request) => string>(() => '10.0.0.1'); - const middleware = limitIPsMiddleware(appConfig, getIpAddressMock); - middleware(req, res, nextMock); + const middleware = limitIPsMiddleware(mockConfig, getIpAddressMock, blockIPMock); + await middleware(req, res, nextMock); assert.strictEqual(getIpAddressMock.mock.calls.length, 1); assert.strictEqual(getIpAddressMock.mock.calls[0].arguments[0], req); + assert.strictEqual(blockIPMock.mock.calls.length, 1); + assert.strictEqual(blockIPMock.mock.calls[0].arguments[0], '10.0.0.1'); + assert.deepStrictEqual(blockIPMock.mock.calls[0].arguments[1], mockConfig); + assert.strictEqual(nextMock.mock.calls.length, 1); const error = nextMock.mock.calls[0].arguments[0] as unknown; assert(error instanceof ForbiddenError); }); - it('should call next() with error if an exception occurs', () => { + it('should call next() with error if an exception occurs', async () => { const req = {} as Request; const res = {} as Response; const nextMock = mock.fn(); + const blockIPMock = mock.fn<(ip: string, config: typeof appConfig) => Promise>(); - const appConfig = { IPS: '127.0.0.1, 192.168.1.1' }; const getIpAddressMock = mock.fn<(req: Request) => string>(() => { throw new Error('Test error'); }); - const middleware = limitIPsMiddleware(appConfig, getIpAddressMock); - middleware(req, res, nextMock); + const middleware = limitIPsMiddleware(mockConfig, getIpAddressMock, blockIPMock); + await middleware(req, res, nextMock); assert.strictEqual(getIpAddressMock.mock.calls.length, 1); assert.strictEqual(getIpAddressMock.mock.calls[0].arguments[0], req); + assert.strictEqual(blockIPMock.mock.calls.length, 0); assert.strictEqual(nextMock.mock.calls.length, 1); const error = nextMock.mock.calls[0].arguments[0] as unknown; assert(error instanceof Error); assert.strictEqual(error.message, 'Test error'); }); + + it('should call next() if apiKey is provided', async () => { + const req = { + body: { apiKey: 'test-key' }, + } as Request; + const res = {} as Response; + const nextMock = mock.fn(); + const blockIPMock = mock.fn<(ip: string, config: typeof appConfig) => Promise>(); + + const getIpAddressMock = mock.fn<(req: Request) => string>(() => '10.0.0.1'); + + const middleware = limitIPsMiddleware(mockConfig, getIpAddressMock, blockIPMock); + await middleware(req, res, nextMock); + + assert.strictEqual(getIpAddressMock.mock.calls.length, 0); + assert.strictEqual(blockIPMock.mock.calls.length, 0); + assert.strictEqual(nextMock.mock.calls.length, 1); + assert.strictEqual(nextMock.mock.calls[0].arguments.length, 0); + }); }); describe('notFoundMiddleware', { concurrency: true }, () => {