Skip to content

Add pluggable native Geo providers and protected CloudFront ingress - #439

Open
ChristianPavilonis wants to merge 15 commits into
feat/native-celld-storesfrom
feature/pluggable-geo-providers
Open

ChristianPavilonis wants to merge 15 commits into
feat/native-celld-storesfrom
feature/pluggable-geo-providers

Conversation

@ChristianPavilonis

@ChristianPavilonis ChristianPavilonis commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Current review readiness

Ready for review. Required current-head CI passed at 414e52ae41ee95880fd11238a22ad2dc944f44c2: test run 42924133045.

Minimal fixes originate in #414 and are merged upward:

  • Typed SDK connector timeouts map to Deadline; non-timeouts remain Unavailable. The previously failing hosted timeout test and all 78 provider cases now pass without assertion, retry or duration changes.
  • Fresh consumers use the existing locked SDK cohort: optional native aws-types =1.6.0 and aws-smithy-runtime-api =1.19.0. This prevents the schema mismatch and preserves the explicit older behavior version instead of switching retry policy or suppressing deprecations. A fresh consumer passes with compiler and Clippy warnings denied throughout.
  • The constructor-boundary test attempts the full upload while independently reading and requiring a complete, finite expected response. The existing startup-readiness poll treats a connection reset as not yet HTTP-ready within its unchanged deadline; success still requires a complete HTTP200 and the original payload checks. Production transport and limits are unchanged.

Local final-top Axum checks pass 488 unit, 28 contract, six AWS startup, four celld startup and 36 hosting cases. Final-top workspace strict Clippy and formatting pass. The prior stable checkpoint passed all six mandatory commands; unchanged inputs retain their receipts, while fresh current-head CI checks the combined state. The current ready state supersedes historical draft-only prerequisites below; unexecuted deployment acceptance is not claimed complete. An additional Fastly WASM runtime job was still running at transition and is not counted as passed. No TS work or deployment qualification is a review prerequisite. Integration #462 remains draft; reviewers aram356, prk-Jr and dhruv8sh remain requested. Stack465 and PR bases are unchanged; #453 is excluded.

Current review-stack checkpoint

Publication head: 414e52ae41ee95880fd11238a22ad2dc944f44c2. This cleanup preserves the existing feature unit and PR identity. The additional change is the completed generated-native build correction: image-only code now follows the scaffold's existing Fastly-example selection, with no request-policy change. Its focused generator/native proof is retained; fresh per-branch hosted checks are not assumed green.

Review order: #275 → #406 → #407 → #409 → #414 → #422 → #439 → #454. Draft integration checkpoint: #462. New SDK/group/codec/phase development and unrelated TS work are not prerequisites or additions to this PR. Earlier notes below retain their original tested revisions and limits; they are not a whole-tree pass for this new head.

Earlier implementation and proof notes

RC refresh checkpoint, 2026-11-10

Current head: c8ee48fcea9828095640a0fa8e593fb59c6d57fa.

Geo-only behavior is ported onto current #409/#422 instead of restoring repeated older dispatch/body code. Built-ins remain synchronous and local. One private outcome is captured from owned ingress and retained through the managed pre-dispatch seam. Native trusted_proxy naming keeps the outbound hard-cut guard byte-unchanged, without the old exemption.

Feature core/native/all-feature, Geo-wire and disabled-feature checks, dependency isolation, strict Clippy, docs/actionlint/format pass. Independent source review found no credible introduced defect. Lower CI/fixture fixes are merged; all five locked fetches and 35 CI tooling cases pass.

Protected CloudFront is a composition: ALB validates the credential value externally; EdgeZero checks the actual socket-source policy and strips the selected credential header, including normalized-head errors, before relevant application callbacks. No live ALB/CloudFront source qualification is claimed. Arbitrary unmanaged ctx.into_request plus a fresh context deliberately resets Geo to NoSelection. This adds no production KV Geo provider or local MaxMind implementation. The admitted-error finalizer is a separate follow-on.

Draft pending fresh hosted checks and the combined RC audit. Two unchanged native default-parallel deadline misses remain recorded and unclassified; serial passes and untouched-foundation parallel passes do not clear them. No timing bound was relaxed. No deployment, main merge, security-alert dismissal or review-thread resolution occurred. Historical design/review context follows.


Summary

  • Add portable validated Geo values, bounded redacted assertions, a synchronous provider contract and one immutable outcome per request.
  • Add default-off native CloudFront and supplied-provider selection, early startup validation and protected-ALB source admission based only on the owned socket peer. No fallback, remote lookup or automatic HTTP permission decision.
  • Add real hosting and portable WASM regressions, deployment guidance, compiled examples and explicit feature/dependency CI coverage.

Stack and prerequisite reconciliation

Base branch: feat/native-celld-stores, draft #422 at 5426ea65. This PR stays on top of the existing native celld stack.

Although the GitHub stack names #409 as an ancestor, this base did not contain its checked-ingress implementation. This diff therefore also reconciles the pinned 66a05e25 checked-ingress/proxy/diagnostics contract. It adapts that commit's own delta while preserving newer native stores, local non-Send initialization, Connections/FuturesUnordered, bounded ingress/config snapshots and asynchronous response EOF validation. The existing forwarding parser supplies the shared bounded peer-network matcher.

The distinct #407 buffered JSON-cap/managed Body feature is not imported or claimed. Native JSON error privacy is retained through private admitted/context state. The plan records exactly which #407-dependent cap/carrier checks are excluded. No old branch tree replaced newer files.

Changes

Crate / File Change
edgezero-core::geo Validated provider IDs, pinned ISO country allowlist, syntax-only subdivisions, bounded input slots and source-free error categories. Portable providers gain no Send/Sync or async requirement.
Core dispatch/context Owned Geo handoff outside public extensions, read-only ctx.geo(), default NoSelection on legacy/unmanaged paths, preserved framework error privacy and egress classification.
Native Geo composition One Off/CloudFront/Supplied selection in AxumRunOptions, capability inspection through finalized options, startup rejection before runner resources, native-only Arc thread bounds.
Checked native ingress Shared CIDR bounds/mapped-peer normalization, separate opaque protected-ALB policy, credential removal before callbacks/error handling while original bytes still count, exactly-once admitted resolution.
Native and portable tests Real no-store listener proof, enabled/disabled/supplied modes, mutation/forgery/cancellation/finalization tests, retained checked-ingress/TLS/lifecycle tests, no-selection WASM contracts.
Docs and CI Native Geo and reconciled diagnostics guides, four compiled Geo examples, explicit feature-state tests, fail-closed dependency graph script, PR trigger for the celld base.
Lockfiles Dependency edges only in root/demo/reusable locks. No selected package version changes. Geo itself adds no third-party runtime dependency.

Issue scope

Closes #432. Implements the CloudFront and synchronous Geo contract under #391. Local GeoIP databases, checked-reader-IP input and database lifecycle are tracked separately in #438 and are not included in this PR.

Trusted Server adoption and jurisdiction/consent interpretation remain separate. Geo is an estimate, not proof of residence, identity or consent. Country-only and unrecognized subdivisions stay unknown to application policy.

Test plan

  • cargo test --locked --workspace --all-targets: 2,115 passed, 6 existing generated-consumer/real-service tests ignored.
  • cargo test --locked -p edgezero-adapter-axum --all-targets --all-features: 422 passed, including mixed AWS/celld startup, preserved lifecycle/EOF tests and 32 hosting tests. Provider service fixtures are loopback-only.
  • Explicit default-off/enabled library suites: 323 disabled and 322 enabled tests passed. Startup-before-resources, bounded parsing, supplied-provider, immutable outcome and exactly-once sentinels executed.
  • Explicit no-default-feature real-hosting suites: 32 enabled and 33 disabled tests passed. Both unavailable-built-in and supplied-without-built-in sentinels passed.
  • cargo clippy --locked --workspace --all-targets --all-features -- -D warnings, disabled-native Clippy, cargo fmt --all -- --check, git diff --check.
  • Workspace feature compilation, minimal native and bindings-only builds, all three adapter WASM target checks.
  • WASM runtime contracts: Fastly 24 tests using Viceroy 0.21.0, Cloudflare 29 using wasm-bindgen-test-runner 0.2.128 and Node 24.12.0, Spin 35 using Wasmtime 48.0.2. Fastly's preparer/closed-lifecycle sentinel also ran explicitly.
  • New Geo and existing native-store dependency scripts. Geo script also has 16 executed failure-case mock checks.
  • Native doc tests: 5 passed. Warnings-denied workspace rustdoc passed. Guide snippets match the compiled examples.
  • App-demo workspace: 54 passed. Reusable fixture-axum checked against its updated lockfile.
  • Docs lint, format and site build. Seven official AWS source pages checked.
  • Manual edgezero serve --adapter axum. Programmatic production-runner and real-listener tests were used instead; no Geo CLI selector is introduced.
  • Actual CloudFront/ALB deployment and downstream policy acceptance. These require separate authorization and are not substitutes for local CI.

Tested implementation is 56eab85f; the later commit records evidence/status only. Exact commands, tested feature sets, timings, local logs and ignored-test limits are in the implementation plan. No failing local gate is excluded.

Trust and acceptance limits

The protected policy declares an externally credential-gated and isolated ALB path. A matching subnet does not authenticate a distribution or prove that its listener rules are correct. EdgeZero does not receive the configured credential value or call AWS to inspect/provision infrastructure.

Production use still requires deployed proof of the credential gate, target isolation, viewer-header replacement including AWS-network missing-subdivision spoofing, value rotation and shared-cache/error-TTL isolation. The guide requires HTTPS, a default-deny ALB, CloudFront origin-facing prefix-list restrictions, private target access, explicitly generated geographic headers and disabled shared caching. No deployment was attempted.

The six existing ignored generated/service harnesses were not counted as passes. Unchanged docs dependencies report seven npm audit vulnerabilities; no unrelated dependency upgrades were attempted. ShellCheck was unavailable; Bash syntax, behavioral failure cases and real graph checks passed.

Checklist

  • Changes follow CLAUDE.md conventions and scoped ownership.
  • No native runtime or AWS SDK dependency added to portable core/WASM graphs. Reconciled TLS fixture dependencies are native test-only.
  • No legacy route-param syntax or application-side geographic-header trust added.
  • Existing store wiring retains typed registries; Geo is separate from stores and PreparedStores.
  • New behavior has constructor, integration, real-listener and WASM tests.
  • No production secrets committed. Checked-in TLS certificates/key and credential strings are public, disposable fixture material only.

@ChristianPavilonis
ChristianPavilonis force-pushed the feature/pluggable-geo-providers branch from 72ceed7 to c8ee48f Compare November 10, 2026 20:15
@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review November 10, 2026 23:48

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR Review

Summary

Adds portable Geo outcome types in core, protected CloudFront/ALB source admission in native Axum, and a deployment guide. Requesting changes for one design question (raw geographic headers stay readable to application code) and a CI guard mistake. The rest is cleanup, including two pieces of core plumbing that I confirmed are unneeded by disabling each one and re-running the tests.

Findings

Blocking

  • ❓ Raw CloudFront-Viewer-* headers stay readable from untrusted peers (crates/edgezero-adapter-axum/src/service.rs:473)
  • 🔧 Geo CI steps run in all four host-test legs (.github/workflows/test.yml:189)

Non-blocking

  • ♻️ category_only_json_errors is redundant for every in-tree caller (crates/edgezero-core/src/context.rs:677)

  • ♻️ The pre-dispatch Geo copy is never read (crates/edgezero-core/src/context.rs:783)

  • ♻️ geo_hosting_invalid_policy_never_initializes_or_listens never reaches the runner (crates/edgezero-adapter-axum/tests/production_hosting.rs:2350)

  • 🤔 Does geo-cloudfront pay for itself? (crates/edgezero-adapter-axum/Cargo.toml:40)

  • ⛏ Duplicate dep:getrandom/dep:ipnet in the axum feature (crates/edgezero-adapter-axum/Cargo.toml:22)

  • ⛏ The PR description is stale relative to the head:

    • It cites tested implementation 56eab85f, which is not on this branch; the head is 414e52ae.
    • It names the base as #422 at 5426ea65; the base is now f9b0f20c.
    • The Lockfiles row claims dependency edges in the root/demo/reusable locks; the diff changes no lockfile.
    • The reconciliation section says this diff brings in the #409 checked-ingress contract, but that is already in the base. Here the diff only extracts peer_networks.rs.
    • One section says "Ready for review", another "Draft pending fresh hosted checks".

    Trimming it to the current head's change set and evidence would make the review surface clear.

  • 🌱 Public Geo types will need breaking changes for #438 and VPC origins (crates/edgezero-core/src/geo.rs:280)

  • 🌱 Hidden dispatch_admitted_with_* chain (crates/edgezero-core/src/app.rs:321)

  • 🌱 Guide snippets are hand copies of the doctests: the Rust blocks in docs/guide/native-geo.md (lines 30-174) duplicate the four doctests in crates/edgezero-adapter-axum/src/geo.rs:7-88, and nothing checks that they stay identical. Moving the examples into compiled files and pulling them into the guide with VitePress <<< snippet imports would let the existing doctest CI step cover the guide.

  • 🌱 Kosovo (XK) acceptance check (docs/guide/native-geo.md:218)

📌 Out of Scope

  • Pre-existing macOS-only failures, untouched by this PR (Linux CI is green): the four production_hosting proxy/TLS tests need 127.0.0.2/127.0.0.3 on loopback, and native_aws_startup::production_runner_composes_celld_config_aws_secret_and_local_secret fails with WouldBlock (os error 35) because its fixture's accepted stream inherits non-blocking mode.

CI Status

  • fmt: PASS
  • clippy: PASS
  • tests: PASS (workspace; feature check and Spin wasm32 check also pass)
  • Geo steps run locally: dependency script, core/Axum Geo unit tests, service::tests, doctests, and Geo hosting tests with the built-in disabled and enabled (serial and parallel): PASS
  • GitHub checks at 414e52ae: all green

.await;
}
};
let geo = self.geo.resolve(remote_addr, &parts.headers);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

❓ Raw CloudFront-Viewer-* headers stay readable, including from untrusted peers

When a Geo selection is active, both headers are read into GeoInput here, then left on parts.headers. A request from a peer outside the protected ranges gets UntrustedIngress from ctx.geo(), while ctx.headers()["cloudfront-viewer-country"] still carries whatever the client sent. Two lines above, strip_forwarding_headers removes raw forwarding headers unconditionally so application code cannot misread them. Geo has no equivalent, and docs/guide/native-geo.md doesn't tell applications not to read these headers.

Was keeping them intentional? If not, remove both names once they have been consumed. Off stays unchanged:

let geo = self.geo.resolve(remote_addr, &parts.headers);
self.geo.strip_assertions(&mut parts.headers);
// geo.rs
pub(crate) fn strip_assertions(&self, headers: &mut HeaderMap) {
    if matches!(self, Self::Selected { .. }) {
        let _country = headers.remove("cloudfront-viewer-country");
        let _region = headers.remove("cloudfront-viewer-country-region");
    }
}

The fixture's mutated_country == "GB" assertion in production_hosting.rs would change accordingly. If keeping them is deliberate, please add a warning to the guide's "Read the immutable request outcome" section that the raw headers are unauthenticated and ctx.geo() is the only trusted read.

if: matrix.suite == 'workspace'
run: bash scripts/check_native_store_dependency_contract.sh

- name: Geo dependency isolation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔧 Geo steps run in every host-test matrix leg

Every neighbouring step here is guarded with if: matrix.suite == 'workspace', but the nine new Geo steps (lines 189-219) have no guard. Run 42924133045 shows all nine executed in the workspace, cli, axum and app-demo legs, so the dependency script and four distinct Axum feature builds run four times per push.

Add the guard to each step:

      - name: Geo dependency isolation
        if: matrix.suite == 'workspace'
        run: bash scripts/check_geo_dependency_contract.sh

(or matrix.suite == 'axum' if they are meant to live with the Axum leg).

let bytes = self.buffered_bytes(max, true).await?;
serde_json::from_slice(bytes.as_ref()).map_err(|err| {
if self.body_policy.is_some() {
if self.category_only_json_errors || self.body_policy.is_some() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

♻️ category_only_json_errors duplicates the existing body_policy privacy

The only caller that passes FrameworkErrorDetail::CategoryOnly is managed Axum dispatch, and Axum attaches with_buffered_json_policy to every managed body (crates/edgezero-adapter-axum/src/request.rs:84). So self.body_policy.is_some() already makes this path category-only, and the base already asserts "invalid JSON payload" in production_hosting.rs (native_default_errors_json_cap_and_logs_keep_sentinels_private).

I checked this locally by forcing the flag off: all 5 Axum hosting JSON tests and all 436 Axum lib tests still pass. The only failure is app::tests::geo_owned_outcome_survives_public_request_mutation, which builds a body without a JSON policy.

Suggest dropping the flag and the bool half of the (GeoOutcome, bool) plumbing (AdmittedIngress::{request_context, take_request_context, with_request_context} and app.rs:337), and removing the JSON assertions from the Geo test. That also keeps a non-Geo change to core JSON error text out of this PR.

}
}
pub(crate) fn new_pre_dispatch(request: Request, ingress: Option<&AdmittedIngress>) -> Self {
let (geo, category_only_json_errors) = ingress.map_or(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

♻️ The pre-dispatch Geo copy is never read

new_pre_dispatch clones the outcome through AdmittedIngress::request_context(), which costs a String allocation for the provider ID on every selected request. PreDispatchRequest has no geo() accessor, and every path that builds the final RequestContext passes Some(ingress) to finish_pre_dispatch, which overwrites the copy from take_request_context().

Locally, defaulting this to (GeoOutcome::NoSelection, false) passes core (1144), Axum lib (436) and the Geo hosting tests, and leaves request_context unused. Either drop it and delete AdmittedIngress::request_context, or, if pre-dispatch hooks should see Geo (design section 3, step 4 says the outcome is stored before request preparers run), add PreDispatchRequest::geo() with a test.

}

#[test]
fn geo_hosting_invalid_policy_never_initializes_or_listens() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

♻️ This test never reaches the runner

In geo-invalid-policy mode the fixture passes an empty network list, so ProtectedCloudFrontAlb::new(...)? (tests/fixtures/production_host.rs:1002) fails in main before run_app_with_options_and_initializer is called. The assertions (no INITIALIZED, no bind, no roots) hold by construction. Empty-list rejection is already covered by geo_protected_policy_reuses_network_bounds, and with geo-cloudfront enabled no hosting test exercises runner-side Geo rejection.

Build a valid policy plus a selection that only NativeGeo::validate rejects. This works in both feature states:

let selection = if selected == "geo-reserved-identity" {
    NativeGeoSelection::Supplied {
        policy,
        id: GeoProviderId::cloudfront(),
        provider: Arc::new(FixtureGeoProvider(false)),
    }
} else if selected == "geo-off" {

and rename the mode and test to match.

aws-dynamodb = ["axum", "edgezero-store-aws/dynamodb-kv"]
celld-config = ["axum", "edgezero-store-celld/config"]
celld-kv = ["axum", "edgezero-store-celld/kv"]
geo-cloudfront = ["axum"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤔 Does the geo-cloudfront feature pay for itself?

It gates geo/cloudfront.rs, about 45 non-test lines with no dependencies; check_geo_dependency_contract.sh proves the package set is identical with it on or off. Geo is already off at runtime until with_geo selects it, so the feature is a second off switch. It adds NativeGeoConfigError::UnavailableCloudFront, the cfg-split tests in dev_server.rs and production_hosting.rs, the cfg_attr on the module, and four of the nine new CI steps.

Design section 7 asks for the built-in to be default-off, and NativeGeoSelection::Off already provides that. Consider compiling the built-in under axum and dropping the feature. Non-blocking, since this is a spec-level choice.

"dep:tokio",
"dep:tower",
"dep:futures-util",
"dep:getrandom",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ Duplicate feature entries

axum already lists dep:ipnet and dep:getrandom at lines 27-28 (from the base), so these two additions do nothing. Cargo deduplicates them silently, but the diff reads as if Geo added dependencies. Remove lines 22-23.

///
/// No request, reader IP, service handle or arbitrary headers are available.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeoInput {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🌱 Planned follow-ups will break these public types

GeoInput has public fields and no constructor, and GeoSource/GeoTrustBasis have a single variant each. None are #[non_exhaustive]. #438 (reader-IP input, GeoIP source) and VPC origins (a new trust basis) are already planned, and each will break every provider test that builds GeoInput { .. } and every exhaustive match on these enums. That is allowed at 0.1.0, but worth deciding on purpose. For example: #[non_exhaustive] plus GeoInput::new(country, subdivision) for the input, while keeping GeoStatus/GeoOutcome exhaustive so consumers are forced to handle new statuses.

/// Dispatches with an adapter-owned outcome, independent of public request extensions.
#[doc(hidden)]
#[inline]
pub async fn dispatch_admitted_with_geo(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🌱 Hidden dispatch entry points grow by one per adapter-provided fact

dispatch_admitted -> dispatch_admitted_with_framework_error_detail -> dispatch_admitted_with_geo, each a #[doc(hidden)] pub method. The next adapter-supplied request fact (for example #438's checked reader IP) would add a fourth. A single hidden parameter struct with Default, such as AdmittedDispatch { detail, geo }, would stop the chain.

Comment thread docs/guide/native-geo.md
| `US` and `01` | `Located` with syntax-checked numeric subdivision |
| `US` and `ZZ` | `Located` with a syntax-valid assertion, not verified subdivision membership |
| Subdivision without country | `InvalidAssertion` |
| Unsupported/reserved country, including `XX`, `ZZ`, `UK`, or `XK` | `InvalidAssertion`, no aliases |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🌱 Check what CloudFront sends for Kosovo

AWS documents CloudFront-Viewer-Country only as "ISO 3166-1 alpha-2" and lists no user-assigned codes, so rejecting XK matches the documented contract. MaxMind and CLDR do use XK for Kosovo, though. If CloudFront sends it, every Kosovo viewer becomes InvalidAssertion, indistinguishable from forged input. Suggest adding "observe the country value for a Kosovo client" to this guide's deployment acceptance list.

@dhruv8sh dhruv8sh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Solid Geo work overall. Requesting changes for two questions (marked ❓) that need an answer before merge; the rest are suggestions.

Local gates at 414e52ae with cargo +1.98.1: fmt, clippy, and cargo test --workspace --all-targets all pass (2899 passed, 0 failed).

⛏ Unrelated Box::pin changes — crates/edgezero-adapter-cloudflare/src/request.rs and crates/edgezero-adapter-spin/src/request.rs shrink future size but are unrelated to Geo. Please mention them in the PR description or split them out.

🌱 Country-code snapshot will go stale — crates/edgezero-core/src/geo/country_codes.rs freezes and checksums the ISO list. When ISO assigns a new code, valid traffic will resolve as InvalidAssertion until the table is updated. Worth a follow-up issue tracking the update process.

😃 Praise

  • The Geo outcome is framework-owned and cannot be altered by mutating request extensions or headers, with tests covering it.
  • The credential header is stripped before error renderers and admission run.
  • PeerNetworks is cleanly extracted from trusted_proxy.rs.
  • Assertion values are bounded and redacted in Debug.
  • Invalid selections are rejected at startup before any resources are created.
  • check_geo_dependency_contract.sh verifies geo-cloudfront adds no runtime dependencies.

let bytes = self.buffered_bytes(max, true).await?;
serde_json::from_slice(bytes.as_ref()).map_err(|err| {
if self.body_policy.is_some() {
if self.category_only_json_errors || self.body_policy.is_some() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

❓ Since Axum always dispatches with FrameworkErrorDetail::CategoryOnly (see app.rs dispatch_admitted_with_geo), json_within now returns "invalid JSON payload" without serde detail on all managed Axum dispatch, even with no body policy set. That is a behaviour change unrelated to Geo, with no CHANGELOG entry or docs mention, and only geo_owned_outcome_survives_public_request_mutation covers it. Is this intended? If so, could it move to its own commit with a changelog line and a dedicated test, or into #407 where the JSON-cap work lives?

.dispatch_resolved_outcome(
resolved,
request,
admitted.with_request_context(geo, detail == FrameworkErrorDetail::CategoryOnly),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

❓ This is where the category-only JSON error flag gets wired in for every Axum dispatch; see the comment on context.rs json_within.

};
let status = match policy.admit(peer, headers) {
None => GeoStatus::Unavailable(GeoUnavailableReason::UntrustedIngress),
Some(admitted) => match provider.resolve(&admitted.input()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤔 This runs application-supplied provider code on the request path with no catch_unwind, while GeoError::ProviderFailure exists but is never produced by the framework. Consider wrapping the call so a panic maps to GeoStatus::Error(ProviderFailure), or documenting that a provider panic aborts the connection.

}
}

pub(crate) fn capability(&self) -> NativeGeoCapability {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

❓ A Supplied provider always reports GeoSource::CloudFront / DeclaredProtectedAlb and only ever sees the CloudFront headers. Is the v1 meaning of "Supplied" "a custom parser over the CloudFront source only"? If so, please say that explicitly in the public docs for NativeGeoSelection::Supplied.

.await;
}
};
let geo = self.geo.resolve(remote_addr, &parts.headers);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤔 When Geo is selected the credential header is stripped, but cloudfront-viewer-country / -region remain in ctx.headers(), including for requests from non-admitted peers. A handler reading them directly instead of ctx.geo() can be spoofed. Consider stripping them when the peer is not admitted, or adding a warning to docs/guide/native-geo.md.

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR Review

Summary

Reviewed at 3a0e5eb4. The portable Geo outcome and socket-only peer admission hold up well. Requesting changes for one new question: the credential header ordering from add18fc3 contradicts the Geo guide and the design spec. The earlier CI-guard finding is also still open.

😃 Praise

  • I couldn't find any way to forge the Geo outcome. No setter, extension, state or header path replaces it, and the core and contract tests check this for every adapter.
  • Invalid Geo selections are rejected in run_production_selected before any listener, runtime or store is created.
  • Peer admission uses only the owned socket peer, and IPv4-mapped peers and networks are normalized correctly. /0 and whole-family unions are rejected.

Findings

Blocking

  • ❓ Credential header is visible to the received-head preparer before it is stripped (crates/edgezero-adapter-axum/src/service.rs:313). See the inline comment.
  • 🔧 Still open: the Geo CI steps in .github/workflows/test.yml still lack the matrix.suite == 'workspace' guard. See the earlier comment on line 189.

Non-blocking

  • 🤔 No test shows that forwarding trust can't admit Geo.
    • docs/guide/native-geo.md says that trusted-proxy configuration and forwarding headers can't create Geo source admission. The code is correct: ProtectedCloudFrontAlb::admit only sees the socket peer.
    • However, every Geo unit and hosting test runs with TrustedProxyPolicy::no_trust and sends no X-Forwarded-For/Forwarded. An implementation that admitted on the effective client IP would pass the whole suite.
    • Suggested test: trust 127.0.0.1 as a proxy, set the Geo policy to 192.0.2.1/32, send X-Forwarded-For: 192.0.2.1, and expect UntrustedIngress.
  • ⛏ The reserved credential names don't match their doc comment (crates/edgezero-adapter-axum/src/geo.rs:144). See the inline comment.
  • ⛏ Nothing pins the detailed JSON error on the Default path (crates/edgezero-core/src/app.rs:337). See the inline comment.
  • 📝 The #[non_exhaustive] follow-up covers more types than reported. In addition to GeoInput/GeoSource/GeoTrustBasis, these public types are also exhaustive: GeoOutcome, GeoStatus, GeoResolution, GeoError, GeoValueError and GeoUnavailableReason.
  • ⛏ The PR description is stale again. It names 414e52ae as the current head; the head is now 3a0e5eb4. The earlier duplicate dep:ipnet/dep:getrandom nit in Cargo.toml also remains.

CI Status

These checks ran locally at 3a0e5eb4 with Rust 1.98.1:

  • fmt: PASS
  • clippy (--workspace --all-targets --all-features -D warnings): PASS
  • tests (--workspace --all-targets): PASS, 2902 passed and 0 failed
  • Hosted checks: all green except cache-restore-seed. That job fails the same way on the base bb56cfe1 (a GHA cache write error), so it isn't caused by this PR.

The Geo feature-matrix commands, the WASM runtime tests and the dependency script were not rerun locally.

let received =
validate_normalized_ingress_parts_with_summary(parts, self.app.ingress_head_limits());
if received.is_ok() {
self.app

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

❓ Credential header is visible to application code before it is stripped

sanitize_received_head calls prepare_received_head(&parts.headers, ..), and only afterwards calls self.geo.strip_credential(..). The preparer is the application-supplied closure registered with RouterBuilder::received_head_preparer. In protected-ALB mode it therefore receives the origin credential header and its value.

This contradicts the documented guarantee:

  • docs/guide/native-geo.md:253: "strips every occurrence before application callbacks".
  • docs/guide/native-geo.md:303: "without exposing name or value to application code".
  • The design spec, line 118: "Remove every occurrence of the configured credential header before application admission callbacks, request preparers or middleware run".

The router.rs rustdoc says the opposite ("before stripping forwarding fields or provider credentials"), so this looks deliberate in add18fc3. Which contract is intended?

Scenario: an app registers a preparer that logs or clones received headers for auditing. The origin-verify secret then ends up in the application's logs or extensions.

Fix: if the preparer has no need to see the credential, strip it first and leave forwarding-field stripping where it is:

self.geo.strip_credential(&mut parts.headers);
let received =
    validate_normalized_ingress_parts_with_summary(parts, self.app.ingress_head_limits());
if received.is_ok() {
    self.app
        .prepare_received_head(&parts.headers, &mut parts.extensions);
}
received

Moving the strip before validation also keeps the credential out of the error path. Please check that the original header bytes still count toward the head limits, as the PR intends.

Otherwise, update the guide and the spec to say that preparers see the credential and must not keep it. Either way, add a test that sets a preparer together with a Geo selection. No current test covers that ordering.

/// Validate a nonempty bounded literal peer allowlist and dedicated header name.
///
/// Names must be literal HTTP tokens of at most 128 ASCII bytes beginning with
/// `x-`. Forwarding, AWS, `EdgeZero`, real-IP and correlation names are reserved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ The doc comment says EdgeZero names are reserved, but the code only rejects the x-edge- prefix

x-edgezero-* doesn't start with x-edge- (the next character is z, not -). So a name like x-edgezero-proxy, which edgezero-core/src/outbound.rs:80 uses, is accepted as a credential name and would then be stripped from inbound requests. Either add x-edgezero- to the reserved prefixes or reword the comment.

.dispatch_resolved_outcome(
resolved,
request,
admitted.with_request_context(geo, detail == FrameworkErrorDetail::CategoryOnly),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ Nothing pins the detailed JSON error message on the Default path

The redaction flag is only set for CategoryOnly (Axum). Fastly, Cloudflare and Spin go through dispatch_admitted/Default and should keep invalid JSON payload: <serde detail>.

No test asserts that suffix. The existing assertion around context.rs:2211 uses .contains("invalid JSON payload"), which also matches the redacted message. An inverted or always-true flag would silently remove error detail on all three WASM adapters with the suite still green.

The code is correct today; this is a coverage gap. One Default-path assertion that the message keeps the ": " detail would close it.

This branch has not been deployed

No deployments
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.

Axum: add protected CloudFront Geo metadata provider

4 participants