Skip to content

Commit a25358b

Browse files
authored
Merge pull request InsurNiffy#1035 from kaynaomi-oss/feature/883-geo-block-policy-initiation
Closes InsurNiffy#883
2 parents 5661d51 + 0cb1cdc commit a25358b

6 files changed

Lines changed: 134 additions & 2 deletions

File tree

backend/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ ADMIN_CORS_ORIGINS=http://localhost:3002
175175
# [optional] Canonical combined CORS allowlist. When set, takes precedence over FRONTEND_ORIGINS + ADMIN_CORS_ORIGINS.
176176
CORS_ALLOWED_ORIGINS=
177177

178+
# Compliance
179+
# [optional] Comma-separated ISO 3166-1 alpha-2 country codes blocked from policy initiation (geo-compliance). Empty = no blocking.
180+
BLOCKED_COUNTRIES=
181+
178182
# Observability
179183
# [required] Minimum application log level.
180184
LOG_LEVEL=info

backend/src/config/env.definitions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface EnvironmentVariables {
5656
FRONTEND_ORIGINS: string;
5757
ADMIN_CORS_ORIGINS: string;
5858
CORS_ALLOWED_ORIGINS: string;
59+
BLOCKED_COUNTRIES: string;
5960
LOG_LEVEL: LogLevel;
6061
CACHE_TTL_SECONDS: number;
6162
QUOTE_SIMULATION_CACHE_ENABLED: 'true' | 'false' | '1' | '0';
@@ -703,6 +704,15 @@ export const ENV_DEFINITIONS: EnvDefinitionMap = {
703704
required: 'optional',
704705
schema: Joi.string().allow('').default(''),
705706
},
707+
BLOCKED_COUNTRIES: {
708+
key: 'BLOCKED_COUNTRIES',
709+
section: 'Compliance',
710+
description:
711+
'Comma-separated ISO 3166-1 alpha-2 country codes blocked from policy initiation (geo-compliance). Empty = no blocking.',
712+
example: 'KP,IR,CU',
713+
required: 'optional',
714+
schema: Joi.string().allow('').default(''),
715+
},
706716
LOG_LEVEL: {
707717
key: 'LOG_LEVEL',
708718
section: 'Observability',
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Tests for GeoBlockGuard.
3+
*
4+
* Verifies:
5+
* - Requests from a blocked country are rejected with 451.
6+
* - Requests from an allowed country pass.
7+
* - A missing geo header defaults to allow (no false positives).
8+
*/
9+
10+
import { ExecutionContext, HttpException } from '@nestjs/common';
11+
import { ConfigService } from '@nestjs/config';
12+
import { GeoBlockGuard } from '../geo-block.guard';
13+
14+
function makeContext(headers: Record<string, string>): ExecutionContext {
15+
const request = { headers };
16+
return {
17+
switchToHttp: () => ({
18+
getRequest: () => request,
19+
}),
20+
} as unknown as ExecutionContext;
21+
}
22+
23+
function makeConfigService(blockedCountries: string): ConfigService {
24+
return { get: () => blockedCountries } as unknown as ConfigService;
25+
}
26+
27+
describe('GeoBlockGuard', () => {
28+
it('rejects requests from a blocked country with 451', () => {
29+
const guard = new GeoBlockGuard(makeConfigService('KP,IR,CU'));
30+
const ctx = makeContext({ 'cf-ipcountry': 'IR' });
31+
32+
expect(() => guard.canActivate(ctx)).toThrow(HttpException);
33+
try {
34+
guard.canActivate(ctx);
35+
} catch (err) {
36+
expect((err as HttpException).getStatus()).toBe(451);
37+
}
38+
});
39+
40+
it('allows requests from a non-blocked country', () => {
41+
const guard = new GeoBlockGuard(makeConfigService('KP,IR,CU'));
42+
const ctx = makeContext({ 'cf-ipcountry': 'US' });
43+
44+
expect(guard.canActivate(ctx)).toBe(true);
45+
});
46+
47+
it('allows requests with no geo header (no false positives)', () => {
48+
const guard = new GeoBlockGuard(makeConfigService('KP,IR,CU'));
49+
const ctx = makeContext({});
50+
51+
expect(guard.canActivate(ctx)).toBe(true);
52+
});
53+
54+
it('falls back to X-Country-Code when CF-IPCountry is absent', () => {
55+
const guard = new GeoBlockGuard(makeConfigService('KP,IR,CU'));
56+
const ctx = makeContext({ 'x-country-code': 'kp' });
57+
58+
expect(() => guard.canActivate(ctx)).toThrow(HttpException);
59+
});
60+
61+
it('allows all requests when BLOCKED_COUNTRIES is empty', () => {
62+
const guard = new GeoBlockGuard(makeConfigService(''));
63+
const ctx = makeContext({ 'cf-ipcountry': 'IR' });
64+
65+
expect(guard.canActivate(ctx)).toBe(true);
66+
});
67+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* GeoBlockGuard — optional jurisdiction block on policy initiation.
3+
*
4+
* Reads the CF-IPCountry header (Cloudflare) or X-Country-Code header
5+
* (fallback, e.g. for non-Cloudflare deployments) and rejects requests
6+
* from countries listed in the BLOCKED_COUNTRIES env var with 451
7+
* (Unavailable For Legal Reasons).
8+
*
9+
* Fails open: a missing geo header allows the request (no false positives).
10+
* Configurable without code changes via BLOCKED_COUNTRIES (comma-separated
11+
* ISO 3166-1 alpha-2 codes).
12+
*/
13+
14+
import { Injectable, CanActivate, ExecutionContext, HttpException } from '@nestjs/common';
15+
import { ConfigService } from '@nestjs/config';
16+
import { Request } from 'express';
17+
18+
@Injectable()
19+
export class GeoBlockGuard implements CanActivate {
20+
constructor(private readonly configService: ConfigService) {}
21+
22+
canActivate(context: ExecutionContext): boolean {
23+
const request = context.switchToHttp().getRequest<Request>();
24+
const countryCode =
25+
(request.headers['cf-ipcountry'] as string | undefined) ??
26+
(request.headers['x-country-code'] as string | undefined);
27+
28+
if (!countryCode) return true;
29+
30+
const blocked = (this.configService.get<string>('BLOCKED_COUNTRIES') ?? '')
31+
.split(',')
32+
.map((code) => code.trim().toUpperCase())
33+
.filter(Boolean);
34+
35+
if (blocked.includes(countryCode.trim().toUpperCase())) {
36+
throw new HttpException(
37+
{
38+
statusCode: 451,
39+
error: 'Unavailable For Legal Reasons',
40+
message: `Policy initiation is not available in your region (${countryCode.trim().toUpperCase()}).`,
41+
},
42+
451,
43+
);
44+
}
45+
46+
return true;
47+
}
48+
}

backend/src/policy/policy.controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Throttle } from '@nestjs/throttler';
44
import { PolicyService } from './policy.service';
55
import { BuildTransactionDto } from './dto/build-transaction.dto';
66
import { WalletRateLimitGuard } from '../rate-limit/wallet-rate-limit.guard';
7+
import { GeoBlockGuard } from './geo-block.guard';
78

89
@ApiTags('Policy')
910
@Controller('policy')
@@ -39,11 +40,12 @@ export class PolicyController {
3940
@Post('build-transaction')
4041
@HttpCode(HttpStatus.OK)
4142
@Throttle({ default: { limit: 10, ttl: 60_000 } })
42-
@UseGuards(WalletRateLimitGuard)
43+
@UseGuards(WalletRateLimitGuard, GeoBlockGuard)
4344
@ApiOperation({ summary: 'Build unsigned initiate_policy transaction' })
4445
@ApiResponse({ status: 200, description: 'Unsigned transaction XDR + fee estimates' })
4546
@ApiResponse({ status: 400, description: 'Validation / account / simulation error' })
4647
@ApiResponse({ status: 429, description: 'Rate limited — protects RPC quotas' })
48+
@ApiResponse({ status: 451, description: 'Blocked jurisdiction (BLOCKED_COUNTRIES)' })
4749
@ApiResponse({ status: 503, description: 'Contract not deployed or RPC unavailable' })
4850
async buildTransaction(@Body() dto: BuildTransactionDto) {
4951
return this.policyService.buildTransaction(dto);

backend/src/policy/policy.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import { RenewalReminderService } from "./renewal-reminder.service";
99
import { RpcModule } from "../rpc/rpc.module";
1010
import { NotificationsModule } from "../notifications/notifications.module";
1111
import { TenantModule } from "../tenant/tenant.module";
12+
import { GeoBlockGuard } from "./geo-block.guard";
1213

1314
@Module({
1415
imports: [ScheduleModule.forRoot(), RpcModule, NotificationsModule, TenantModule],
1516
controllers: [PolicyController, RenewalController],
16-
providers: [PolicyService, PolicyReadService, RenewalService, RenewalReminderService],
17+
providers: [PolicyService, PolicyReadService, RenewalService, RenewalReminderService, GeoBlockGuard],
1718
exports: [PolicyService, PolicyReadService, RenewalService],
1819
})
1920
export class PolicyModule {}

0 commit comments

Comments
 (0)