fix: validate RFC 9457 URI references

This commit is contained in:
2026-11-01 06:15:36 +09:00
parent eb05300a32
commit 5dfc50f293
5 changed files with 182 additions and 17 deletions
Generated
+1
View File
@@ -1518,6 +1518,7 @@ dependencies = [
name = "error-contract"
version = "0.1.0"
dependencies = [
"iri-string",
"schemars",
"serde",
"serde_json",
+1
View File
@@ -11,6 +11,7 @@ typescript = ["dep:ts-rs", "json-schema"]
test-support = []
[dependencies]
iri-string = { version = "0.7.12", default-features = false }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
+10 -13
View File
@@ -86,7 +86,7 @@ pub trait ApiBoundary<D> {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct ProblemDetails<D> {
#[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,
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
pub title: String,
@@ -94,7 +94,7 @@ pub struct ProblemDetails<D> {
pub status: u16,
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
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 yoi_failure: FailureEnvelope<D>,
}
@@ -122,19 +122,16 @@ impl ProblemResponse {
}
fn validate_uri(value: &str) -> Result<(), ContractError> {
validate_explanation(value)?;
if !value.is_ascii()
|| value.bytes().any(|byte| {
byte <= b' ' || byte == b'"' || byte == b'<' || byte == b'>' || byte == b'\\'
})
{
if value.len() > MAX_EXPLANATION_BYTES {
return Err(ContractError::Invalid);
}
// Accept absolute URI and RFC URI-reference instances, not arbitrary prose.
if !(value.contains(':') || value.starts_with('/') || value.starts_with('#')) {
return Err(ContractError::Invalid);
}
Ok(())
// Reuse the RFC 3986 validator already in the transport dependency graph, without its
// optional features. Unlike WHATWG URL parsing, borrowed UriReferenceStr validation
// neither resolves a base URI nor normalizes malformed input into different text.
// Empty references, relative paths, queries and fragments are valid URI-references.
iri_string::types::UriReferenceStr::new(value)
.map(|_| ())
.map_err(|_| ContractError::Invalid)
}
pub fn to_problem_details<D: WireDetails>(
+168
View File
@@ -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());
}
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:?}"
);
}
}
+2 -4
View File
@@ -11,8 +11,7 @@
},
"instance": {
"type": "string",
"maxLength": 2048,
"minLength": 1
"maxLength": 2048
},
"status": {
"type": "integer",
@@ -27,8 +26,7 @@
},
"type": {
"type": "string",
"maxLength": 2048,
"minLength": 1
"maxLength": 2048
},
"yoi_failure": {
"$ref": "#/$defs/FailureEnvelope"