fix: validate HTTPS binding limits and response ordering

This commit is contained in:
2026-09-30 06:43:02 +09:00
parent 56923c1160
commit beeffae9c5
5 changed files with 115 additions and 44 deletions
-23
View File
@@ -397,23 +397,6 @@ fn response_from_json(
Ok(response) Ok(response)
} }
fn unsupported_batch_descriptor_format(value: &Json, request: &BatchRequest) -> Option<String> {
let responses = value.as_object()?.get("responses")?.as_array()?;
for item in responses {
let fields = item.as_object()?;
let id = fields.get("id")?.as_str()?;
let request_item = request.requests.iter().find(|item| item.id == id)?;
if matches!(request_item.request, BatchRequestKind::FetchInterface(_))
&& let Some(result) = fields.get("result")
&& let Ok(format) = json::fetch_interface_response_format(result)
&& format != INTERFACE_FORMAT_V1
{
return Some(format);
}
}
None
}
/// Decodes a batch response, including item-local failures within HTTP 200. /// Decodes a batch response, including item-local failures within HTTP 200.
pub fn decode_batch_response<B: AsRef<[u8]>>( pub fn decode_batch_response<B: AsRef<[u8]>>(
request: &BatchRequest, request: &BatchRequest,
@@ -434,12 +417,6 @@ pub fn decode_batch_response<B: AsRef<[u8]>>(
} }
return Err(response::transport_failure(value.status())); return Err(response::transport_failure(value.status()));
} }
if value.status().is_success()
&& let Ok(json) = json::parse(value.body().as_ref(), BodyKind::Response, limits)
&& let Some(format) = unsupported_batch_descriptor_format(&json, request)
{
return Err(ClientResponseError::UnsupportedDescriptorFormat { format });
}
let decoded = response::decode( let decoded = response::decode(
value.status(), value.status(),
value.body().as_ref(), value.body().as_ref(),
-7
View File
@@ -355,13 +355,6 @@ pub fn decode_fetch_interface_response<B: AsRef<[u8]>>(
limits: Limits, limits: Limits,
) -> Result<DecodedResponse<FetchInterfaceResponse>, ClientResponseError> { ) -> Result<DecodedResponse<FetchInterfaceResponse>, ClientResponseError> {
require_response_content_type(value)?; require_response_content_type(value)?;
if value.status().is_success()
&& let Ok(json) = json::parse(value.body().as_ref(), BodyKind::Response, limits)
&& let Ok(format) = json::fetch_interface_response_format(&json)
&& format != INTERFACE_FORMAT_V1
{
return Err(ClientResponseError::UnsupportedDescriptorFormat { format });
}
let decoded = response::decode( let decoded = response::decode(
value.status(), value.status(),
value.body().as_ref(), value.body().as_ref(),
+26 -14
View File
@@ -8,8 +8,8 @@ use serde_json::{Map, Number, Value as Json};
use wip_protocol::{ use wip_protocol::{
CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration,
InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object, INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER,
ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target,
TypeDeclaration, TypeExpr, UnionCase, Value, TypeDeclaration, TypeExpr, UnionCase, Value,
}; };
@@ -119,6 +119,7 @@ pub(crate) fn serialize(
kind: BodyKind, kind: BodyKind,
limits: Limits, limits: Limits,
) -> Result<Vec<u8>, CodecError> { ) -> Result<Vec<u8>, CodecError> {
check_nesting(value, 0, limits.max_nesting)?;
let body = serde_json::to_vec(value).expect("serde_json::Value serialization cannot fail"); let body = serde_json::to_vec(value).expect("serde_json::Value serialization cannot fail");
let maximum = match kind { let maximum = match kind {
BodyKind::Request => limits.max_request_bytes, BodyKind::Request => limits.max_request_bytes,
@@ -759,23 +760,34 @@ pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse
Json::Object(fields) Json::Object(fields)
} }
pub(crate) fn fetch_interface_response_format(value: &Json) -> Result<String, CodecError> {
let fields = exact_object(value, "response", &["interface", "descriptor", "validator"])?;
string(required(fields, "interface")?, "interface")?;
let descriptor = object(required(fields, "descriptor")?, "descriptor")?;
string(required(descriptor, "format")?, "descriptor.format")
}
pub(crate) fn fetch_interface_response_from_json( pub(crate) fn fetch_interface_response_from_json(
value: &Json, value: &Json,
) -> Result<FetchInterfaceResponse, CodecError> { ) -> Result<FetchInterfaceResponse, CodecError> {
let fields = exact_object(value, "response", &["interface", "descriptor", "validator"])?; let fields = exact_object(value, "response", &["interface", "descriptor", "validator"])?;
Ok(FetchInterfaceResponse { let interface = string(required(fields, "interface")?, "interface")?;
interface: string(required(fields, "interface")?, "interface")?, let validator = optional(fields, "validator")?
descriptor: descriptor_from_json(required(fields, "descriptor")?, "descriptor")?,
validator: optional(fields, "validator")?
.map(|value| base64_decode(value, "validator")) .map(|value| base64_decode(value, "validator"))
.transpose()?, .transpose()?;
let descriptor_value = required(fields, "descriptor")?;
let descriptor_fields = object(descriptor_value, "descriptor")?;
let format = string(required(descriptor_fields, "format")?, "descriptor.format")?;
let descriptor = if format == INTERFACE_FORMAT_V1 {
descriptor_from_json(descriptor_value, "descriptor")?
} else {
// Preserve only the format discriminator. The client classifies this
// response after validating common response fields and request
// correspondence, without interpreting an unknown descriptor schema.
InterfaceDescriptor {
format,
documentation: None,
types: Vec::new(),
operations: Vec::new(),
}
};
Ok(FetchInterfaceResponse {
interface,
descriptor,
validator,
}) })
} }
+49
View File
@@ -165,6 +165,55 @@ fn top_level_batch_errors_use_the_batch_allowed_code_set() {
)); ));
} }
#[test]
fn unsupported_batch_descriptor_waits_for_envelope_correlation() {
let duplicate = wip_https::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
br#"{"responses":[{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future":true}}},{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"}}}]}"#
.to_vec(),
)
.unwrap();
assert!(matches!(
decode_batch_response(&request(), &duplicate, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidBatch,
..
}
));
let missing = wip_https::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(
br#"{"responses":[{"id":"interface","result":{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"}}}]}"#
.to_vec(),
)
.unwrap();
assert!(matches!(
decode_batch_response(&request(), &missing, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidBatch,
..
}
));
let request = BatchRequest {
requests: vec![BatchRequestItem {
id: "interface".into(),
request: BatchRequestKind::FetchInterface(FetchInterfaceRequest {
interface: "interface-v1".into(),
}),
}],
};
assert!(matches!(
decode_batch_response(&request, &missing, limits()).unwrap_err(),
ClientResponseError::UnsupportedDescriptorFormat { format }
if format == "wip-interface/2"
));
}
#[test] #[test]
fn batch_response_must_have_exactly_the_requested_ids() { fn batch_response_must_have_exactly_the_requested_ids() {
let request = request(); let request = request();
+40
View File
@@ -367,6 +367,27 @@ fn caller_limits_bound_bytes_and_nesting() {
let nested = br#"{"path":[[["/"]]]}"#; let nested = br#"{"path":[[["/"]]]}"#;
let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap(); let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap();
assert!(decode_fetch_request(nested, nesting_limited).is_err()); assert!(decode_fetch_request(nested, nesting_limited).is_err());
let outbound_limited = Limits::new(1_000_000, 1_000_000, 1, 10).unwrap();
assert!(
encode_call_operation_request(
&endpoint(),
&call_request(),
&descriptor(),
outbound_limited,
)
.is_err()
);
assert!(
encode_fetch_response(
&FetchRequest {
path: "/item".into(),
},
&object("item", b"validator"),
outbound_limited,
)
.is_err()
);
} }
#[test] #[test]
@@ -400,6 +421,25 @@ fn unsupported_descriptor_format_is_client_local() {
ClientResponseError::UnsupportedDescriptorFormat { format } ClientResponseError::UnsupportedDescriptorFormat { format }
if format == "wip-interface/2" if format == "wip-interface/2"
)); ));
for malformed in [
br#"{"interface":"wrong","descriptor":{"format":"wip-interface/2"}}"#.as_slice(),
br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2"},"validator":null}"#
.as_slice(),
] {
let response = wip_https::http::Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json; charset=utf-8")
.body(malformed.to_vec())
.unwrap();
assert!(matches!(
decode_fetch_interface_response(&request, &response, limits()).unwrap_err(),
ClientResponseError::InvalidResponse {
kind: InvalidResponseKind::InvalidSuccessBody,
..
}
));
}
} }
#[test] #[test]