1use std::fmt;
8
9use serde::Serialize;
10use sha2::{Digest, Sha256};
11use url::Url;
12
13use crate::openapi_review::{
14 OpenApiDocument, OpenApiHttpMethod, OpenApiMediaFamily, OpenApiOperation, OpenApiOperationId,
15 OpenApiParameterLocation, OpenApiResponseStatus, OpenApiServerKind,
16};
17
18pub const REST_REVIEW_SELECTION_ALGORITHM: &str = "security.openapi-rest-readonly-selection/v1";
20pub const MAX_REST_REVIEW_OPERATIONS: usize = 1;
22pub const MAX_REST_REVIEW_TARGET_BYTES: usize = 8 * 1024;
24
25const REST_TARGET_IDENTITY_DOMAIN: &[u8] = b"security.openapi-rest-readonly-target.v1\0";
26
27#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum RestDocumentedResponseClass {
32 JsonCompatible,
34 Unknown,
36}
37
38#[derive(Clone, Eq, PartialEq)]
40#[non_exhaustive]
41pub enum RestOperationSelectionOutcome {
42 Selected(RestOperationSelection),
44 NoEligibleOperation,
46}
47
48impl fmt::Debug for RestOperationSelectionOutcome {
49 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50 match self {
51 Self::Selected(selection) => {
52 formatter.debug_tuple("Selected").field(selection).finish()
53 },
54 Self::NoEligibleOperation => formatter.write_str("NoEligibleOperation"),
55 }
56 }
57}
58
59#[derive(Clone, Eq, PartialEq)]
65pub struct RestOperationSelection {
66 operation_id: OpenApiOperationId,
67 execution_url: Url,
68 target_identity: String,
69 documented_response: RestDocumentedResponseClass,
70 deprecated: bool,
71 eligible_operation_count: u32,
72}
73
74impl RestOperationSelection {
75 pub const fn operation_id(&self) -> &OpenApiOperationId {
77 &self.operation_id
78 }
79
80 pub const fn documented_response(&self) -> RestDocumentedResponseClass {
82 self.documented_response
83 }
84
85 pub const fn deprecated(&self) -> bool {
87 self.deprecated
88 }
89
90 pub const fn eligible_operation_count(&self) -> u32 {
92 self.eligible_operation_count
93 }
94
95 pub fn target_identity(&self) -> &str {
97 &self.target_identity
98 }
99
100 pub(crate) const fn execution_url(&self) -> &Url {
102 &self.execution_url
103 }
104}
105
106impl fmt::Debug for RestOperationSelection {
107 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108 formatter
109 .debug_struct("RestOperationSelection")
110 .field("operation_id", &self.operation_id)
111 .field("execution_url", &"<exact-origin-redacted>")
112 .field("target_identity", &self.target_identity)
113 .field("documented_response", &self.documented_response)
114 .field("deprecated", &self.deprecated)
115 .field("eligible_operation_count", &self.eligible_operation_count)
116 .finish()
117 }
118}
119
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121enum RestOperationIneligibility {
122 Method,
123 RequiredParameter(OpenApiParameterLocation),
124 RequestBody,
125 Authentication,
126 Server,
127 ResponseMedia,
128 Target,
129}
130
131#[derive(Clone)]
132struct EligibleOperation {
133 selection: RestOperationSelection,
134 server_rank: u8,
135 path_bytes: usize,
136}
137
138pub fn select_rest_operation(
141 document: &OpenApiDocument,
142 document_url: &Url,
143) -> RestOperationSelectionOutcome {
144 let mut eligible = document
145 .catalog()
146 .operations()
147 .iter()
148 .filter_map(|operation| eligible_operation(document_url, operation).ok())
149 .collect::<Vec<_>>();
150 let eligible_operation_count = u32::try_from(eligible.len()).unwrap_or(u32::MAX);
151 eligible.sort_by(|left, right| {
152 left.server_rank
153 .cmp(&right.server_rank)
154 .then(
155 left.selection
156 .documented_response
157 .cmp(&right.selection.documented_response),
158 )
159 .then(left.selection.deprecated.cmp(&right.selection.deprecated))
160 .then(left.path_bytes.cmp(&right.path_bytes))
161 .then(
162 left.selection
163 .operation_id
164 .cmp(&right.selection.operation_id),
165 )
166 });
167 match eligible.into_iter().next() {
168 Some(mut selected) => {
169 selected.selection.eligible_operation_count = eligible_operation_count;
170 RestOperationSelectionOutcome::Selected(selected.selection)
171 },
172 None => RestOperationSelectionOutcome::NoEligibleOperation,
173 }
174}
175
176fn eligible_operation(
177 document_url: &Url,
178 operation: &OpenApiOperation,
179) -> Result<EligibleOperation, RestOperationIneligibility> {
180 if operation.method() != OpenApiHttpMethod::Get {
181 return Err(RestOperationIneligibility::Method);
182 }
183 if let Some(parameter) = operation
184 .parameters()
185 .iter()
186 .find(|parameter| parameter.required())
187 {
188 return Err(RestOperationIneligibility::RequiredParameter(
189 parameter.location(),
190 ));
191 }
192 if operation.request_body_declared() {
193 return Err(RestOperationIneligibility::RequestBody);
194 }
195 if !operation.security().permits_anonymous() {
196 return Err(RestOperationIneligibility::Authentication);
197 }
198 let (execution_url, server_rank) = resolve_operation_target(document_url, operation)?;
199 let documented_response = documented_response_class(operation)?;
200 let target_identity = rest_target_identity(operation.id(), &execution_url);
201 Ok(EligibleOperation {
202 selection: RestOperationSelection {
203 operation_id: operation.id().clone(),
204 execution_url,
205 target_identity,
206 documented_response,
207 deprecated: operation.deprecated(),
208 eligible_operation_count: 0,
209 },
210 server_rank,
211 path_bytes: operation.path().len(),
212 })
213}
214
215fn documented_response_class(
216 operation: &OpenApiOperation,
217) -> Result<RestDocumentedResponseClass, RestOperationIneligibility> {
218 let relevant = operation
219 .responses()
220 .iter()
221 .filter(|response| {
222 matches!(
223 response.status(),
224 OpenApiResponseStatus::Exact(200..=299) | OpenApiResponseStatus::Range(2)
225 )
226 })
227 .collect::<Vec<_>>();
228 if relevant.iter().any(|response| {
229 response.media_families().iter().any(|family| {
230 matches!(
231 family,
232 OpenApiMediaFamily::Json | OpenApiMediaFamily::JsonSuffix
233 )
234 })
235 }) {
236 return Ok(RestDocumentedResponseClass::JsonCompatible);
237 }
238 if relevant.is_empty()
239 || relevant
240 .iter()
241 .any(|response| response.media_families().is_empty())
242 {
243 return Ok(RestDocumentedResponseClass::Unknown);
244 }
245 Err(RestOperationIneligibility::ResponseMedia)
246}
247
248fn resolve_operation_target(
249 document_url: &Url,
250 operation: &OpenApiOperation,
251) -> Result<(Url, u8), RestOperationIneligibility> {
252 if !document_url_has_safe_origin(document_url)
253 || operation.path().contains(['{', '}'])
254 || operation.servers().len() > 1
255 {
256 return Err(RestOperationIneligibility::Server);
257 }
258 let (mut base, server_rank) = match operation.servers() {
259 [] => {
260 let mut root = document_url.clone();
261 root.set_path("/");
262 root.set_query(None);
263 root.set_fragment(None);
264 (root, 0)
265 },
266 [server]
267 if matches!(server.kind(), OpenApiServerKind::ExactOrigin)
268 && server.execution_base().is_some() =>
269 {
270 (
271 server
272 .execution_base()
273 .expect("guarded server base")
274 .clone(),
275 0,
276 )
277 },
278 [server]
279 if matches!(server.kind(), OpenApiServerKind::Relative)
280 && server.execution_base().is_some() =>
281 {
282 (
283 server
284 .execution_base()
285 .expect("guarded server base")
286 .clone(),
287 1,
288 )
289 },
290 [_] => return Err(RestOperationIneligibility::Server),
291 _ => unreachable!("server count was bounded above"),
292 };
293 let base_path = base.path().trim_end_matches('/');
294 let path = if base_path.is_empty() {
295 operation.path().to_owned()
296 } else {
297 format!("{base_path}{}", operation.path())
298 };
299 if !safe_execution_path(&path) {
300 return Err(RestOperationIneligibility::Target);
301 }
302 base.set_path(&path);
303 base.set_query(None);
304 base.set_fragment(None);
305 if base.as_str().len() > MAX_REST_REVIEW_TARGET_BYTES
306 || !same_origin(document_url, &base)
307 || !document_url_has_safe_origin(&base)
308 {
309 return Err(RestOperationIneligibility::Target);
310 }
311 Ok((base, server_rank))
312}
313
314fn document_url_has_safe_origin(url: &Url) -> bool {
315 matches!(url.scheme(), "http" | "https")
316 && url.username().is_empty()
317 && url.password().is_none()
318 && url.query().is_none()
319 && url.fragment().is_none()
320}
321
322fn safe_execution_path(path: &str) -> bool {
323 path.starts_with('/')
324 && !path.contains(['?', '#', '%', '\\', '\r', '\n', '\0'])
325 && !path.contains("//")
326 && !path.split('/').any(|segment| matches!(segment, "." | ".."))
327}
328
329fn same_origin(left: &Url, right: &Url) -> bool {
330 left.scheme() == right.scheme()
331 && left.host_str() == right.host_str()
332 && left.port_or_known_default() == right.port_or_known_default()
333}
334
335fn rest_target_identity(operation: &OpenApiOperationId, target: &Url) -> String {
336 let mut digest = Sha256::new();
337 update_framed(&mut digest, REST_TARGET_IDENTITY_DOMAIN);
338 update_framed(&mut digest, REST_REVIEW_SELECTION_ALGORITHM.as_bytes());
339 update_framed(&mut digest, operation.as_str().as_bytes());
340 update_framed(&mut digest, target.as_str().as_bytes());
341 format!("rest-readonly-target-sha256:{:x}", digest.finalize())
342}
343
344fn update_framed(digest: &mut Sha256, value: &[u8]) {
345 digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
346 digest.update(value);
347}
348
349#[cfg(test)]
350mod tests {
351 use serde_json::{json, Value};
352
353 use super::*;
354 use crate::openapi_review::{parse_openapi_document, OpenApiCandidateTag, OpenApiParseOutcome};
355
356 fn document_at(value: Value, document_url: &Url) -> OpenApiDocument {
357 match parse_openapi_document(&serde_json::to_vec(&value).unwrap(), document_url) {
358 OpenApiParseOutcome::Complete(document) => document,
359 outcome => panic!("expected complete OpenAPI document, got {outcome:?}"),
360 }
361 }
362
363 fn operation(path: &str, method: &str, body: Value) -> Value {
364 json!({
365 "openapi": "3.1.0",
366 "info": {"title": "Fixture", "version": "1"},
367 "paths": {(path): {(method): body}}
368 })
369 }
370
371 fn selected(value: Value, document_url: &Url) -> RestOperationSelection {
372 let document = document_at(value, document_url);
373 match select_rest_operation(&document, document_url) {
374 RestOperationSelectionOutcome::Selected(selection) => selection,
375 RestOperationSelectionOutcome::NoEligibleOperation => panic!("expected selection"),
376 }
377 }
378
379 fn assert_none(value: Value, document_url: &Url) {
380 let document = document_at(value, document_url);
381 assert_eq!(
382 select_rest_operation(&document, document_url),
383 RestOperationSelectionOutcome::NoEligibleOperation
384 );
385 }
386
387 #[test]
388 fn selects_one_anonymous_bodyless_get_with_json_response() {
389 let url = Url::parse("https://example.test/docs/openapi.json").unwrap();
390 let selection = selected(
391 operation(
392 "/health",
393 "get",
394 json!({"responses":{"200":{"content":{"application/json":{}}}}}),
395 ),
396 &url,
397 );
398 assert_eq!(
399 selection.execution_url().as_str(),
400 "https://example.test/health"
401 );
402 assert_eq!(selection.eligible_operation_count(), 1);
403 assert_eq!(
404 selection.documented_response(),
405 RestDocumentedResponseClass::JsonCompatible
406 );
407 assert!(!selection.deprecated());
408 assert!(selection
409 .operation_id()
410 .as_str()
411 .starts_with("openapi-operation-sha256:"));
412 assert!(selection
413 .target_identity()
414 .starts_with("rest-readonly-target-sha256:"));
415 }
416
417 #[test]
418 fn every_required_parameter_location_is_ineligible() {
419 let url = Url::parse("https://example.test/openapi.json").unwrap();
420 for (location, path) in [
421 ("path", "/items/{id}"),
422 ("query", "/items"),
423 ("header", "/items"),
424 ("cookie", "/items"),
425 ] {
426 assert_none(
427 operation(
428 path,
429 "get",
430 json!({
431 "parameters":[{"name":"id","in":location,"required":true,"schema":{"type":"string"}}],
432 "responses":{"200":{"content":{"application/json":{}}}}
433 }),
434 ),
435 &url,
436 );
437 }
438 }
439
440 #[test]
441 fn empty_request_body_is_still_declared_and_changes_identity() {
442 let url = Url::parse("https://example.test/openapi.json").unwrap();
443 let without = document_at(
444 operation("/items", "get", json!({"responses":{"200":{}}})),
445 &url,
446 );
447 let with = document_at(
448 operation(
449 "/items",
450 "get",
451 json!({"requestBody":{},"responses":{"200":{}}}),
452 ),
453 &url,
454 );
455 assert!(!without.catalog().operations()[0].request_body_declared());
456 assert!(with.catalog().operations()[0].request_body_declared());
457 assert!(with.catalog().operations()[0]
458 .candidate_tags()
459 .contains(&OpenApiCandidateTag::BodyBearing));
460 assert_ne!(without.semantic_digest(), with.semantic_digest());
461 assert_eq!(
462 select_rest_operation(&with, &url),
463 RestOperationSelectionOutcome::NoEligibleOperation
464 );
465 }
466
467 #[test]
468 fn required_security_is_ineligible_but_explicit_anonymous_is_eligible() {
469 let url = Url::parse("https://example.test/openapi.json").unwrap();
470 for (name, scheme) in [
471 ("bearer", json!({"type":"http","scheme":"bearer"})),
472 ("key", json!({"type":"apiKey","in":"header","name":"X-Key"})),
473 ("oauth", json!({"type":"oauth2","flows":{}})),
474 ] {
475 let value = json!({
476 "openapi":"3.1.0",
477 "info":{"title":"Fixture","version":"1"},
478 "components":{"securitySchemes":{(name):scheme}},
479 "security":[{(name):[]}],
480 "paths":{"/health":{"get":{"responses":{"200":{}}}}}
481 });
482 assert_none(value, &url);
483 }
484 let anonymous = json!({
485 "openapi":"3.1.0",
486 "info":{"title":"Fixture","version":"1"},
487 "security":[{"unknown":[]}],
488 "paths":{"/health":{"get":{"security":[],"responses":{"200":{}}}}}
489 });
490 assert_eq!(selected(anonymous, &url).eligible_operation_count(), 1);
491 }
492
493 #[test]
494 fn unsafe_or_ambiguous_servers_are_ineligible() {
495 let url = Url::parse("https://example.test/docs/openapi.json").unwrap();
496 for servers in [
497 json!([{"url":"https://elsewhere.invalid"}]),
498 json!([{"url":"https://{tenant}.example.test","variables":{"tenant":{}}}]),
499 json!([{"url":"/v1"},{"url":"/v2"}]),
500 json!([{"url":"https://user:secret@example.test/api"}]),
501 json!([{"url":"/api?variant=1"}]),
502 json!([{"url":"/api#fragment"}]),
503 ] {
504 let value = json!({
505 "openapi":"3.1.0",
506 "info":{"title":"Fixture","version":"1"},
507 "servers":servers,
508 "paths":{"/health":{"get":{"responses":{"200":{}}}}}
509 });
510 assert_none(value, &url);
511 }
512 }
513
514 #[test]
515 fn relative_server_base_is_concatenated_without_losing_its_path() {
516 let url = Url::parse("https://example.test/docs/openapi.json").unwrap();
517 let value = json!({
518 "openapi":"3.1.0",
519 "info":{"title":"Fixture","version":"1"},
520 "servers":[{"url":"/api/v1"}],
521 "paths":{"/health":{"get":{"responses":{"200":{}}}}}
522 });
523 let selection = selected(value, &url);
524 assert_eq!(
525 selection.execution_url().as_str(),
526 "https://example.test/api/v1/health"
527 );
528 }
529
530 #[test]
531 fn post_and_known_non_json_response_are_ineligible() {
532 let url = Url::parse("https://example.test/openapi.json").unwrap();
533 assert_none(
534 operation("/items", "post", json!({"responses":{"200":{}}})),
535 &url,
536 );
537 assert_none(
538 operation(
539 "/items",
540 "get",
541 json!({"responses":{"200":{"content":{"text/html":{}}}}}),
542 ),
543 &url,
544 );
545 }
546
547 #[test]
548 fn ranking_prefers_json_then_non_deprecated_then_shortest_path() {
549 let url = Url::parse("https://example.test/openapi.json").unwrap();
550 let value = json!({
551 "openapi":"3.1.0",
552 "info":{"title":"Fixture","version":"1"},
553 "paths":{
554 "/unknown":{"get":{"responses":{"200":{}}}},
555 "/deprecated":{"get":{"deprecated":true,"responses":{"200":{"content":{"application/json":{}}}}}},
556 "/longer/path":{"get":{"responses":{"200":{"content":{"application/json":{}}}}}},
557 "/ok":{"get":{"responses":{"200":{"content":{"application/json":{}}}}}}
558 }
559 });
560 let first = selected(value.clone(), &url);
561 let second = selected(value, &url);
562 assert_eq!(first, second);
563 assert_eq!(first.execution_url().path(), "/ok");
564 assert_eq!(first.eligible_operation_count(), 4);
565 }
566
567 #[test]
568 fn unknown_response_media_is_a_lower_ranked_safe_observation() {
569 let url = Url::parse("https://example.test/openapi.json").unwrap();
570 let selection = selected(
571 operation("/health", "get", json!({"responses":{"200":{}}})),
572 &url,
573 );
574 assert_eq!(
575 selection.documented_response(),
576 RestDocumentedResponseClass::Unknown
577 );
578 }
579
580 #[test]
581 fn default_response_metadata_never_outranks_explicit_json_success() {
582 let url = Url::parse("https://example.test/openapi.json").unwrap();
583 let value = json!({
584 "openapi":"3.1.0",
585 "info":{"title":"Fixture","version":"1"},
586 "paths":{
587 "/default":{"get":{"responses":{"default":{"content":{"application/json":{}}}}}},
588 "/explicit":{"get":{"responses":{"200":{"content":{"application/json":{}}}}}}
589 }
590 });
591 let selection = selected(value, &url);
592 assert_eq!(selection.execution_url().path(), "/explicit");
593 assert_eq!(
594 selection.documented_response(),
595 RestDocumentedResponseClass::JsonCompatible
596 );
597 }
598
599 #[test]
600 fn optional_examples_and_defaults_are_never_materialized_into_the_target() {
601 let url = Url::parse("https://example.test/openapi.json").unwrap();
602 let value = operation(
603 "/items",
604 "get",
605 json!({
606 "parameters":[{
607 "name":"filter",
608 "in":"query",
609 "required":false,
610 "example":"MUST-NOT-BE-MATERIALIZED",
611 "schema":{"type":"string","default":"MUST-NOT-BE-MATERIALIZED"}
612 }],
613 "responses":{"200":{"content":{"application/json":{}}}}
614 }),
615 );
616 let selection = selected(value, &url);
617 assert_eq!(
618 selection.execution_url().as_str(),
619 "https://example.test/items"
620 );
621 assert!(selection.execution_url().query().is_none());
622 assert!(!format!("{selection:?}").contains("MUST-NOT-BE-MATERIALIZED"));
623 }
624
625 #[test]
626 fn debug_redacts_execution_url_and_path() {
627 let url = Url::parse("https://example.test/openapi.json").unwrap();
628 let selection = selected(
629 operation("/private/sentinel", "get", json!({"responses":{"200":{}}})),
630 &url,
631 );
632 let debug = format!("{selection:?}");
633 assert!(!debug.contains("example.test"));
634 assert!(!debug.contains("private"));
635 assert!(!debug.contains("sentinel"));
636
637 let server_document = document_at(
638 json!({
639 "openapi":"3.1.0",
640 "info":{"title":"Fixture","version":"1"},
641 "servers":[{"url":"/private/base"}],
642 "paths":{"/health":{"get":{"responses":{"200":{}}}}}
643 }),
644 &url,
645 );
646 let server_debug = format!("{:?}", server_document.servers()[0]);
647 assert!(!server_debug.contains("/private/base"));
648 assert!(!server_debug.contains("example.test"));
649 }
650
651 #[test]
652 fn execution_path_rejects_every_encoded_or_control_ambiguous_form() {
653 assert!(safe_execution_path("/plain/status"));
654 for path in [
655 "/encoded%2fsegment",
656 "/double%252e%252e/admin",
657 "/control%00byte",
658 "/query?value=1",
659 "/fragment#part",
660 "/dot/../segment",
661 "/double//segment",
662 "/backslash\\segment",
663 ] {
664 assert!(!safe_execution_path(path), "accepted unsafe path {path}");
665 }
666 }
667}