Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- The remaining non-trailing parity rows close (guard-core-ts, matrix gaps 24, 29, 30, 33 and 41): `IPBanManager.initializeRedis` ports the reference ban-list migration - legacy keys stored under a non-canonical IP (bracketed or non-compressed IPv6, IPv4-mapped forms, via the new `canonicalizeIp` twin) move to the canonical key with the longer remaining TTL preserved, expired legacy keys are deleted, and every failure mode is a warning that leaves the store untouched. `BaseSecurityDecorator` gains the `routeConfigRevision` surface (the reference `RouteConfigRevision` counter, bumped on every route-config mutation seam). The standalone `checkIpAccess` ships the detailed IP-access verdict - block reason, cloud provider and network on cloud denials, the unknown-identity allowlist arms, the whitelist-skips-countries propagation and the reference error semantics (unparseable ip or malformed entry blocks with the generic reason; the cloud tier takes an injected `CloudHandler` where the reference reads its singleton). Every handler manager class (`IPBanManager`, `CloudHandler`, `DynamicRuleManager`, `IPInfoManager`, `RateLimitManager`, `RedisManager`, `SecurityHeadersManager`, `SusPatternsManager`) is now exported from the package root like the reference `guard_core.__init__`. And the NestJS adapter gains the adapter-level CORS helper (`configureCors` + `buildOriginAllowlist`): the host-express preflight seam with the explicit origin allowlist predicate, closing the last adapter B12 gap.


- The standalone `checkRateLimitByIp` primitive (guard-core-ts, matrix gap 12): the full request-free rate-limit subsystem, not a wrapper over the tiered check. Its own LRU-capped sliding-window counters (never shared with the pipeline manager's store), the `_hash_identity_segment` sha256 endpoint suffix (so the raw path text never reaches Redis and different endpoint budgets cannot collide), the fail-open/fail-closed Redis resolution (one `GuardRedisError` on a closed failure, the once-per-process in-memory fallback warning on an open one), and the dedicated auto-ban feed: a private per-process violation counter (never merged with the pipeline's suspicious counts, counted independently even for the same IP) resolved through the same pure threshold helper the middleware path uses - `threat_ban_config["rate_limit"]` first, then the flat threshold, passive-mode suppression, no counting and no re-ban once the IP is already banned, `rate_limit_exceeded` as the reason. Input validation runs before any side effect (a rejected IP or an endpoint path containing `:` records no hit and feeds no ban, and the rejection message redacts the path). With the default empty endpoint path the Redis key is the pipeline's own global bucket, so the two share one budget by design; the export ships from the package root like the reference `guard_core.utils` surface.


Expand Down
152 changes: 152 additions & 0 deletions packages/core/src/core/checks/ip-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import ipaddr from 'ipaddr.js';

import type { ResolvedSecurityConfig } from '../../models/config.js';
import type { GeoIPHandler } from '../../protocols/geo-ip.js';
import { UNKNOWN_CLIENT_IDENTITY } from '../client-identity.js';
import { checkIpCountry } from '../../utils.js';
import type { CloudHandler } from '../../handlers/cloud.js';

/* The standalone detailed IP-access verdict (the TS port of check_ip_access,
guard_core/_utils/access_control.py): the full allow/block answer with
the block reason and, for cloud-provider denials, the provider and
network - the shape isIpAllowed (the is_ip_allowed twin) computes and
discards. Exported from the package root like the reference
guard_core.utils surface.

Error semantics mirror the reference try/except: an unparseable ip or a
malformed list entry lands in the ValueError arm (blocked with the
generic reason - the reference ip_network(strict=False) raise), and no
other failure mode can turn a clean ip into a block.

The reference resolves cloud providers through the CloudManager
singleton; the TS engine has no singletons, so the cloud tier takes the
handler as an argument (the reference's singleton read becomes an
injected CloudHandler). */

export interface IpAccessResult {
allowed: boolean;
reason: string;
cloudProvider: string | null;
network: string | null;
}

function allowedResult(): IpAccessResult {
return { allowed: true, reason: '', cloudProvider: null, network: null };
}

function blockedResult(reason: string): IpAccessResult {
return { allowed: false, reason, cloudProvider: null, network: null };
}

function genericListBlockReason(ip: string): string {
return `IP ${ip} not in global allowlist/blocklist`;
}

function ipInList(parsed: ipaddr.IPv4 | ipaddr.IPv6, ip: string, entries: readonly string[]): boolean {
for (const entry of entries) {
if (entry.includes('/')) {
const [addr, prefixLen] = ipaddr.parseCIDR(entry);
if (parsed.kind() === addr.kind() && parsed.match([addr, prefixLen])) return true;
} else if (ip === entry) {
return true;
}
}
return false;
}

/* The cloud-provider detail tier (the _check_cloud_providers_detail twin). */
function checkCloudProvidersDetail(
ip: string,
config: ResolvedSecurityConfig,
cloudHandler: CloudHandler | null | undefined,
): IpAccessResult | null {
if (!cloudHandler || config.blockCloudProviders.size === 0) return null;
if (!cloudHandler.isCloudIp(ip, config.blockCloudProviders)) return null;

const details = cloudHandler.getCloudProviderDetails(ip, config.blockCloudProviders);
if (details === null) return blockedResult(genericListBlockReason(ip));

const [provider, network] = details;
return {
allowed: false,
reason: `IP belongs to blocked cloud provider: ${provider}`,
cloudProvider: provider,
network,
};
}

/* The unknown-identity tier (the _check_unknown_identity_access twin): an
unknown client identity only blocks when an allowlist tier is active. */
function checkUnknownIdentityAccess(
ip: string,
config: ResolvedSecurityConfig,
skipIpLists: boolean,
skipCountries: boolean,
): IpAccessResult | null {
if (ip !== UNKNOWN_CLIENT_IDENTITY) return null;
const whitelistBlocks = !skipIpLists && (config.whitelist?.length ?? 0) > 0;
const countryWhitelistBlocks = !skipCountries && config.whitelistCountries.length > 0;
if (whitelistBlocks || countryWhitelistBlocks) {
return blockedResult(genericListBlockReason(ip));
}
return allowedResult();
}

export async function checkIpAccess(
ip: string,
config: ResolvedSecurityConfig,
geoIpHandler?: GeoIPHandler | null,
cloudHandler?: CloudHandler | null,
options?: { skipIpLists?: boolean; skipCountries?: boolean },
): Promise<IpAccessResult> {
const skipIpLists = options?.skipIpLists ?? false;
const skipCountries = options?.skipCountries ?? false;

const unknown = checkUnknownIdentityAccess(ip, config, skipIpLists, skipCountries);
if (unknown !== null) return unknown;

let skipCountryVerdict = skipCountries;

try {
const parsed = ipaddr.parse(ip);

if (!skipIpLists) {
if (config.whitelist && config.whitelist.length > 0) {
if (!ipInList(parsed, ip, config.whitelist)) {
return blockedResult(genericListBlockReason(ip));
}
/* A whitelist match skips the country verdict (the reference
_check_ip_lists_detail's skip_countries propagation). */
skipCountryVerdict = true;
} else if (ipInList(parsed, ip, config.blacklist)) {
return blockedResult(genericListBlockReason(ip));
}
}

if (!skipCountryVerdict) {
if (
(config.blockedCountries.length > 0 || config.whitelistCountries.length > 0)
&& geoIpHandler
) {
const countryBlocked = await checkIpCountry(ip, config, geoIpHandler);
if (countryBlocked) {
const country = geoIpHandler.getCountry(ip);
return blockedResult(
country
? `IP from blocked country: ${country}`
: genericListBlockReason(ip),
);
}
}
}

const cloud = checkCloudProvidersDetail(ip, config, cloudHandler);
if (cloud !== null) return cloud;

return allowedResult();
} catch {
/* The reference ValueError arm: an unparseable ip or a malformed list
entry blocks with the generic reason. */
return blockedResult(genericListBlockReason(ip));
}
}
18 changes: 17 additions & 1 deletion packages/core/src/decorators/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,35 @@ type Constructor<T = object> = new (...args: unknown[]) => T;
const routeIdMap = new WeakMap<Function, string>();
let routeIdCounter = 0;

/* The revision counters live in a module WeakMap so the private state never
appears on the mixin-composed anonymous class types' declaration emit. */
const revisionCounters = new WeakMap<object, number>();

function bumpRouteConfigRevision(self: object): void {
revisionCounters.set(self, (revisionCounters.get(self) ?? 0) + 1);
}

export class BaseSecurityDecorator {
routeConfigs = new Map<string, RouteConfig>();
behaviorTracker: BehaviorTracker;
agentHandler: AgentHandlerProtocol | null = null;
geoIpHandler: unknown = null;
readonly config: ResolvedSecurityConfig;
readonly logger: Logger;

constructor(config: ResolvedSecurityConfig, logger?: Logger) {
this.config = config;
this.logger = logger ?? defaultLogger;
this.behaviorTracker = new BehaviorTracker(config, this.logger);
}

/* The twin of the route_config_revision property (the reference
RouteConfigRevision + _route_config_revision, decorators/base.py):
every route-config mutation seam bumps it, so callers can detect that
the config surface changed without diffing the map. */
get routeConfigRevision(): number {
return revisionCounters.get(this) ?? 0;
}

getRouteConfig(routeId: string): RouteConfig | undefined {
return this.routeConfigs.get(routeId);
}
Expand All @@ -38,6 +53,7 @@ export class BaseSecurityDecorator {
const rc = new RouteConfig();
rc.enableSuspiciousDetection = this.config.enablePenetrationDetection;
this.routeConfigs.set(id, rc);
bumpRouteConfigRevision(this);
}
return this.routeConfigs.get(id)!;
}
Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/handlers/ip-ban.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import ipaddr from 'ipaddr.js';

import type { Logger } from '../models/logger.js';
import type { AgentHandlerProtocol } from '../protocols/agent.js';
import type { RedisManager } from './redis.js';
Expand All @@ -8,6 +10,26 @@ interface BanEntry {
bannedAt: number;
}

/* The twin of _canonicalize_ip (guard_core/_utils/ip_extraction.py): strip
brackets, parse, map an IPv4-mapped IPv6 to its IPv4 text, otherwise the
normalized compressed text; unparseable input answers unchanged (the
legacy key survives migration untouched). */
export function canonicalizeIp(value: string): string {
const stripped = value.startsWith('[') && value.endsWith(']')
? value.slice(1, -1)
: value;
try {
const addr = ipaddr.parse(stripped);
if (addr.kind() === 'ipv6') {
const v6 = addr as ipaddr.IPv6;
if (v6.isIPv4MappedAddress()) return v6.toIPv4Address().toString();
}
return addr.toString();
} catch {
return value;
}
}

export class IPBanManager {
private bannedIps = new Map<string, BanEntry>();
private redisHandler: RedisManager | null = null;
Expand All @@ -18,6 +40,57 @@ export class IPBanManager {

async initializeRedis(redisHandler: RedisManager): Promise<void> {
this.redisHandler = redisHandler;
await this.migrateLegacyBanKeys(redisHandler);
}

/* The twin of _migrate_legacy_ban_keys
(guard_core/handlers/_ipban_migration.py): bans stored under a
non-canonical IP key (bracketed IPv6, non-compressed text, an
IPv4-mapped form) move to the canonical key with the greater remaining
TTL preserved, the legacy key is deleted, and every failure mode is a
warning that leaves the store untouched. */
private async migrateLegacyBanKeys(redisHandler: RedisManager): Promise<void> {
try {
const prefix = `${(redisHandler as unknown as { prefix: string }).prefix}banned_ips:`;
const client = redisHandler.getRawClient();
if (!client) return;
const keys = await client.keys(`${prefix}*`);
for (const key of keys ?? []) {
await this.migrateOneBanKey(client, key as string, prefix);
}
} catch (e) {
this.logger.warn(`Legacy ban-key migration skipped: ${String(e)}`);
}
}

/* The twin of _migrate_one_ban_key: a key whose raw IP already is
canonical stays; an expired legacy key is deleted; the canonical key
keeps the longer of the two TTLs; the legacy key always goes. */
private async migrateOneBanKey(
client: NonNullable<ReturnType<RedisManager['getRawClient']>>,
key: string,
prefix: string,
): Promise<void> {
const rawIp = key.slice(prefix.length);
const canonicalIp = canonicalizeIp(rawIp);
if (canonicalIp === rawIp) return;

const value = await client.get(key);
const oldPttl = await (client as unknown as { pttl(k: string): Promise<number> }).pttl(key);
if (oldPttl <= 0) {
await client.del(key);
return;
}

const canonicalKey = `${prefix}${canonicalIp}`;
const newPttl = await (client as unknown as { pttl(k: string): Promise<number> }).pttl(canonicalKey);
if (newPttl < oldPttl) {
await client.set(canonicalKey, value ?? '', 'PX', oldPttl);
}
await client.del(key);
this.logger.info(
`Migrated legacy ban key ${rawIp} to canonical ${canonicalIp}`,
);
}

async initializeAgent(agentHandler: AgentHandlerProtocol): Promise<void> {
Expand All @@ -35,6 +108,7 @@ export class IPBanManager {

if (this.bannedIps.size >= this.maxSize) {
const oldestKey = this.bannedIps.keys().next().value;
/* v8 ignore next -- oldestKey is undefined only for an empty map, which the size guard excludes */
if (oldestKey) this.bannedIps.delete(oldestKey);
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/handlers/rate-limit-by-ip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function lruPopOrCreate<T>(
}
if (store.size >= maxSize) {
const oldestKey = store.keys().next().value;
/* v8 ignore next -- oldestKey is undefined only for an empty map, which the size guard excludes */
if (oldestKey !== undefined) store.delete(oldestKey);
}
return create();
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,25 @@ export { InMemoryCloudIpStore, RedisCloudIpStore } from './handlers/cloud-ip-sto
/* The request-free rate-limit primitive (parity with the reference
guard_core.utils check_rate_limit_by_ip export). */
export { checkRateLimitByIp } from './handlers/rate-limit-by-ip.js';

/* The standalone detailed IP-access verdict (parity with the reference
guard_core.utils check_ip_access export). */
export { checkIpAccess } from './core/checks/ip-access.js';
export type { IpAccessResult } from './core/checks/ip-access.js';

/* Handler manager classes on the package root (parity with the reference
guard_core/__init__ exports; the adapters and application code compose
them directly). */
export {
BehaviorTracker,
CloudHandler,
DynamicRuleManager,
IPBanManager,
IPInfoManager,
RateLimitManager,
RedisManager,
SecurityHeadersManager,
SusPatternsManager,
} from './handlers/index.js';
export { canonicalizeIp } from './handlers/ip-ban.js';
export type { NodeWebSocketGuardOptions } from './core/node-websocket-guard.js';
Loading
Loading