fix: validate RFC 9457 URI references
This commit is contained in:
Generated
+1
@@ -1518,6 +1518,7 @@ dependencies = [
|
|||||||
name = "error-contract"
|
name = "error-contract"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"iri-string",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ typescript = ["dep:ts-rs", "json-schema"]
|
|||||||
test-support = []
|
test-support = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
iri-string = { version = "0.7.12", default-features = false }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ pub trait ApiBoundary<D> {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct ProblemDetails<D> {
|
pub struct ProblemDetails<D> {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
#[cfg_attr(feature = "json-schema", schemars(length(max = 2048)))]
|
||||||
pub type_uri: String,
|
pub type_uri: String,
|
||||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -94,7 +94,7 @@ pub struct ProblemDetails<D> {
|
|||||||
pub status: u16,
|
pub status: u16,
|
||||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
||||||
pub detail: String,
|
pub detail: String,
|
||||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
#[cfg_attr(feature = "json-schema", schemars(length(max = 2048)))]
|
||||||
pub instance: String,
|
pub instance: String,
|
||||||
pub yoi_failure: FailureEnvelope<D>,
|
pub yoi_failure: FailureEnvelope<D>,
|
||||||
}
|
}
|
||||||
@@ -122,19 +122,16 @@ impl ProblemResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_uri(value: &str) -> Result<(), ContractError> {
|
fn validate_uri(value: &str) -> Result<(), ContractError> {
|
||||||
validate_explanation(value)?;
|
if value.len() > MAX_EXPLANATION_BYTES {
|
||||||
if !value.is_ascii()
|
|
||||||
|| value.bytes().any(|byte| {
|
|
||||||
byte <= b' ' || byte == b'"' || byte == b'<' || byte == b'>' || byte == b'\\'
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return Err(ContractError::Invalid);
|
return Err(ContractError::Invalid);
|
||||||
}
|
}
|
||||||
// Accept absolute URI and RFC URI-reference instances, not arbitrary prose.
|
// Reuse the RFC 3986 validator already in the transport dependency graph, without its
|
||||||
if !(value.contains(':') || value.starts_with('/') || value.starts_with('#')) {
|
// optional features. Unlike WHATWG URL parsing, borrowed UriReferenceStr validation
|
||||||
return Err(ContractError::Invalid);
|
// neither resolves a base URI nor normalizes malformed input into different text.
|
||||||
}
|
// Empty references, relative paths, queries and fragments are valid URI-references.
|
||||||
Ok(())
|
iri_string::types::UriReferenceStr::new(value)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|_| ContractError::Invalid)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_problem_details<D: WireDetails>(
|
pub fn to_problem_details<D: WireDetails>(
|
||||||
|
|||||||
@@ -1395,3 +1395,171 @@ fn schemas_derive_typed_details_and_shared_context_bounds_from_the_same_wire_typ
|
|||||||
);
|
);
|
||||||
assert!(workdir["$defs"]["WorkdirInUse"]["properties"]["operation"].is_object());
|
assert!(workdir["$defs"]["WorkdirInUse"]["properties"]["operation"].is_object());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct UriBoundary(&'static str);
|
||||||
|
impl problem::ApiBoundary<SubjectDenied> for UriBoundary {
|
||||||
|
fn classify(&self, _: &SubjectDenied) -> problem::ProblemClass {
|
||||||
|
problem::ProblemClass {
|
||||||
|
status: problem::HttpFailureStatus::Conflict,
|
||||||
|
type_uri: self.0,
|
||||||
|
title: "Operation conflict",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rfc9457_uri_references_accept_relative_absolute_and_component_syntax_in_both_directions() {
|
||||||
|
let projected = browser_subject();
|
||||||
|
// RFC 3986 section 5.4 references, plus authorities and escaped delimiters. Syntax only:
|
||||||
|
// no base resolution, normalization, DNS, HTTP scheme restriction or implicit execution.
|
||||||
|
for reference in [
|
||||||
|
"../requests/1",
|
||||||
|
"?occurrence=1",
|
||||||
|
"",
|
||||||
|
"g:h",
|
||||||
|
"g",
|
||||||
|
"./g",
|
||||||
|
"g/",
|
||||||
|
"/g",
|
||||||
|
"//g",
|
||||||
|
"?y",
|
||||||
|
"g?y",
|
||||||
|
"#s",
|
||||||
|
"g#s",
|
||||||
|
"g?y#s",
|
||||||
|
";x",
|
||||||
|
"g;x",
|
||||||
|
"g;x?y#s",
|
||||||
|
".",
|
||||||
|
"./",
|
||||||
|
"..",
|
||||||
|
"../",
|
||||||
|
"../g",
|
||||||
|
"../..",
|
||||||
|
"../../",
|
||||||
|
"../../g",
|
||||||
|
"a/b:c",
|
||||||
|
"a//b",
|
||||||
|
"///path",
|
||||||
|
"?",
|
||||||
|
"#",
|
||||||
|
"urn:yoi:occurrence:one",
|
||||||
|
"mailto:user@example.com",
|
||||||
|
"https://user:pass@example.test:443/a?b#c",
|
||||||
|
"//[2001:db8::1]:443/a",
|
||||||
|
"//[::ffff:192.0.2.128]/",
|
||||||
|
"//[v1.a:b]/x",
|
||||||
|
"//host:/",
|
||||||
|
"a%20b/%5B?x=%25&y=?/#%2f",
|
||||||
|
"http://host/path??query#fragment?with/slash",
|
||||||
|
] {
|
||||||
|
let boundary = UriBoundary(reference);
|
||||||
|
let response = problem::to_problem_details(&projected, &boundary, reference, budget())
|
||||||
|
.unwrap_or_else(|error| panic!("valid reference {reference:?}: {error}"));
|
||||||
|
let wire: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
assert_eq!(wire["type"], reference);
|
||||||
|
assert_eq!(
|
||||||
|
wire["instance"], reference,
|
||||||
|
"syntax validation must not normalize the reference"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
problem::decode_problem_details::<SubjectDenied>(
|
||||||
|
response.body(),
|
||||||
|
response.status,
|
||||||
|
response.content_type,
|
||||||
|
&boundary,
|
||||||
|
budget()
|
||||||
|
),
|
||||||
|
DecodeFailure::Known(_)
|
||||||
|
),
|
||||||
|
"{reference:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let exact = format!("/{}", "a".repeat(MAX_EXPLANATION_BYTES - 1));
|
||||||
|
assert!(problem::to_problem_details(&projected, &WorkerApi, &exact, budget()).is_ok());
|
||||||
|
assert!(problem::to_problem_details(&projected, &WorkerApi, &(exact + "a"), budget()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rfc9457_uri_references_reject_malformed_components_in_both_directions() {
|
||||||
|
let projected = browser_subject();
|
||||||
|
let response =
|
||||||
|
problem::to_problem_details(&projected, &WorkerApi, "urn:yoi:occurrence:test", budget())
|
||||||
|
.unwrap();
|
||||||
|
let original: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
for reference in [
|
||||||
|
"/requests/%GG",
|
||||||
|
"/requests/[invalid",
|
||||||
|
"foo%",
|
||||||
|
"foo%2",
|
||||||
|
"?%GG",
|
||||||
|
"#%GG",
|
||||||
|
"//user@@host/x",
|
||||||
|
"//host:wrongport/x",
|
||||||
|
"//[::not-ipv6]/",
|
||||||
|
"//[v.a]/",
|
||||||
|
"//[vG.foo]/",
|
||||||
|
"//[v1.]/",
|
||||||
|
"//[::1]tail/",
|
||||||
|
"//[::1",
|
||||||
|
"//host]/",
|
||||||
|
"bad space",
|
||||||
|
"1invalid:rest",
|
||||||
|
":rest",
|
||||||
|
"a{b",
|
||||||
|
"a#b#c",
|
||||||
|
"a\\b",
|
||||||
|
"日本語",
|
||||||
|
"/a\n",
|
||||||
|
"?x=\0",
|
||||||
|
] {
|
||||||
|
let invalid_class = UriBoundary(reference);
|
||||||
|
assert!(
|
||||||
|
problem::to_problem_details(
|
||||||
|
&projected,
|
||||||
|
&invalid_class,
|
||||||
|
"urn:yoi:occurrence:test",
|
||||||
|
budget()
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"type {reference:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
problem::to_problem_details(&projected, &WorkerApi, reference, budget()).is_err(),
|
||||||
|
"instance {reference:?}"
|
||||||
|
);
|
||||||
|
let mut instance_wire = original.clone();
|
||||||
|
instance_wire["instance"] = json!(reference);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
problem::decode_problem_details::<SubjectDenied>(
|
||||||
|
&serde_json::to_vec(&instance_wire).unwrap(),
|
||||||
|
409,
|
||||||
|
problem::PROBLEM_CONTENT_TYPE,
|
||||||
|
&WorkerApi,
|
||||||
|
budget()
|
||||||
|
),
|
||||||
|
DecodeFailure::Corrupt(_)
|
||||||
|
),
|
||||||
|
"received instance {reference:?}"
|
||||||
|
);
|
||||||
|
let mut type_wire = original.clone();
|
||||||
|
type_wire["type"] = json!(reference);
|
||||||
|
// Use a matching invalid mapping too, so a classification mismatch cannot mask a
|
||||||
|
// syntax-validation bypass on receive.
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
problem::decode_problem_details::<SubjectDenied>(
|
||||||
|
&serde_json::to_vec(&type_wire).unwrap(),
|
||||||
|
409,
|
||||||
|
problem::PROBLEM_CONTENT_TYPE,
|
||||||
|
&invalid_class,
|
||||||
|
budget()
|
||||||
|
),
|
||||||
|
DecodeFailure::Corrupt(_)
|
||||||
|
),
|
||||||
|
"received type {reference:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
},
|
},
|
||||||
"instance": {
|
"instance": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 2048,
|
"maxLength": 2048
|
||||||
"minLength": 1
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -27,8 +26,7 @@
|
|||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 2048,
|
"maxLength": 2048
|
||||||
"minLength": 1
|
|
||||||
},
|
},
|
||||||
"yoi_failure": {
|
"yoi_failure": {
|
||||||
"$ref": "#/$defs/FailureEnvelope"
|
"$ref": "#/$defs/FailureEnvelope"
|
||||||
|
|||||||
Reference in New Issue
Block a user