From f3e6c3cf87a97c32aec781cb96edea65fbbf124e Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Tue, 25 Aug 2026 02:22:44 +0200 Subject: [PATCH] fix(resolver): filter payload in its own scope --- CHANGELOG.md | 15 +++++ .../dpp-resolver/src/handlers/resolve_json.rs | 11 +++- crates/dpp-resolver/tests/resolver_e2e.rs | 57 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e44ea88..76881fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,21 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): ## [Unreleased] +### Fixed + +- **A restricted product-group field could have reached the public view.** The + resolver filters a passport in two passes — the envelope, then + `productGroupData` handed on as its own root document. The core filter now + scopes a product group's disclosure classes to that payload, which means the + second pass has to declare that its root is *already inside* the product group. + Filtering it as an envelope applies none of that product group's classes and + serves every restricted field in it. + + That failure compiles, returns 200, and produces a body that looks right unless + you know which field should be missing, so nothing in the suite would have + caught it. There is now a test asserting an Annex XIII point 2 field is absent + from the public response bytes — confirmed to fail when the scope is wrong. + ### Added - **The create request is one type, not two kept in step by a comment.** diff --git a/crates/dpp-resolver/src/handlers/resolve_json.rs b/crates/dpp-resolver/src/handlers/resolve_json.rs index 097e3ba..f4f2c55 100644 --- a/crates/dpp-resolver/src/handlers/resolve_json.rs +++ b/crates/dpp-resolver/src/handlers/resolve_json.rs @@ -9,7 +9,9 @@ use dpp_common::http_problem; use serde_json::Value; use dpp_domain::Audience; -use dpp_domain::access::{ProductGroupAccessPolicy, filter_by_audience}; +use dpp_domain::access::{ + DocumentScope, ProductGroupAccessPolicy, filter_by_audience, filter_by_audience_in_scope, +}; use crate::{infra::did, state::AppState}; @@ -136,7 +138,12 @@ fn apply_access_tier_filter(passport: Value, tier: Audience) -> Value { { let product_group_policy = detect_product_group_policy(&sd, &schema_version); if let Some(policy) = product_group_policy { - let inner = filter_by_audience(&sd, &policy, tier); + // The sub-object was removed from the envelope above, so it is now + // its own root document — and its root is already inside the + // product group. Filtering it as an envelope would apply none of + // this product group's classes and serve every restricted field. + let inner = + filter_by_audience_in_scope(&sd, &policy, tier, DocumentScope::ProductGroupData); obj.insert("productGroupData".into(), inner.filtered_data); } else if is_tagged_unknown_product_group(&sd) { // Fail closed (RT2-1 / RT2-5): the sub-object carries a `product_group` diff --git a/crates/dpp-resolver/tests/resolver_e2e.rs b/crates/dpp-resolver/tests/resolver_e2e.rs index b22c7b5..383963b 100644 --- a/crates/dpp-resolver/tests/resolver_e2e.rs +++ b/crates/dpp-resolver/tests/resolver_e2e.rs @@ -740,3 +740,60 @@ async fn scan_telemetry_counts_terminal_views_and_qr_separately() { assert_eq!(batch.qr_renders[0].count, 1); assert_eq!(batch.qr_renders[0].dpp_id, id); } + +/// A restricted product-group field must not reach the public JSON view. +/// +/// The resolver filters a passport in two passes: the envelope with the +/// passport-level policy, then `productGroupData` — removed from the envelope and +/// handed on as its own root document — with the product group's policy. The +/// second pass has to say that its root is *already inside* the product group, +/// because a payload filtered as an envelope has none of that product group's +/// classes applied and every restricted field in it is served. +/// +/// Nothing caught that before this test: the wrong scope compiles, returns 200, +/// and produces a body that looks right unless you know which field should be +/// missing. `cathodeMaterial` is Annex XIII point 2 — withheld from the public — +/// so its presence here is the leak, in the response bytes. +#[tokio::test] +async fn a_restricted_product_group_field_is_absent_from_the_public_view() { + let mut passport = sample_battery_passport(); + passport["schemaVersion"] = json!("2.6.0"); + passport["productGroupData"]["cathodeMaterial"] = json!("LiFePO4 cathode, 12 kg"); + + let vault = { + let p = passport.clone(); + Router::new().route( + "/public/dpp/{id}", + get(move || { + let pp = p.clone(); + async move { axum::Json(pp) } + }), + ) + }; + let port = start_mock_vault(vault).await; + let app = router::build(test_state(format!("http://127.0.0.1:{port}"))); + + let req = Request::builder() + .uri("/dpp/00000000-0000-4000-9000-000000000002") + .header("accept", "application/ld+json") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let body = String::from_utf8(body.to_vec()).expect("utf-8"); + + assert!( + !body.contains("cathodeMaterial"), + "Annex XIII point 2 content reached the public view: {body}" + ); + // The public half of the same payload is still served — this is a scoping + // test, not an argument for redacting everything. + assert!( + body.contains("batteryChemistry"), + "point 1 content must survive: {body}" + ); +}