Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
11 changes: 9 additions & 2 deletions crates/dpp-resolver/src/handlers/resolve_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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`
Expand Down
57 changes: 57 additions & 0 deletions crates/dpp-resolver/tests/resolver_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
Loading