Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: block ip from cloudflare #18

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]"
CF_AUTH_KEY="nutz"

# notify
NOTIFY_URL="https://notify.jaw.dev"
Expand Down
15 changes: 15 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,49 @@
NOTIFY_URL: {
value: process.env.NOTIFY_URL,
required: true,
type: (value: any) => String(value),

Check warning on line 11 in src/config.ts

View workflow job for this annotation

GitHub Actions / ESLint (22.x)

Unexpected any. Specify a different type
},
NOTIFY_X_API_KEY: {
value: process.env.NOTIFY_X_API_KEY,
required: true,
type: (value: any) => String(value),

Check warning on line 16 in src/config.ts

View workflow job for this annotation

GitHub Actions / ESLint (22.x)

Unexpected any. Specify a different type
},
IPS: {
value: process.env.IPS,
required: true,
type: (value: any) => String(value),

Check warning on line 21 in src/config.ts

View workflow job for this annotation

GitHub Actions / ESLint (22.x)

Unexpected any. Specify a different type
},
PORT: {
value: process.env.PORT,
default: 80,
type: (value: any) => Number(value),

Check warning on line 26 in src/config.ts

View workflow job for this annotation

GitHub Actions / ESLint (22.x)

Unexpected any. Specify a different type
required: false,
},
OPENAI_API_KEY: {
value: process.env.OPENAI_API_KEY,
required: true,
type: (value: any) => String(value),

Check warning on line 32 in src/config.ts

View workflow job for this annotation

GitHub Actions / ESLint (22.x)

Unexpected any. Specify a different type
},
CLAUDE_API_KEY: {
value: process.env.CLAUDE_API_KEY,
required: true,
type: (value: any) => String(value),

Check warning on line 37 in src/config.ts

View workflow job for this annotation

GitHub Actions / ESLint (22.x)

Unexpected any. Specify a different type
},
CF_ZONE_ID: {
value: process.env.CF_ZONE_ID,
required: true,
type: (value: any) => String(value),

Check warning on line 42 in src/config.ts

View workflow job for this annotation

GitHub Actions / ESLint (22.x)

Unexpected any. Specify a different type
},
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',
Expand Down
64 changes: 51 additions & 13 deletions src/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]',
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<NextFunction>();
const blockIPMock = mock.fn<(ip: string, config: typeof appConfig) => Promise<void>>();

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<NextFunction>();
const blockIPMock = mock.fn<(ip: string, config: typeof appConfig) => Promise<void>>();

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<NextFunction>();
const blockIPMock = mock.fn<(ip: string, config: typeof appConfig) => Promise<void>>();

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<NextFunction>();
const blockIPMock = mock.fn<(ip: string, config: typeof appConfig) => Promise<void>>();

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 }, () => {
Expand Down
8 changes: 5 additions & 3 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>,
) {
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();
Expand All @@ -44,6 +45,7 @@ export function limitIPsMiddleware(
const ip = getIpAddress(req);

if (!ips.includes(ip)) {
await blockIPInCloudflare(ip, appConfig);
throw new ForbiddenError();
}

Expand Down
8 changes: 6 additions & 2 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand Down
26 changes: 26 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,29 @@ export const html = (content: string, title: string = 'commit.jaw.dev'): string
</body>
</html>`;
};

export async function blockIPInCloudflare(ip: string, config: typeof appConfig): Promise<void> {
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);
}
}
Loading