Skip to content
Closed
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
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ SITE_URL=http://localhost:5173
GEO_PROVIDER_URL=http://ip-api.com/json
GEO_TIMEOUT_MS=3000

# Blocked countries — comma-separated ISO 3166-1 alpha-2 country codes (e.g., "US,NG,GB")
# Leave empty or set to "*" to allow all countries. Can be changed via env var without redeploying.
GEO_BLOCK_COUNTRIES=

# ── Rate Limiting ────────────────────────────────────────────────────────────
# Limits are per IP address. TTL values are in seconds.
# All values have sensible defaults — only set these to override.
Expand Down
20 changes: 18 additions & 2 deletions backend/src/config/env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ export const env = {
};
},
get geo() {
const blockedCountriesStr = process.env.GEO_BLOCK_COUNTRIES ?? '';
const blockedCountries = this.parseBlockedCountries(blockedCountriesStr);
return {
providerUrl: process.env.GEO_PROVIDER_URL ?? 'http://ip-api.com/json',
timeoutMs: parseInt(process.env.GEO_TIMEOUT_MS ?? '3000', 10),
blockedCountries: process.env.BLOCKED_COUNTRIES ?? '',
blockedCountries: blockedCountries,
};
},
get server() {
Expand All @@ -123,6 +125,20 @@ export const env = {
};
},

// Private helper for parsing blocked countries
private parseBlockedCountries(str: string): string[] {
if (!str || str.trim() === '') {
return [];
}
if (str.trim() === '*') {
return []; // Wildcard means allow all
}
return str
.split(',')
.map(code => code.trim().toUpperCase())
.filter(code => code.length === 2 && /^[A-Z]{2}$/.test(code));
},

// -------------------------------------------------------------------------
// Backward-compatible aliases — prefer the unified modules above.
// These delegate to the canonical getters so that existing consumers
Expand Down Expand Up @@ -155,6 +171,6 @@ export const env = {
},
/** @deprecated Use `env.geo.blockedCountries` instead. */
get blockedCountries() {
return this.geo.blockedCountries;
return this.geo.blockedCountries.join(',');
},
} as const;
2 changes: 1 addition & 1 deletion backend/src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const envSchemaInner = z
// Geolocation
GEO_PROVIDER_URL: z.string().url().default('http://ip-api.com/json'),
GEO_TIMEOUT_MS: z.coerce.number().int().positive().default(3000),
BLOCKED_COUNTRIES: z.string().default(''),
GEO_BLOCK_COUNTRIES: z.string().default(''),

// Sentry — optional; when absent the SDK is not initialized
SENTRY_DSN: z.string().url().optional(),
Expand Down
53 changes: 31 additions & 22 deletions backend/src/middleware/geo-blocking.middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,41 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GeoBlockingMiddleware } from './geo-blocking.middleware';
import { GeoService } from '../services/geo.service';
import { ConfigService } from '@nestjs/config';
import { ForbiddenException } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';

describe('GeoBlockingMiddleware', () => {
let middleware: GeoBlockingMiddleware;
let geoService: jest.Mocked<GeoService>;
let configService: jest.Mocked<ConfigService>;
let mockRequest: any;
let mockResponse: any;
let mockNext: jest.Mock;

beforeEach(async () => {
// Mock environment variables
process.env.BLOCKED_COUNTRIES = 'US,NG,GB';

geoService = {
checkAccess: jest.fn(),
} as any;

configService = {
get: jest.fn((key: string, defaultValue?: any) => {
if (key === 'GEO_BLOCK_COUNTRIES') return 'US,NG,GB';
return defaultValue;
}),
} as any;

const module: TestingModule = await Test.createTestingModule({
providers: [
GeoBlockingMiddleware,
{
provide: GeoService,
useValue: geoService,
},
{
provide: ConfigService,
useValue: configService,
},
],
}).compile();

Expand All @@ -45,46 +55,45 @@ describe('GeoBlockingMiddleware', () => {
mockNext = jest.fn();
});

afterEach(() => {
delete process.env.BLOCKED_COUNTRIES;
});

describe('constructor', () => {
it('should parse blocked countries from environment variable', () => {
process.env.BLOCKED_COUNTRIES = 'US,NG,GB';
const newMiddleware = new GeoBlockingMiddleware(geoService);
it('should parse blocked countries from ConfigService', () => {
configService.get.mockImplementation((key: string) => {
if (key === 'GEO_BLOCK_COUNTRIES') return 'US,NG,GB';
return undefined;
});
const newMiddleware = new GeoBlockingMiddleware(geoService, configService);
expect(newMiddleware['blockedCountries']).toEqual(['US', 'NG', 'GB']);
});

it('should handle empty blocked countries', () => {
process.env.BLOCKED_COUNTRIES = '';
const newMiddleware = new GeoBlockingMiddleware(geoService);
configService.get.mockImplementation(() => '');
const newMiddleware = new GeoBlockingMiddleware(geoService, configService);
expect(newMiddleware['blockedCountries']).toEqual([]);
});

it('should handle wildcard allow-all', () => {
process.env.BLOCKED_COUNTRIES = '*';
const newMiddleware = new GeoBlockingMiddleware(geoService);
configService.get.mockImplementation(() => '*');
const newMiddleware = new GeoBlockingMiddleware(geoService, configService);
expect(newMiddleware['blockedCountries']).toEqual([]);
});

it('should filter invalid country codes', () => {
process.env.BLOCKED_COUNTRIES = 'US,NG,INVALID,GB,123';
const newMiddleware = new GeoBlockingMiddleware(geoService);
configService.get.mockImplementation(() => 'US,NG,INVALID,GB,123');
const newMiddleware = new GeoBlockingMiddleware(geoService, configService);
expect(newMiddleware['blockedCountries']).toEqual(['US', 'NG', 'GB']);
});

it('should handle lowercase country codes', () => {
process.env.BLOCKED_COUNTRIES = 'us,ng,gb';
const newMiddleware = new GeoBlockingMiddleware(geoService);
configService.get.mockImplementation(() => 'us,ng,gb');
const newMiddleware = new GeoBlockingMiddleware(geoService, configService);
expect(newMiddleware['blockedCountries']).toEqual(['US', 'NG', 'GB']);
});
});

describe('use', () => {
it('should allow request when no countries are blocked', async () => {
process.env.BLOCKED_COUNTRIES = '';
const newMiddleware = new GeoBlockingMiddleware(geoService);
configService.get.mockImplementation(() => '');
const newMiddleware = new GeoBlockingMiddleware(geoService, configService);

await newMiddleware.use(mockRequest as FastifyRequest, mockResponse as FastifyReply, mockNext);

Expand All @@ -93,8 +102,8 @@ describe('GeoBlockingMiddleware', () => {
});

it('should allow request when wildcard is set', async () => {
process.env.BLOCKED_COUNTRIES = '*';
const newMiddleware = new GeoBlockingMiddleware(geoService);
configService.get.mockImplementation(() => '*');
const newMiddleware = new GeoBlockingMiddleware(geoService, configService);

await newMiddleware.use(mockRequest as FastifyRequest, mockResponse as FastifyReply, mockNext);

Expand Down
19 changes: 12 additions & 7 deletions backend/src/middleware/geo-blocking.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Injectable, Logger, NestMiddleware, ForbiddenException } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { ConfigService } from '@nestjs/config';
import { GeoService } from '../services/geo.service';
import { env } from '../config/env.config';

/**
* GeoBlockingMiddleware — blocks requests from restricted geographic regions
* based on the BLOCKED_COUNTRIES environment variable.
* based on the GEO_BLOCK_COUNTRIES environment variable.
*
* This middleware uses the GeoService.checkAccess method to determine if a request
* should be allowed or blocked based on the client's IP address and country.
Expand All @@ -17,13 +17,13 @@ import { env } from '../config/env.config';
* the country from the client IP using GeoService
*
* Environment configuration:
* - BLOCKED_COUNTRIES: Comma-separated list of ISO 3166-1 alpha-2 country codes
* to block (e.g., "US,NG,GB"). Empty string or "*" allows all.
* - GEO_BLOCK_COUNTRIES: Comma-separated list of ISO 3166-1 alpha-2 country codes
* to block (e.g., "US,NG,GB"). Empty string or "*" allows all.
* - GEO_PROVIDER_URL: URL for IP geolocation service (defaults to ip-api.com)
* - GEO_TIMEOUT_MS: Timeout for geolocation requests (defaults to 3000ms)
*
* Local development override:
* Set BLOCKED_COUNTRIES="*" to disable all geo-blocking during local development.
* Set GEO_BLOCK_COUNTRIES="*" to disable all geo-blocking during local development.
*
* Registration: Can be applied globally in AppModule or selectively to specific routes.
*/
Expand All @@ -32,8 +32,13 @@ export class GeoBlockingMiddleware implements NestMiddleware {
private readonly logger = new Logger(GeoBlockingMiddleware.name);
private readonly blockedCountries: string[];

constructor(private readonly geoService: GeoService) {
this.blockedCountries = this.parseBlockedCountries(env.blockedCountries);
constructor(
private readonly geoService: GeoService,
private readonly configService: ConfigService,
) {
this.blockedCountries = this.parseBlockedCountries(
this.configService.get<string>('GEO_BLOCK_COUNTRIES', '')
);
}

/**
Expand Down
Loading
Loading