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
17 changes: 9 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [4.4.0] - 2026-10-15

### Fixed

- Client IP resolution now matches the reference `extract_client_ip`. The resolved identity is canonical (an IPv4-mapped IPv6 address such as `::ffff:127.0.0.1`, which is how a dual-stack Node listener reports an IPv4 client, becomes `127.0.0.1`), is computed once in the `route_config` check and stored on `request.state.client_ip`, and is what `ip_security`, `rate_limit`, `suspicious_activity`, `cloud_provider`, `user_agent`, `emergency_mode`, bans, behavioral tracking, request logging and events read, where they previously read the raw connecting address. `X-Forwarded-For` follows the reference chain rules: honored only from a trusted connecting peer, entries counted from the right at `trustedProxyDepth` with empty entries kept, ports and brackets stripped, invalid or wildcard entries rejected, the right-to-left walk when the declared depth over-counts proxy hops, and the redacted spoof report for a header from an untrusted peer. IP list entries and CIDRs (blacklist, whitelist, `exemptIps`, route lists, `emergencyWhitelist`) are compared in canonical form, so an IPv4 entry or an IPv4 CIDR matches a client that connected as IPv4-mapped IPv6. `trustedProxies` accepts the `unix` token, and a request with no peer address resolves through the forwarded chain when it is present. IP entries are validated with strict address parsing. `canonicalizeIp` now follows the reference exactly (scoped addresses keep their zone, `::1.2.3.4` is not read as IPv4-mapped).
- An excluded path, an all-checks bypass route and a request with no client address no longer end the request in the adapter with an empty 200 (the route handler never ran). The Express, Fastify, Hono and NestJS adapters now continue to the real downstream handler in every case where the reference does. An excluded path is exclusion-scoped like the reference (`guard_exclusion_scoped`): only `route_config`, `ip_security` (ban check plus global IP and country rules, without route rules or ban escalation) and `rate_limit` still run, behavior rules are not tracked, and the response still gets security headers, CORS headers and request metrics. An all-checks bypass route runs only `customResponseModifier` on the real response. A request with no client address is answered 403 under the default `failSecure` (with `onBlock`) and otherwise continues as identity `unknown`. `BypassHandler.handlePassthrough` no longer calls its `callNext` argument, and `BypassHandler.beginSecurityBypass` is the announce half of `handleSecurityBypass` for adapters that cannot await the downstream response. Ports guard-core `handle_passthrough` and `enforced_on_excluded_paths`.
- Fastify: a request blocked in `preValidation` no longer reaches the `onSend` response pass, so it emits no request metrics (reference: metrics only for pass-through responses). The response body is captured for string and Buffer payloads, capped at 10000 bytes, so body-based behavioral return rules fire.
- Hono: the response body is read (capped at 10000 bytes, from a clone, never for `text/event-stream`) when a body-based behavioral return rule is configured and `behaviorScanResponseBody` is on, so those rules fire. The client address now resolves by default on Node (`@hono/node-server`), Deno, Bun and Cloudflare Workers (`resolveHonoClientAddress`); the previous default only understood a string `c.env.remoteAddr`.
- The `Cookie` header is scanned one `name=value` pair at a time, like query parameters. As a single value, the `;` between cookies counted as command separators in the semantic score, so an ordinary logged-in header (a JWT session cookie plus `theme` and `_ga`) could reach 0.71 and be blocked. Each pair takes the normal header path, empty pairs are skipped, only spaces around a pair are trimmed (a CR or LF stays in the scanned value), and each pair counts against the value budget. Ports guard-core #162 (issue #161).
The scan-budget release, with client IP, IP list and dynamic rule parity with the reference engine and adapters that reach the real handler. Lockstep release with the core engines at 4.4.0.

### Added

Expand All @@ -22,11 +16,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Behavior to know before upgrading.** The Express, Fastify, Hono and NestJS adapters now run the downstream route handler for excluded paths, all-checks bypass routes and requests with no client address, where they used to answer an empty 200. The client identity is canonical (an IPv4-mapped IPv6 address is read as IPv4), so every ban, rate-limit key, IP list match and event now uses the IPv4 form. A configured whitelist now decides alone in `isIpAllowed` and `checkRouteIpAccess`, a route whitelist match no longer overrides a blocked country, and the unknown identity and an ungeolocated client are treated as in the reference (see Fixed). A dynamic rule version no longer carries over the previous version's changes: rules that relied on stacking versions must set every field they want. A request that spends `detectionMaxScanSeconds` of regex CPU time is flagged with a `pattern_timeout` threat. A logged-in `Cookie` header that used to be rejected as a command-separator false positive now passes.
- The `enableDynamicRules` description lists exactly what a rule can change.
- The detection scan yields to the event loop every ~2 ms of scanning, so one request's scan no longer holds the loop for its whole duration.

### Fixed

- Client IP resolution now matches the reference `extract_client_ip`. The resolved identity is canonical (an IPv4-mapped IPv6 address such as `::ffff:127.0.0.1`, which is how a dual-stack Node listener reports an IPv4 client, becomes `127.0.0.1`), is computed once in the `route_config` check and stored on `request.state.client_ip`, and is what `ip_security`, `rate_limit`, `suspicious_activity`, `cloud_provider`, `user_agent`, `emergency_mode`, bans, behavioral tracking, request logging and events read, where they previously read the raw connecting address. `X-Forwarded-For` follows the reference chain rules: honored only from a trusted connecting peer, entries counted from the right at `trustedProxyDepth` with empty entries kept, ports and brackets stripped, invalid or wildcard entries rejected, the right-to-left walk when the declared depth over-counts proxy hops, and the redacted spoof report for a header from an untrusted peer. IP list entries and CIDRs (blacklist, whitelist, `exemptIps`, route lists, `emergencyWhitelist`) are compared in canonical form, so an IPv4 entry or an IPv4 CIDR matches a client that connected as IPv4-mapped IPv6. `trustedProxies` accepts the `unix` token, and a request with no peer address resolves through the forwarded chain when it is present. IP entries are validated with strict address parsing. `canonicalizeIp` now follows the reference exactly (scoped addresses keep their zone, `::1.2.3.4` is not read as IPv4-mapped).
- An excluded path, an all-checks bypass route and a request with no client address no longer end the request in the adapter with an empty 200 (the route handler never ran). The Express, Fastify, Hono and NestJS adapters now continue to the real downstream handler in every case where the reference does. An excluded path is exclusion-scoped like the reference (`guard_exclusion_scoped`): only `route_config`, `ip_security` (ban check plus global IP and country rules, without route rules or ban escalation) and `rate_limit` still run, behavior rules are not tracked, and the response still gets security headers, CORS headers and request metrics. An all-checks bypass route runs only `customResponseModifier` on the real response. A request with no client address is answered 403 under the default `failSecure` (with `onBlock`) and otherwise continues as identity `unknown`. `BypassHandler.handlePassthrough` no longer calls its `callNext` argument, and `BypassHandler.beginSecurityBypass` is the announce half of `handleSecurityBypass` for adapters that cannot await the downstream response. Ports guard-core `handle_passthrough` and `enforced_on_excluded_paths`.
- Fastify: a request blocked in `preValidation` no longer reaches the `onSend` response pass, so it emits no request metrics (reference: metrics only for pass-through responses). The response body is captured for string and Buffer payloads, capped at 10000 bytes, so body-based behavioral return rules fire.
- Hono: the response body is read (capped at 10000 bytes, from a clone, never for `text/event-stream`) when a body-based behavioral return rule is configured and `behaviorScanResponseBody` is on, so those rules fire. The client address now resolves by default on Node (`@hono/node-server`), Deno, Bun and Cloudflare Workers (`resolveHonoClientAddress`); the previous default only understood a string `c.env.remoteAddr`.
- The `Cookie` header is scanned one `name=value` pair at a time, like query parameters. As a single value, the `;` between cookies counted as command separators in the semantic score, so an ordinary logged-in header (a JWT session cookie plus `theme` and `_ga`) could reach 0.71 and be blocked. Each pair takes the normal header path, empty pairs are skipped, only spaces around a pair are trimmed (a CR or LF stays in the scanned value), and each pair counts against the value budget. Ports guard-core #162 (issue #161).
- The IP list and route country checks now match the reference engine (issues #139, #141). A configured whitelist decides alone: `isIpAllowed` and `checkRouteIpAccess` consult the blacklist only when no whitelist is configured, where they used to run the blacklist first. `checkRouteIpAccess` combines the route IP list verdict with the route country verdict (false if either is false, true if either is true, otherwise no verdict), so a route whitelist match no longer overrides a blocked country. `isIpAllowed` allows the unknown client identity unless a whitelist or a country whitelist is configured, and a whitelist match skips the country verdict. `checkIpCountry` blocks a client with no geolocation when a country whitelist is configured. Each path is covered by tables generated by running the reference.
- Received dynamic rules are now applied. `DynamicRuleManager` fetched, validated, stored and persisted rules but never acted on them, so an `ipBlacklist`, `emergencyMode` or `globalRateLimit` rule had no effect on request handling. The apply step (the reference `_dynamic_rule_application`) now bans the `ipBlacklist` IPs for `ipBanDuration`, unbans the `ipWhitelist` IPs, adds `suspiciousPatterns` through the pattern validator, and replaces the live country lists, rate limits, cloud providers, `blockedUserAgents` (ReDoS validated), feature toggles, auto-ban threshold and duration, and emergency mode with its whitelist (which also halves the auto-ban threshold). A failed apply restores the previous config, an expiring rule restores the pre-rule config, and boot hydration applies the last-known rules. The rule model gains the `autoBanThreshold`, `autoBanDuration` and `enableRateLimitAutoBan` overrides. `SusPatternsManager.addPattern` now resolves a boolean (false when the validator refuses the pattern).
- Each dynamic rule version now applies over the base config instead of over the previous version's result. The apply step restores the base snapshot taken before the first rule, then applies the new version, so the emergency auto-ban halving no longer compounds (16 went 8, 4, 2 across versions 1 to 3, and now stays 8), a change from an earlier version no longer lingers when a later version omits it, and expiry restores the true base. A failed apply restores the config as it was before the call, so the previously active version keeps running. `suspiciousPatterns` the registry already holds are skipped instead of re-added. Ports guard-core #169.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "guardcore-ts",
"version": "4.3.3",
"version": "4.4.0",
"private": true,
"description": "TypeScript port of guard-core — framework-agnostic security middleware engine",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@guardcore/core",
"version": "4.3.3",
"version": "4.4.0",
"description": "Framework-agnostic security middleware engine for Node.js and edge runtimes",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe('PatternValidationCache', () => {
const values = Object.values(stored);
expect(values.length).toBe(1);
expect(values[0]['version']).toBe(ENGINE_VERSION);
expect(engineVersion()).toBe('4.3.3');
expect(engineVersion()).toBe('4.4.0');
});

it('a corrupt cache starts empty and recovers', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/tests/test-models/agent-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('toAgentConfig (the reference to_agent_config seam)', () => {
const result = toAgentConfig(config({ onError }));
expect(result?.onError).toBe(onError);
expect(result?.guardCoreVersion).toBe(ENGINE_VERSION);
expect(result?.guardCoreVersion).toBe('4.3.3');
expect(result?.guardCoreVersion).toBe('4.4.0');
});

it('rejects an out-of-range status interval and overflow policy at the schema', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/express/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@guardcore/express",
"version": "4.3.3",
"version": "4.4.0",
"description": "Express adapter for @guardcore/core security middleware",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
2 changes: 1 addition & 1 deletion packages/fastify/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@guardcore/fastify",
"version": "4.3.3",
"version": "4.4.0",
"description": "Fastify adapter for @guardcore/core security middleware",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
2 changes: 1 addition & 1 deletion packages/hono/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@guardcore/hono",
"version": "4.3.3",
"version": "4.4.0",
"description": "Hono adapter for @guardcore/core security middleware (edge-safe)",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
2 changes: 1 addition & 1 deletion packages/nestjs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@guardcore/nestjs",
"version": "4.3.3",
"version": "4.4.0",
"description": "NestJS adapter for @guardcore/core security middleware",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
Loading