Skip to content

feat(security): canonicalize filter modes to strict/lenient/none - #133

Merged
cuioss-oliver merged 14 commits into
mainfrom
feature/plan-15-security-pipeline-modes
Jul 30, 2026
Merged

feat(security): canonicalize filter modes to strict/lenient/none#133
cuioss-oliver merged 14 commits into
mainfrom
feature/plan-15-security-pipeline-modes

Conversation

@cuioss-oliver

@cuioss-oliver cuioss-oliver commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Introduce a SecurityProfile mode set {strict, lenient, none} and wire the previously-inert profile
knob end-to-end: the gateway-wide SecurityConfiguration is seeded from the resolved global profile
instead of the hard-coded SecurityConfiguration.defaults(), and each route resolves an effective
profile through the route/endpoint/anchor security_filter → security_defaults fallback chain onto a
cui-http preset, with an omitted security_defaults.profile resolving to strict. The none mode is
a partial disable: it turns off only the per-route injection/validation checks — the URL-parameter
name/value pipeline (relocated out of the pre-route BasicChecksStage into ThoroughChecksStage) and
the per-route divergent-pipeline re-run — while max_body_bytes, allowed_paths, and the entire
pre-route floor (canonical-path production, CanonicalPathGuard, collection limits, header validation)
keep running. none is refused at boot for any route whose effective AccessLevel is
AUTHENTICATED or whose anchor AnchorType is BFF, and the default preset is dropped outright from
the three JSON-Schema enum sites that already gate the value.

Changes

  • SecurityProfile — new mode enum {strict, lenient, none} replacing the dropped default preset.
  • SecurityDefaultsConfig / SecurityFilterConfig — canonicalized profile field, range-checked at
    config-validation time with a hard boot-failure error for unrecognized values.
  • RouteTableBuilder — resolves the effective per-route profile through the
    security_filter → security_defaults fallback chain and refuses none at boot when the route's
    effective AccessLevel is AUTHENTICATED or its anchor AnchorType is BFF.
  • GatewayEdgeRoutesecurityConfigurationFor now maps the resolved profile onto the corresponding
    cui-http preset instead of ignoring the knob.
  • BasicChecksStage / ThoroughChecksStage — stage-1 inbound pipeline split three ways: an always-on
    pre-route part (canonical-path production, URL path pipeline, collection limits, header validation)
    and a per-route-skippable post-route part (URL-parameter value validation) plus
    ThoroughChecksStage wholesale, both skippable under none. CanonicalPathGuard keeps its position.
  • RouteRuntimeAssembler / RouteRuntime — wired the skip decision through to pipeline assembly.
  • ConfigValidator — added the none-on-authenticated / none-on-BFF boot refusal and the unrecognized
    profile hard-failure path.
  • JSON schemas (endpoint.schema.json, gateway.schema.json) — profile enum updated to
    {strict, lenient, none}, default preset removed.
  • ADR-0023 (new) documents the none partial-disable semantics; ADR-0019 amended for the relocated
    URL-parameter validation boundary.
  • doc/configuration.adoc, doc/architecture.adoc, doc/development/README.adoc,
    doc/user/README.adoc — corrected stale claims and documented the new mode set (three-layer docs).
  • integration-tests/scripts/verify-invalid-config-fails.sh and the new
    SecurityProfileModeIT — Docker IT coverage for strict-applies-when-omitted, none-skips-the-
    skippable-stages, and the none-on-authenticated / none-on-BFF boot refusals.
  • Unit test coverage across RouteTableBuilderTest, ConfigLoaderTest, SecurityProfileTest,
    ConfigValidatorTest, GatewayEdgePipelineTest, GatewayEdgeRouteBffWiringTest,
    GatewayEdgeRouteTest, BasicChecksStageTest, ThoroughChecksStageTest, RouteRuntimeTest,
    RouteRuntimeAssemblerTest.

Test Plan

  • Verification command passed (verify -Ppre-commit and verify)
  • Module tests passed (test -pl api-sheriff -am)
  • Integration tests passed (verify -Pintegration-tests -pl integration-tests -am)
  • Manual testing completed (if applicable)

Related Issues

None


Generated by plan-finalize skill

Intent

The problem. The profile knob on security_filter / security_defaults was inert at HEAD: no
read site mapped it to a cui-http preset, so operators believed they could dial security strictness
per route and could not. There was also no way to fully disable the per-route injection/validation
pipeline for routes that genuinely don't need it, and the existing docs described behaviour the code
did not implement.

The chosen approach. Canonicalize the mode set to {strict, lenient, none} (dropping the unused
default preset outright — pre-1.0, no deprecation shim) and wire it end-to-end: boot-time
range-checking with a hard-failure error on unrecognized values, per-route effective-profile resolution
through the security_filter → security_defaults fallback chain (defaulting to strict when omitted),
and a real mapping onto the route's cui-http SecurityConfiguration preset. none is implemented as
a narrow, closed carve-out rather than a full bypass: the stage-1 inbound pipeline is split so only the
per-route injection/validation checks (relocated URL-parameter value validation plus the
ThoroughChecksStage re-run) are skippable, while the pre-route floor (canonical path production,
CanonicalPathGuard, collection limits, header validation) and the body-cap / allowlist checks inside
ThoroughChecksStage always run. none is refused at boot when the

[Intent truncated — 1391 of 2361 characters shown; full outline in the plan workspace]

Summary by CodeRabbit

  • New Features

    • Added configurable inbound security profiles: strict, lenient, and none.
    • Added gateway-wide defaults with route-level profile inheritance and overrides.
    • none mode now selectively disables URL-parameter validation while retaining key safety limits, body-size caps, and allowed-path checks.
    • Reserved gateway paths now bypass route-level checks structurally.
  • Bug Fixes

    • Gateway startup now rejects invalid profiles and unsafe none usage on authenticated or BFF routes.
    • Body-size violations consistently return 413 Content Too Large.
  • Documentation

    • Updated configuration guidance and architecture documentation for profile behavior and validation stages.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @cuioss-oliver, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cuioss-oliver, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository: cuioss/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12c51898-a8ce-440a-9852-6a207648508f

📥 Commits

Reviewing files that changed from the base of the PR and between 7b56cf7 and acb7edd.

📒 Files selected for processing (1)
  • doc/architecture.adoc
📝 Walkthrough

Walkthrough

Changes

Inbound filter profiles and staged validation

Layer / File(s) Summary
Profile contract and boot validation
api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/*, api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java, api-sheriff/src/main/resources/schema/*, api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/...
Adds strict, lenient, and none profiles, schema validation, limits fallback, and boot refusal for authenticated or BFF routes using none.
Posture resolution and runtime assembly
api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java, api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/*, api-sheriff/src/main/java/de/cuioss/sheriff/gateway/routing/RouteRuntime.java, related tests
Resolves gateway and route profiles, builds preset-backed configurations, carries SecurityPosture into runtimes, and logs effective profiles.
Pre-route floor and post-route enforcement
api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/*, api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java, pipeline and wiring tests
Moves URL-parameter validation to the post-route stage, preserves non-skippable floor checks, applies none-mode gating, and changes body-cap failures to CONTENT_TOO_LARGE.
Architecture and operator documentation
doc/adr/*, doc/architecture.adoc, doc/configuration.adoc, doc/development/README.adoc, doc/user/README.adoc
Documents profile resolution, none-mode boundaries, staged validation, refusal rules, and reserved-path behavior.
Integration configuration and end-to-end validation
integration-tests/scripts/*, integration-tests/src/main/docker/sheriff-config/*, integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/*
Adds strict baseline and none-mode routes and verifies fallback, disabled parameter validation, retained limits, and invalid-configuration boot failures.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the core change: canonicalizing security filter modes to strict, lenient, and none.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java (1)

286-289: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Delegate to RouteTableBuilder.globalProfile(gatewayConfig) instead of re-implementing it.

This is byte-for-byte the same resolution as RouteTableBuilder.globalProfile (which ConfigValidator.effectiveProfile already uses). Two copies of the gateway-wide fallback can drift, and a drift here silently changes the effective posture of every block-less route.

♻️ Proposed refactor
-        SecurityProfile globalProfile = gatewayConfig.securityDefaults()
-                .flatMap(SecurityDefaultsConfig::profile)
-                .flatMap(SecurityProfile::parse)
-                .orElse(SecurityProfile.DEFAULT_PROFILE);
+        SecurityProfile globalProfile = RouteTableBuilder.globalProfile(gatewayConfig);
api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java (1)

312-327: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

One resolution site still hand-rolls this chain.

The Javadoc names ConfigValidator as the shared consumer, but GatewayEdgeRoute's constructor re-implements the identical securityDefaults → profile → parse → DEFAULT_PROFILE chain inline rather than calling this method. That is the runtime seam that actually seeds BasicChecksStage and defaultMaxBodySize, so it is the one most costly to let drift from the boot refusal.

Routing it through RouteTableBuilder.globalProfile(gatewayConfig) keeps all three sites on one definition.

api-sheriff/src/main/java/de/cuioss/sheriff/gateway/routing/RouteRuntime.java (1)

77-82: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

securityConfiguration is the only Optional field without a @Builder.Default.

Every other Optional/collection field here (upstream, httpClient, resilienceGuard, assetSource, effectiveWebSocketIdleTimeoutSeconds) declares one, so a builder call site that omits this field gets a null rather than Optional.empty()RouteRuntimeTest.runtimeBuilder() is exactly such a site. The new Javadoc says it "stays Optional only for the builder's test call sites", which only holds if omitting it is safe.

♻️ Give the field the same default as its siblings
+    `@Builder.Default`
     private final Optional<SecurityConfiguration> securityConfiguration;

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: cuioss/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4760ee88-b659-4b15-bca1-073c808e0f3c

📥 Commits

Reviewing files that changed from the base of the PR and between 29f1dac and 2c0244c.

📒 Files selected for processing (35)
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityProfile.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStage.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/ThoroughChecksStage.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/routing/RouteRuntime.java
  • api-sheriff/src/main/resources/schema/endpoint.schema.json
  • api-sheriff/src/main/resources/schema/gateway.schema.json
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/RouteTableBuilderTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/load/ConfigLoaderTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/SecurityProfileTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgePipelineTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GrpcDispatchStageTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssemblerTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/ThoroughChecksStageTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/routing/RouteRuntimeTest.java
  • doc/adr/0019-Reserved_BFF_paths_bypass_the_url-parameter_value_pipeline_and_the_cookie-header_length_relaxation_is_gateway-wide.adoc
  • doc/adr/0023-Inbound_filter_modes_none_is_a_partial_disable_retaining_the_body_cap_and_path_allowlist_and_is_refused_on_authenticated_and_BFF_routes.adoc
  • doc/architecture.adoc
  • doc/configuration.adoc
  • doc/development/README.adoc
  • doc/user/README.adoc
  • integration-tests/scripts/verify-invalid-config-fails.sh
  • integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml
  • integration-tests/src/main/docker/sheriff-config/gateway.yaml
  • integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MetricsIT.java
  • integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/SecurityProfileModeIT.java

Comment thread doc/architecture.adoc Outdated
Comment thread doc/user/README.adoc Outdated
cuioss-oliver and others added 8 commits July 30, 2026 12:04
…tial disable

Move url-parameter validation out of the pre-route BasicChecksStage into the
post-route ThoroughChecksStage so it can be keyed on the route's resolved
security profile. The `none` mode is a PARTIAL disable, not a bypass: it keeps
the max_body_bytes cap and the allowed_paths check, and disables only the
relocated url-parameter validation and the reRunPipelines re-run.

The pre-route floor (parameter-count cap, encoded-separator rejection, header
size limits) still applies to every route regardless of mode, and the reserved
path bypass now holds structurally — a reserved path returns before route
selection and never reaches ThoroughChecksStage.

Tests updated for the none-mode partial-disable matrix across
ThoroughChecksStageTest, BasicChecksStageTest, GatewayEdgePipelineTest and
GatewayEdgeRouteBffWiringTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…routes

Add a fail-closed ConfigValidator rule (ADR-0023): a route whose effective
inbound-filter profile resolves to `none` must be neither effectively
authenticated nor anchored under a `type: bff` anchor. The refusal covers the
gateway-wide case for free — a `security_defaults.profile: none` reaching such a
route through the fallback resolves to `none` and is refused identically.

The access level is derived through the promoted
RouteTableBuilder.effectiveAccessLevel seam rather than read off
AnchorConfig.access, so a route that strengthens a public anchor's auth floor
(permitted by ADR-0007) is still refused — reading the anchor's static access
would under-refuse exactly those routes. RouteTableBuilder.globalProfile is
promoted alongside it so the boot refusal resolves the gateway-wide fallback
through the same chain the route table uses.

Errors collect into the shared list and never fail fast (ADR-0009); the message
names the route, the refusing dimension and the remedy, and echoes no configured
scalar value. No profile-value range rule is added — the JSON Schema owns that
gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… in the Docker tier

Boot half — three new cases in verify-invalid-config-fails.sh (now six total), each
asserting a fail-fast non-zero exit plus a marker that is a config key or a fixed
message fragment, never a rejected scalar:
  4. security_defaults.profile: default — the dropped preset, refused by the JSON
     Schema's enum (the value-range gate);
  5. profile: none on an effectively-authenticated route — refused by the ADR-0023
     ConfigValidator rule on the effective-access-level dimension;
  6. profile: none on a type: bff route — refused on the anchor-type dimension.
Splitting 4 from 5/6 keeps the schema's range gate and the validator's posture gate
independently regression-visible.

Runtime half — SecurityProfileModeIT proves `none` is a PARTIAL disable: a
url-parameter value the strict /proxy route rejects is forwarded on the none route,
while the pre-route floor (encoded separator, query-parameter count cap), the route
body cap and the allowed_paths allowlist all still reject. Without those
still-rejects cases the suite would pass equally against a total bypass.

The mounted config now declares security_defaults.profile: strict explicitly rather
than relying on the omitted-block default, and adds one none-mode route under the
existing public, effectively-unauthenticated `api` anchor, carrying both a
max_body_bytes cap and an allowed_paths allowlist.

Behavioural consequence of the tightened baseline: the pre-route floor moved from the
`defaults()` preset to `strict()` — 1 MiB body (was 5 MiB), 20 query parameters (was
100), 1024-character header values, parameter values and paths (was 2048/2048/4096),
and failOnSuspiciousPatterns now on. The mandated fixture sweep over the mounted
endpoints and the existing IT suites found no fixture that depends on the looser caps:
inbound header values are short (the BFF bearer is injected outbound, not sent by the
client), no IT sends over 20 parameters or a body above 1 MiB, and the upload anchor's
deliberate 64 MiB benchmark cap is an explicit per-anchor override that survives
unchanged. The full 84-test IT suite is green, so this run is also the blast-radius
probe for the tightened baseline — no fixture was loosened to accommodate it.

Also corrects a MetricsIT comment that deliverable 2 invalidated: the url-parameter
rejection it drives now happens post-route in ThoroughChecksStage, not before route
selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two auto-fixes the quality gate produced over the new SecurityProfileNoneRefusal
cases: restore the missing space after a comma in the assertHasError calls, and
replace assertTrue(!x) with assertFalse(x).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ADR-0019

ADR-0023 records four coupled decisions: the canonical mode set strict/lenient/none
with `default` removed and an omitted profile resolving to strict (the value range
staying in the JSON Schema rather than being duplicated into ConfigValidator); `none`
as a partial disable bounded both structurally (the pre-route floor cannot be skipped
because route selection hard-requires the canonical path) and by policy (max_body_bytes
and allowed_paths are deliberately retained); the closed, normative list of what `none`
disables — the relocated url-parameter name/value validation and the pipeline re-run,
nothing else; and the fail-closed boot refusal on effectively-authenticated and BFF
routes, keyed on the derived effective access level per ADR-0007's strengthening case.
The wholesale-skip alternative is recorded as considered and rejected. ADR-0007,
ADR-0013 and ADR-0019 are cross-referenced and explicitly NOT superseded.

ADR-0019 gains an Amendment section: its reserved-BFF-path relaxation is now enforced
structurally — a reserved path terminates before route selection and so never reaches
the relocated parameter pipeline — instead of by the reserved-path predicate, which is
removed. The observable relaxation is unchanged, and the cookie-header relaxation is
untouched.

architecture.adoc's Component Model no longer describes inbound validation as one
undifferentiated step: it now describes the non-skippable pre-route floor, route
selection, and the always-dispatched post-route stage whose validation half is
profile-gated, plus a per-check table naming which half owns each check and whether
`profile` can switch it. `none`'s exact blast radius is stated without describing it as
skipping the thorough checks. The embedded request-pipeline.svg still renders the old
single-box model, so a NOTE marks the prose as authoritative for the pipeline's shape
and the diagram as accurate only for the surrounding stage order — the two no longer
contradict each other silently.

Deviation from the task's acceptance wording: the ADR-0019 amendment carries no literal
date. The manage-adr authoring discipline forbids dates in an ADR body ("no PR numbers,
commit SHAs, dates, or lesson IDs"), and git already carries the chronology.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oc layers

configuration.adoc — five stale or now-incomplete sites corrected:
  (a) the security_defaults example comment now reads strict | lenient | none and points
      at the closed definition instead of glossing `none` inline;
  (b) the when-omitted table row now states the resolution precisely, naming the
      route -> anchor -> security_defaults wholesale-replacement order (and that the
      endpoint level carries no security_filter block, so it is not in the chain);
  (c) the security_filter mapping table drops defaults() and adds `none` as a dispatch
      mode with no cui-http preset of its own, inheriting its limits from the nearest
      non-`none` profile in the chain;
  (d) the security_defaults section is rewritten for the new set — an omitted block
      resolves to strict, the removed `default` is a hard boot failure with a one-line
      migration, and the fail-closed refusal on effective-authenticated and BFF routes is
      stated, including why it keys on the effective rather than the declared access
      level;
  (e) the forwarded-header sanitization paragraph is verified and kept, now noting the
      pipeline is configured gateway-wide so no per-route profile — `none` included —
      relaxes it, alongside the fixed fail-secure field guards.

A new "The `none` mode" subsection reproduces the closed two-list definition as a
normative closed list: everything still enforced (the pre-route floor, max_body_bytes,
allowed_paths, verb gate, auth, CSRF, forward policy) and the exactly two things disabled
(the relocated url-parameter name+value validation, and the pipeline re-run). The
path-safety row now distinguishes the floor's run from the divergent post-route re-run,
since only the latter is switchable.

user/README.adoc (operator-facing) — a decision guide for the three modes with a
when-to-choose-each table, the partial-disable correction spelled out as what a `none`
route still rejects, the boot refusal on authenticated/BFF routes including the indirect
global-fallback case and the strengthened-public-anchor case, the hard boot failure on an
unrecognized value with the `default` -> `strict` migration, and the omitted-block
tightening. The tightening carries its diagnosable symptom — previously-passing requests
returning 400 — plus a before/after cap table (body 5 MiB -> 1 MiB, parameters 100 -> 20,
header/parameter values 2048 -> 1024, path 4096 -> 1024, suspicious-pattern rejection
off -> on) and two remedies ordered to prefer a per-route cap raise over a gateway-wide
loosening.

development/README.adoc (contributor-facing) — the four structural constraints that look
arbitrary without their reasons: why the url-parameter validation moved post-route (the
profile is per-route, so a pre-route check can be gated on neither the profile nor the
route's own configuration) and that the reservedPathMatcher predicate is gone and must not
return; why the pre-route floor cannot be made skippable (route selection consumes the
canonical path the floor produces — a circular dependency, not a preference); why the
`none` branch lives inside ThoroughChecksStage rather than at the dispatch site (the body
cap and path allowlist are enforced inside the stage and must run under `none`, so
skipping the dispatch would silently drop both); where the value-range gate lives (the
JSON Schema's three enum sites, never a second copy in ConfigValidator); and why the boot
refusal keys on the effective access level.

All cap figures are verified against the cui-http strict/lenient/default presets rather
than restated from memory. No occurrence of the `default` preset survives in doc/ as a
valid profile value — the only remaining mention is the migration note that names it as
rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ody cap on 413

Post-rebase reconciliation with PLAN-33 (29f1dac), which landed the derived body-cap plus its 413 CONTENT_TOO_LARGE rejection while this branch was in its finalize wait region.

ADR number collision: PLAN-33 landed doc/adr/0023-The_declared_max_body_bytes_cap_is_the_effective_one... and owns 0023. This plan's inbound-filter-modes ADR is renumbered to 0024 — filename, internal title heading, and every cross-reference in doc/, doc/adr/0019 (the amendment this plan added), the JSON Schema description, javadoc and code comments, the IT config descriptors and the boot-refusal verification script. The renumbered record now also references ADR-0023 as the definition of the cap it retains under 'none'.

Body-cap status alignment: three assertions and one operator-doc claim still described the retained cap as a 400. The cap now raises 413, unchanged by the mode, so GatewayEdgePipelineTest, SecurityProfileModeIT and doc/user/README.adoc are corrected. The 'none' semantics are untouched: the cap and allowed_paths remain enforced under 'none', which still disables only the relocated url-parameter validation and the pipeline re-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cuioss-oliver
cuioss-oliver force-pushed the feature/plan-15-security-pipeline-modes branch from 2c0244c to 30ab701 Compare July 30, 2026 10:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java (1)

312-327: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Route GatewayEdgeRoute's profile fallback through RouteTableBuilder.globalProfile(). ConfigValidator and RouteTableBuilder.logPosture() both use the helper, but GatewayEdgeRoute constructor lines 286-291 still inlines the same chain, keeping the runtime default baseline on independent code.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: cuioss/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 177b194a-3eda-46e9-8971-e4abed69b2f4

📥 Commits

Reviewing files that changed from the base of the PR and between 2c0244c and 30ab701.

📒 Files selected for processing (35)
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/RouteTableBuilder.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityProfile.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStage.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/ThoroughChecksStage.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/routing/RouteRuntime.java
  • api-sheriff/src/main/resources/schema/endpoint.schema.json
  • api-sheriff/src/main/resources/schema/gateway.schema.json
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/RouteTableBuilderTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/load/ConfigLoaderTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/SecurityProfileTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgePipelineTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GrpcDispatchStageTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssemblerTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/ThoroughChecksStageTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/routing/RouteRuntimeTest.java
  • doc/adr/0019-Reserved_BFF_paths_bypass_the_url-parameter_value_pipeline_and_the_cookie-header_length_relaxation_is_gateway-wide.adoc
  • doc/adr/0024-Inbound_filter_modes_none_is_a_partial_disable_retaining_the_body_cap_and_path_allowlist_and_is_refused_on_authenticated_and_BFF_routes.adoc
  • doc/architecture.adoc
  • doc/configuration.adoc
  • doc/development/README.adoc
  • doc/user/README.adoc
  • integration-tests/scripts/verify-invalid-config-fails.sh
  • integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml
  • integration-tests/src/main/docker/sheriff-config/gateway.yaml
  • integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MetricsIT.java
  • integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/SecurityProfileModeIT.java
🚧 Files skipped from review as they are similar to previous changes (7)
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityDefaultsConfig.java
  • integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MetricsIT.java
  • doc/adr/0019-Reserved_BFF_paths_bypass_the_url-parameter_value_pipeline_and_the_cookie-header_length_relaxation_is_gateway-wide.adoc
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/SecurityFilterConfig.java
  • doc/user/README.adoc
  • doc/configuration.adoc
  • doc/development/README.adoc

Comment thread integration-tests/scripts/verify-invalid-config-fails.sh
@cuioss-oliver

Copy link
Copy Markdown
Collaborator Author

/review

@cuioss-review-bot

Copy link
Copy Markdown

PR Reviewer Guide 🔍

🧪 PR contains tests
🔒 No security concerns identified
⚡ No major issues detected

@cuioss-oliver

Copy link
Copy Markdown
Collaborator Author

Triage dispositions

In reply to comment_id: PRR_kwDOPatrT88AAAABHxmIuw

All three nitpicks accepted and still live at HEAD 30ab701. (1)+(2) the duplicated globalProfile resolution between GatewayEdgeRoute:286-289 and RouteTableBuilder.globalProfile — TASK-10 routes the constructor through the shared helper. (3) RouteRuntime.securityConfiguration lacking @Builder.Default — you are right that it is the only Optional field in the class without one, and the consequence is concrete: an omitting builder call site yields null, and ThoroughChecksStage.java:113 calls getSecurityConfiguration().orElse(...), so it NPEs rather than falling back. It also falsified the field's own Javadoc claim that it stays Optional only for the builder's test call sites. This is the same defect class as an earlier internal Q-Gate finding on the sibling securityProfile field, and it takes the same remedy for consistency: TASK-10 adds @Builder.Default = Optional.empty() with Javadoc noting the assembler still sets it unconditionally, and TASK-12 adds the test that pins it. See follow-up commit on this branch.

In reply to comment_id: PRR_kwDOPatrT88AAAABHyvsJg

Accepted — this is the same GatewayEdgeRoute/RouteTableBuilder globalProfile duplication raised inline as a Major on this review, and it is genuinely still present at HEAD 30ab701. Will be addressed by TASK-10, which routes the GatewayEdgeRoute constructor's profile fallback through RouteTableBuilder.globalProfile(gatewayConfig) so the runtime default baseline stops riding independent code. See follow-up commit on this branch.

cuioss-oliver and others added 5 commits July 30, 2026 14:07
…untime.securityConfiguration

GatewayEdgeRoute inlined a second copy of the fail-closed security_defaults
profile resolution that RouteTableBuilder.globalProfile already owns; two copies
of a security-critical default-resolution rule can silently diverge and change
the effective posture of every block-less route. Route the constructor through
the shared helper and drop the now-unused SecurityDefaultsConfig import.

RouteRuntime.securityConfiguration was the only Optional field in the class
without a @Builder.Default, so an omitting builder call site handed a raw null to
ThoroughChecksStage's getSecurityConfiguration().orElse(...) on the hot path.
Apply the same fail-closed remedy already accepted for securityProfile.

Tests: a semantic round-trip guard over builderSeededFrom (seeding from a preset
and restating one dimension at its own value must rebuild that preset, for both
non-none presets) plus a 24-component tripwire so a cui-http upgrade surfaces the
review question, and a RouteRuntime assertion that an omitted
securityConfiguration is Optional.empty() rather than null.

Addresses PR #133 review findings 10985c, 9cc397, 8a03df, cf8372.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e configuration

ThoroughChecksStage.reRunPipelines re-ran the url-path and header-VALUE pipelines
under the route's divergent SecurityConfiguration but never the header-NAME
pipeline, while BasicChecksStage validates header names under the gateway
baseline only. A route whose declared security_filter carried a stricter
header-name policy -- allowed_header_names, blocked_header_names or a lower
maxHeaderNameLength -- therefore never had that policy applied, so it accepted
header names its own resolved configuration should reject. The method's javadoc
already claimed the header pipelines were re-run, so the code under-delivered its
documented contract.

Iterate header entries and validate the key before the values. This completes the
internals of reRunPipelines; it does not change WHAT profile: none disables --
the whole method is item two of the closed two-item list, and the
skippableValidationEnabled() gate in process() is untouched. Header-name
validation has no ADR-0019 cookie carve-out (that relaxation is value-only and
lives in the pre-route floor), so the value loop's pipeline selection is
unchanged.

Tests: the stricter-header-name case is red without the validate call (verified
locally: exactly the two STRICT/LENIENT cases fail), plus two boundary guards --
a none route with the same policy is still not rejected on the name, and a
baseline-equal route is still not re-run.

Addresses PR #133 review finding b35d66.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…refusal leg

ConfigValidator.effectiveProfile resolves through three legs -- route-declared,
anchor-declared, gateway-wide fallback -- but the SecurityProfileNoneRefusal suite
covered only the first and third. The middle leg (an anchor declaring
security_filter.profile: none, inherited by a block-less route) had zero coverage
on a fail-closed security rule.

Add the refusal on an authenticated anchor, its mirror-image acceptance on a
genuinely public unauthenticated route, and the wholesale-replacement boundary the
effectiveProfile javadoc states but nothing pinned: a route declaring a
security_filter block WITHOUT a profile falls back to the gateway-wide value and
never to its anchor's profile, so such a route is accepted under a gateway-wide
strict even when its anchor says none. That last case is the one most likely to
regress silently if the chain were ever turned into a merge.

Test-only; ConfigValidator is unchanged.

Addresses PR #133 review finding 9adc51.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… JSON pointer

verify-invalid-config-fails.sh asserted Case 4 with the bare marker 'profile'.
assert_fails_to_boot only needs a non-zero exit plus a grep -Fq hit, and the bare
word can appear in unrelated startup output, so the case could pass green even if
the D2 schema enum gate on security_defaults.profile regressed entirely -- which
is the one thing the case exists to prove.

Established the real message shape first rather than adopting a plausible
fragment: ConfigLoader files the schema violation with the validator's instance
location, ConfigLoadException renders it as 'gateway.yaml [<pointer>]', and the
live container log reads

  ApiSheriff-200: Invalid configuration in 'gateway.yaml' at
  '/security_defaults/profile': does not have a value in the enumeration
  ["strict", "lenient", "none"]

so /security_defaults/profile is the marker. It is a config key path, never the
rejected scalar, so the D5 redaction contract holds; it is also preferred over any
slice of the enum sentence, which the schema validator localises (the same
assertion renders in German on a de_DE JVM) and which would make a text marker
locale-fragile.

Verified by running the script against the built distroless image: all six cases
still fail-fast non-zero, Case 4 on the tightened marker.

Addresses PR #133 review finding 35ec23.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nonical contract

Three prose defects, all fixed by aligning the docs with the already-implemented
and already-tested contract; no settled semantic changes.

architecture.adoc: the SecurityConfiguration wiring bullet left a reader wondering
what a none route gets, since none has no preset. Add one clause noting it inherits
the nearest non-none preset and cross-reference the Profiles paragraph, which stays
the single authoritative statement of the rule.

doc/user/README.adoc, none row: 'Use it per route, never gateway-wide' contradicted
the implemented contract -- a gateway-wide security_defaults.profile: none IS
accepted while no authenticated or BFF route inherits it, which
ConfigValidatorTest.shouldAcceptGlobalNoneOnPublicRoutesOnly proves. Rewritten as a
recommendation plus the real boundary. The same cell named only one of the two
disabled behaviours, contradicting the section directly below it; the per-route
pipeline re-run is now named too.

doc/user/README.adoc, strict row: the flat 1024-character header-value claim is the
preset cap, not universal. Qualified, with the Cookie / Set-Cookie carve-out named
and cross-referenced to the configuration reference.

Figure verification: the review comment's 2048-character figure is cui-http's
defaults() baseline (MAX_HEADER_VALUE_LENGTH_DEFAULT), not a cap any documented
mode produces -- BasicChecksStage is seeded from the resolved profile preset and an
omitted security_defaults block resolves to strict, so 1024 is the correct strict
figure and 2048 is deliberately NOT written into the strict row. The cookie cap is
oidc.session.max_cookie_size (default 4096, range 40-8192) plus the fixed 512-byte
COOKIE_HEADER_OVERHEAD_BYTES headroom; the README states the derivation rather than
a literal number so it cannot drift from the key.

Addresses PR #133 review findings 170b58 and d7525c.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
doc/architecture.adoc (1)

143-148: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the profile-validation mechanism description.

This says the range is enforced by JSON Schema “not by the configuration validator,” but this PR adds ConfigValidator hard-failure paths for unrecognized profile values. Document both layers rather than excluding the validator.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: cuioss/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e0b0da30-6fd8-4841-93fd-a33d090f2053

📥 Commits

Reviewing files that changed from the base of the PR and between 30ab701 and 7b56cf7.

📒 Files selected for processing (10)
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/ThoroughChecksStage.java
  • api-sheriff/src/main/java/de/cuioss/sheriff/gateway/routing/RouteRuntime.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/ThoroughChecksStageTest.java
  • api-sheriff/src/test/java/de/cuioss/sheriff/gateway/routing/RouteRuntimeTest.java
  • doc/architecture.adoc
  • doc/user/README.adoc
  • integration-tests/scripts/verify-invalid-config-fails.sh

@cuioss-oliver

Copy link
Copy Markdown
Collaborator Author

Triage dispositions

In reply to comment_id: PRR_kwDOPatrT88AAAABHxmIuw

All three nitpicks accepted and still live at HEAD 30ab701. (1)+(2) the duplicated globalProfile resolution between GatewayEdgeRoute:286-289 and RouteTableBuilder.globalProfile — TASK-10 routes the constructor through the shared helper. (3) RouteRuntime.securityConfiguration lacking @Builder.Default — you are right that it is the only Optional field in the class without one, and the consequence is concrete: an omitting builder call site yields null, and ThoroughChecksStage.java:113 calls getSecurityConfiguration().orElse(...), so it NPEs rather than falling back. It also falsified the field's own Javadoc claim that it stays Optional only for the builder's test call sites. This is the same defect class as an earlier internal Q-Gate finding on the sibling securityProfile field, and it takes the same remedy for consistency: TASK-10 adds @Builder.Default = Optional.empty() with Javadoc noting the assembler still sets it unconditionally, and TASK-12 adds the test that pins it. See follow-up commit on this branch.

In reply to comment_id: PRR_kwDOPatrT88AAAABHyvsJg

Accepted — this is the same GatewayEdgeRoute/RouteTableBuilder globalProfile duplication raised inline as a Major on this review, and it is genuinely still present at HEAD 30ab701. Will be addressed by TASK-10, which routes the GatewayEdgeRoute constructor's profile fallback through RouteTableBuilder.globalProfile(gatewayConfig) so the runtime default baseline stops riding independent code. See follow-up commit on this branch.

In reply to comment_id: PRR_kwDOPatrT88AAAABHzhBGw

Fixed in doc/architecture.adoc (Inbound Security Filter, Profiles paragraph): the prose now documents BOTH layers instead of excluding the validator. The bundled JSON Schema profile enum owns the value RANGE (an unrecognized value fails the boot before binding, so no post-binding range rule exists), and ConfigValidator owns the POSTURE rule -- the fail-closed refusal of an effective none on an effectively-authenticated or type: bff route (ADR-0024). This matches the ConfigValidator class Javadoc verbatim. No gate was moved: the range check keeps its schema home per the standing outline decision for this plan.

In reply to comment_id: IC_kwDOPatrT88AAAABMcjMOw

Acknowledged -- no action required. This PR Reviewer Guide comment reports no actionable finding (PR contains tests / no security concerns identified / no major issues detected), so it is recorded as review-participation evidence rather than triaged as a defect. Thanks for the pass.

The passage claimed profile-range validation is enforced "by the bundled
JSON Schema, not by the configuration validator". The range half is
accurate, but the trailing clause read as excluding ConfigValidator from
profile validation entirely — which this change makes false, since
ConfigValidator now owns the `none` fail-closed posture refusal via
validateSecurityProfileNone.

The paragraph now names both layers and cross-references the existing
"What `profile: none` costs, exactly." paragraph, which documented the
boot refusal without naming the enforcing layer. No gate moved: the value
range stays owned by the three JSON Schema enum sites, matching
ConfigValidator's own class javadoc.

Resolves CodeRabbit finding 14ae54 on PR #133.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cuioss-oliver
cuioss-oliver added this pull request to the merge queue Jul 30, 2026
Merged via the queue into main with commit 9b8ec4a Jul 30, 2026
24 checks passed
@cuioss-oliver
cuioss-oliver deleted the feature/plan-15-security-pipeline-modes branch July 30, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant