1use std::{fmt, mem, str::FromStr};
10
11use serde::Deserialize;
12use sha2::{Digest, Sha256};
13use termivar_oast::{CallbackId, CallbackTarget, NativeOastRoute, PublicOrigin};
14use thiserror::Error;
15use url::{Host, Url};
16use zeroize::Zeroize;
17
18use crate::oast::{OastAuthorityEpoch, OastCorrelationToken, OastEventKey};
19#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
20use crate::openapi_review::{
21 OpenApiDocument, OpenApiFormatClass, OpenApiHttpMethod, OpenApiOperation,
22 OpenApiParameterLocation, OpenApiServerKind,
23};
24
25pub const SSRF_OAST_REVIEW_POLICY_SCHEMA: &str = "security.ssrf-oast-review-policy/v1";
27pub const SSRF_OAST_REVIEW_ALGORITHM: &str = "security.ssrf-oast-query-review/v1";
29pub const MAX_SSRF_OAST_REVIEW_POLICY_BYTES: usize = 64 * 1024;
31pub const MAX_SSRF_OAST_REVIEW_RESOURCES: usize = 1;
33pub const MAX_SSRF_OAST_REVIEW_PARAMETERS: usize = 1;
35pub const MAX_SSRF_OAST_REVIEW_FAMILIES: usize = 1;
37pub const SSRF_OAST_TARGET_REQUESTS: usize = 3;
39pub const SSRF_OAST_ACTIVE_VERIFICATIONS: usize = 1;
41pub const MAX_SSRF_OAST_PROVIDER_REQUESTS: usize = 12;
43pub const MIN_SSRF_OAST_POLLS_PER_LEG: u16 = 1;
45pub const MAX_SSRF_OAST_POLLS_PER_LEG: u16 = 4;
47pub const MIN_SSRF_OAST_POLL_INTERVAL_MS: u64 = 250;
49pub const MAX_SSRF_OAST_POLL_INTERVAL_MS: u64 = 2_000;
51pub const MIN_SSRF_OAST_LIFETIME_MS: u64 = 5_000;
53pub const MAX_SSRF_OAST_LIFETIME_MS: u64 = 30_000;
55pub const MIN_SSRF_OAST_ADMIN_TOKEN_BYTES: usize = 32;
57pub const MAX_SSRF_OAST_ADMIN_TOKEN_BYTES: usize = 4_096;
59
60const MAX_TARGET_BYTES: usize = 8 * 1024;
61const MAX_SUBJECT_IDENTITY_BYTES: usize = 512;
62const MAX_PARAMETER_NAME_BYTES: usize = 256;
63const POLICY_ID_DOMAIN: &[u8] = b"security.ssrf-oast-review-policy.identity.v1\0";
64const RESOURCE_ID_DOMAIN: &[u8] = b"security.ssrf-oast-review-resource.v1\0";
65const PARAMETER_ID_DOMAIN: &[u8] = b"security.ssrf-oast-review-parameter.v1\0";
66const SELECTION_ID_DOMAIN: &[u8] = b"security.ssrf-oast-review-selection.v1\0";
67const CONTROL_LABEL_DOMAIN: &[u8] = b"security.ssrf-oast-review-control-label.v1\0";
68const EPOCH_DOMAIN: &[u8] = b"security.ssrf-oast-review-authority-epoch.v1\0";
69const CANDIDATE_TOKEN_DOMAIN: &[u8] = b"security.ssrf-oast-review-candidate-token.v1\0";
70const REPLAY_TOKEN_DOMAIN: &[u8] = b"security.ssrf-oast-review-replay-token.v1\0";
71const CALLBACK_ID_DOMAIN: &[u8] = b"security.ssrf-oast-review-callback-id.v1\0";
72#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
73const OPENAPI_PARAMETER_NAME_DOMAIN: &[u8] = b"openapi-parameter-name/v1";
74
75#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
77pub struct SsrfOastPolicyId([u8; 32]);
78
79impl SsrfOastPolicyId {
80 pub fn to_wire(self) -> String {
82 format!("ssrf-oast-policy-sha256:{}", hex(self.0))
83 }
84
85 pub const fn as_bytes(self) -> [u8; 32] {
87 self.0
88 }
89}
90
91impl fmt::Debug for SsrfOastPolicyId {
92 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93 formatter.write_str(&self.to_wire())
94 }
95}
96
97impl fmt::Display for SsrfOastPolicyId {
98 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99 formatter.write_str(&self.to_wire())
100 }
101}
102
103pub struct SsrfOastReviewPolicy {
105 target_origin: Url,
106 provider_origin: PublicOrigin,
107 polls_per_leg: u16,
108 poll_interval_ms: u64,
109 lifetime_ms: u64,
110 policy_id: SsrfOastPolicyId,
111}
112
113impl SsrfOastReviewPolicy {
114 pub fn parse_toml(
116 assessment_target: &Url,
117 source: &[u8],
118 ) -> Result<Self, SsrfOastReviewPolicyError> {
119 if source.len() > MAX_SSRF_OAST_REVIEW_POLICY_BYTES {
120 return Err(SsrfOastReviewPolicyError::PolicyTooLarge);
121 }
122 let source =
123 std::str::from_utf8(source).map_err(|_| SsrfOastReviewPolicyError::MalformedPolicy)?;
124 let wire: WirePolicy =
125 toml::from_str(source).map_err(|_| SsrfOastReviewPolicyError::MalformedPolicy)?;
126 if wire.schema != SSRF_OAST_REVIEW_POLICY_SCHEMA {
127 return Err(SsrfOastReviewPolicyError::UnsupportedSchema);
128 }
129 if !wire.acknowledge_external_interaction {
130 return Err(SsrfOastReviewPolicyError::AcknowledgementRequired);
131 }
132 if !(MIN_SSRF_OAST_POLLS_PER_LEG..=MAX_SSRF_OAST_POLLS_PER_LEG)
133 .contains(&wire.polls_per_leg)
134 || !(MIN_SSRF_OAST_POLL_INTERVAL_MS..=MAX_SSRF_OAST_POLL_INTERVAL_MS)
135 .contains(&wire.poll_interval_ms)
136 || !(MIN_SSRF_OAST_LIFETIME_MS..=MAX_SSRF_OAST_LIFETIME_MS).contains(&wire.lifetime_ms)
137 {
138 return Err(SsrfOastReviewPolicyError::InvalidLimits);
139 }
140
141 let assessment_origin = canonical_target_origin(assessment_target)
142 .ok_or(SsrfOastReviewPolicyError::TargetOriginMismatch)?;
143 let target_origin = parse_declared_target_origin(&wire.target_origin)?;
144 if !same_origin(&assessment_origin, &target_origin) {
145 return Err(SsrfOastReviewPolicyError::TargetOriginMismatch);
146 }
147 let provider_origin = PublicOrigin::from_str(&wire.provider_origin)
148 .map_err(|_| SsrfOastReviewPolicyError::InvalidProviderOrigin)?;
149 let provider_url = Url::parse(provider_origin.as_str())
150 .map_err(|_| SsrfOastReviewPolicyError::InvalidProviderOrigin)?;
151 if same_origin(&target_origin, &provider_url) {
152 return Err(SsrfOastReviewPolicyError::ProviderOriginMatchesTarget);
153 }
154
155 let policy_id = policy_identity(
156 &target_origin,
157 &provider_url,
158 wire.polls_per_leg,
159 wire.poll_interval_ms,
160 wire.lifetime_ms,
161 );
162 Ok(Self {
163 target_origin,
164 provider_origin,
165 polls_per_leg: wire.polls_per_leg,
166 poll_interval_ms: wire.poll_interval_ms,
167 lifetime_ms: wire.lifetime_ms,
168 policy_id,
169 })
170 }
171
172 #[cfg(test)]
176 pub(crate) fn for_loopback(
177 target_origin: Url,
178 provider_origin: PublicOrigin,
179 polls_per_leg: u16,
180 poll_interval_ms: u64,
181 lifetime_ms: u64,
182 ) -> Result<Self, SsrfOastReviewPolicyError> {
183 if !(MIN_SSRF_OAST_POLLS_PER_LEG..=MAX_SSRF_OAST_POLLS_PER_LEG).contains(&polls_per_leg)
184 || !(MIN_SSRF_OAST_POLL_INTERVAL_MS..=MAX_SSRF_OAST_POLL_INTERVAL_MS)
185 .contains(&poll_interval_ms)
186 || !(MIN_SSRF_OAST_LIFETIME_MS..=MAX_SSRF_OAST_LIFETIME_MS).contains(&lifetime_ms)
187 {
188 return Err(SsrfOastReviewPolicyError::InvalidLimits);
189 }
190 let target_origin = canonical_target_origin(&target_origin)
191 .ok_or(SsrfOastReviewPolicyError::TargetOriginMismatch)?;
192 let provider_url = Url::parse(provider_origin.as_str())
193 .map_err(|_| SsrfOastReviewPolicyError::InvalidProviderOrigin)?;
194 if same_origin(&target_origin, &provider_url) {
195 return Err(SsrfOastReviewPolicyError::ProviderOriginMatchesTarget);
196 }
197 let policy_id = policy_identity(
198 &target_origin,
199 &provider_url,
200 polls_per_leg,
201 poll_interval_ms,
202 lifetime_ms,
203 );
204 Ok(Self {
205 target_origin,
206 provider_origin,
207 polls_per_leg,
208 poll_interval_ms,
209 lifetime_ms,
210 policy_id,
211 })
212 }
213
214 pub const fn policy_id(&self) -> SsrfOastPolicyId {
216 self.policy_id
217 }
218
219 pub const fn polls_per_leg(&self) -> u16 {
221 self.polls_per_leg
222 }
223
224 pub const fn poll_interval_ms(&self) -> u64 {
226 self.poll_interval_ms
227 }
228
229 pub const fn lifetime_ms(&self) -> u64 {
231 self.lifetime_ms
232 }
233
234 pub(crate) const fn target_origin(&self) -> &Url {
236 &self.target_origin
237 }
238
239 pub(crate) const fn provider_origin(&self) -> &PublicOrigin {
241 &self.provider_origin
242 }
243}
244
245impl fmt::Debug for SsrfOastReviewPolicy {
246 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
247 formatter
248 .debug_struct("SsrfOastReviewPolicy")
249 .field("target_origin", &"<redacted>")
250 .field("provider_origin", &"<redacted>")
251 .field("polls_per_leg", &self.polls_per_leg)
252 .field("poll_interval_ms", &self.poll_interval_ms)
253 .field("lifetime_ms", &self.lifetime_ms)
254 .field("policy_id", &self.policy_id)
255 .finish()
256 }
257}
258
259#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
261#[non_exhaustive]
262pub enum SsrfOastReviewPolicyError {
263 #[error("SSRF OAST review policy exceeds its compiled byte limit")]
265 PolicyTooLarge,
266 #[error("SSRF OAST review policy is malformed")]
268 MalformedPolicy,
269 #[error("SSRF OAST review policy schema is unsupported")]
271 UnsupportedSchema,
272 #[error("SSRF OAST review requires explicit external-interaction acknowledgement")]
274 AcknowledgementRequired,
275 #[error("SSRF OAST review scheduling limits are invalid")]
277 InvalidLimits,
278 #[error("SSRF OAST review target does not match exact-origin authority")]
280 TargetOriginMismatch,
281 #[error("SSRF OAST review provider origin is invalid")]
283 InvalidProviderOrigin,
284 #[error("SSRF OAST review provider origin must differ from target origin")]
286 ProviderOriginMatchesTarget,
287}
288
289#[derive(Deserialize)]
290#[serde(deny_unknown_fields)]
291struct WirePolicy {
292 schema: String,
293 target_origin: String,
294 provider_origin: String,
295 acknowledge_external_interaction: bool,
296 polls_per_leg: u16,
297 poll_interval_ms: u64,
298 lifetime_ms: u64,
299}
300
301pub struct SsrfOastAdminToken {
303 bytes: Vec<u8>,
304}
305
306impl SsrfOastAdminToken {
307 pub fn new(mut bytes: Vec<u8>) -> Result<Self, SsrfOastAdminTokenError> {
309 let valid = (MIN_SSRF_OAST_ADMIN_TOKEN_BYTES..=MAX_SSRF_OAST_ADMIN_TOKEN_BYTES)
310 .contains(&bytes.len())
311 && bytes.iter().all(|byte| (0x21..=0x7e).contains(byte));
312 if !valid {
313 bytes.zeroize();
314 return Err(SsrfOastAdminTokenError::Invalid);
315 }
316 Ok(Self { bytes })
317 }
318
319 pub(crate) fn into_bytes(mut self) -> Vec<u8> {
321 mem::take(&mut self.bytes)
322 }
323}
324
325impl fmt::Debug for SsrfOastAdminToken {
326 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
327 formatter.write_str("SsrfOastAdminToken(<redacted>)")
328 }
329}
330
331impl Drop for SsrfOastAdminToken {
332 fn drop(&mut self) {
333 self.bytes.zeroize();
334 }
335}
336
337#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
339pub enum SsrfOastAdminTokenError {
340 #[error("SSRF OAST administrator token is invalid")]
342 Invalid,
343}
344
345#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
347pub enum SsrfOastCandidateSource {
348 ObservedUrlQuery,
350 #[cfg(feature = "openapi-review")]
352 OpenApiOptionalUrlQuery,
353}
354
355impl SsrfOastCandidateSource {
356 #[cfg(feature = "openapi-review")]
357 const fn rank(self) -> u8 {
358 match self {
359 Self::ObservedUrlQuery => 0,
360 #[cfg(feature = "openapi-review")]
361 Self::OpenApiOptionalUrlQuery => 1,
362 }
363 }
364
365 pub const fn as_str(self) -> &'static str {
367 match self {
368 Self::ObservedUrlQuery => "observed_url_query",
369 #[cfg(feature = "openapi-review")]
370 Self::OpenApiOptionalUrlQuery => "openapi_optional_url_query",
371 }
372 }
373}
374
375pub(crate) enum SsrfOastCandidateSelection {
377 Selected(Box<SsrfOastQueryCandidate>),
379 NotEligible,
381}
382
383impl fmt::Debug for SsrfOastCandidateSelection {
384 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
385 match self {
386 Self::Selected(candidate) => {
387 formatter.debug_tuple("Selected").field(candidate).finish()
388 },
389 Self::NotEligible => formatter.write_str("NotEligible"),
390 }
391 }
392}
393
394#[derive(Clone, Eq, PartialEq)]
396pub(crate) struct SsrfOastQueryCandidate {
397 source: SsrfOastCandidateSource,
398 execution_resource: Url,
399 parameter_name: String,
400 selected_pair_index: Option<usize>,
401 parameter_id: String,
402 selection_id: String,
403 subject_identity: String,
404}
405
406impl SsrfOastQueryCandidate {
407 pub(crate) const fn source(&self) -> SsrfOastCandidateSource {
409 self.source
410 }
411
412 pub(crate) fn parameter_id(&self) -> &str {
414 &self.parameter_id
415 }
416
417 pub(crate) fn selection_id(&self) -> &str {
419 &self.selection_id
420 }
421
422 pub(crate) const fn execution_resource(&self) -> &Url {
424 &self.execution_resource
425 }
426
427 pub(crate) fn control_execution_url(
429 &self,
430 control_seed: [u8; 32],
431 ) -> Result<Url, SsrfOastContractError> {
432 if control_seed.iter().all(|byte| *byte == 0) {
433 return Err(SsrfOastContractError::InvalidControlSeed);
434 }
435 let control_payload = inert_control_url(control_seed, self.selection_id())?;
436 let control = materialize_query(self, control_payload.as_str())?;
437 if !same_origin(&self.execution_resource, &control)
438 || self.execution_resource.path() != control.path()
439 {
440 return Err(SsrfOastContractError::MutationInvariant);
441 }
442 Ok(control)
443 }
444}
445
446impl fmt::Debug for SsrfOastQueryCandidate {
447 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
448 formatter
449 .debug_struct("SsrfOastQueryCandidate")
450 .field("source", &self.source)
451 .field("execution_resource", &"<redacted>")
452 .field("parameter_name", &"<redacted>")
453 .field("parameter_id", &self.parameter_id)
454 .field("selection_id", &self.selection_id)
455 .finish()
456 }
457}
458
459pub(crate) fn select_observed_query_candidate(
461 assessment_origin: &Url,
462 resource: &Url,
463 subject_identity: &str,
464 complete: bool,
465 replay_stable: bool,
466 defense_clear: bool,
467) -> SsrfOastCandidateSelection {
468 if !complete
469 || !replay_stable
470 || !defense_clear
471 || !valid_subject_identity(subject_identity)
472 || !safe_exact_origin_resource(assessment_origin, resource)
473 {
474 return SsrfOastCandidateSelection::NotEligible;
475 }
476 let Some(query) = resource.query() else {
477 return SsrfOastCandidateSelection::NotEligible;
478 };
479 let Ok(pairs) = parse_query_pairs(query) else {
480 return SsrfOastCandidateSelection::NotEligible;
481 };
482 let mut eligible = Vec::new();
483 for (index, pair) in pairs.iter().enumerate() {
484 if pairs
485 .iter()
486 .filter(|candidate| candidate.name == pair.name)
487 .count()
488 != 1
489 || !valid_parameter_name(&pair.name)
490 || !absolute_http_url_value(&pair.value)
491 {
492 continue;
493 }
494 eligible.push(build_candidate(
495 SsrfOastCandidateSource::ObservedUrlQuery,
496 resource.clone(),
497 pair.name.clone(),
498 Some(index),
499 subject_identity,
500 index,
501 ));
502 }
503 if eligible.len() != MAX_SSRF_OAST_REVIEW_PARAMETERS {
504 return SsrfOastCandidateSelection::NotEligible;
505 }
506 SsrfOastCandidateSelection::Selected(Box::new(eligible.remove(0)))
507}
508
509#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
515pub(crate) fn select_openapi_query_candidate(
516 assessment_origin: &Url,
517 execution_url: &Url,
518 operation: &OpenApiOperation,
519 parameter_name: &str,
520 document_complete: bool,
521 replay_stable: bool,
522 defense_clear: bool,
523) -> SsrfOastCandidateSelection {
524 if !document_complete
525 || !replay_stable
526 || !defense_clear
527 || operation.method() != OpenApiHttpMethod::Get
528 || operation.request_body_declared()
529 || !operation.security().permits_anonymous()
530 || operation
531 .parameters()
532 .iter()
533 .any(|parameter| parameter.required())
534 || operation.path().contains(['{', '}'])
535 || operation.servers().iter().any(|server| {
536 matches!(
537 server.kind(),
538 OpenApiServerKind::CrossOrigin
539 | OpenApiServerKind::Templated
540 | OpenApiServerKind::Unsupported
541 )
542 })
543 || !valid_parameter_name(parameter_name)
544 || !safe_exact_origin_resource(assessment_origin, execution_url)
545 || execution_url.query().is_some()
546 || !execution_url.path().ends_with(operation.path())
547 {
548 return SsrfOastCandidateSelection::NotEligible;
549 }
550
551 let url_parameters = operation
552 .parameters()
553 .iter()
554 .enumerate()
555 .filter(|(_, parameter)| {
556 parameter.location() == OpenApiParameterLocation::Query
557 && !parameter.required()
558 && matches!(
559 parameter.format_class(),
560 OpenApiFormatClass::Uri | OpenApiFormatClass::Url
561 )
562 })
563 .collect::<Vec<_>>();
564 let [(position, metadata)] = url_parameters.as_slice() else {
565 return SsrfOastCandidateSelection::NotEligible;
566 };
567 if digest_bytes(OPENAPI_PARAMETER_NAME_DOMAIN, parameter_name.as_bytes())
568 != *metadata.name_fingerprint()
569 {
570 return SsrfOastCandidateSelection::NotEligible;
571 }
572
573 SsrfOastCandidateSelection::Selected(Box::new(build_candidate(
574 SsrfOastCandidateSource::OpenApiOptionalUrlQuery,
575 execution_url.clone(),
576 parameter_name.to_owned(),
577 None,
578 operation.id().as_str(),
579 *position,
580 )))
581}
582
583#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
589pub(crate) fn select_openapi_document_query_candidate(
590 document: &OpenApiDocument,
591 document_url: &Url,
592) -> Option<SsrfOastQueryCandidate> {
593 let mut selected = None;
594 for operation in document.catalog().operations() {
595 let Some(execution_url) = openapi_operation_target(document_url, operation) else {
596 continue;
597 };
598 for parameter in operation.parameters() {
599 let SsrfOastCandidateSelection::Selected(candidate) = select_openapi_query_candidate(
600 document_url,
601 &execution_url,
602 operation,
603 parameter.execution_name(),
604 true,
605 true,
606 true,
607 ) else {
608 continue;
609 };
610 selected = match choose_ssrf_oast_query_candidate(selected, Some(*candidate)) {
611 SsrfOastCandidateSelection::Selected(candidate) => Some(*candidate),
612 SsrfOastCandidateSelection::NotEligible => None,
613 };
614 }
615 }
616 selected
617}
618
619#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
620fn openapi_operation_target(document_url: &Url, operation: &OpenApiOperation) -> Option<Url> {
621 let mut base = match operation.servers() {
622 [] => {
623 let mut root = document_url.clone();
624 root.set_path("/");
625 root.set_query(None);
626 root.set_fragment(None);
627 root
628 },
629 [server] => server.execution_base()?.clone(),
630 _ => return None,
631 };
632 let base_path = base.path().trim_end_matches('/');
633 let path = if base_path.is_empty() {
634 operation.path().to_owned()
635 } else {
636 format!("{base_path}{}", operation.path())
637 };
638 if !path.starts_with('/')
639 || path.contains(['?', '#', '%', '\\', '\r', '\n', '\0'])
640 || path.contains("//")
641 || path.split('/').any(|segment| matches!(segment, "." | ".."))
642 {
643 return None;
644 }
645 base.set_path(&path);
646 base.set_query(None);
647 base.set_fragment(None);
648 (base.origin() == document_url.origin()).then_some(base)
649}
650
651#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
653pub(crate) fn choose_ssrf_oast_query_candidate(
654 observed: Option<SsrfOastQueryCandidate>,
655 openapi: Option<SsrfOastQueryCandidate>,
656) -> SsrfOastCandidateSelection {
657 let mut candidates = observed.into_iter().chain(openapi).collect::<Vec<_>>();
658 candidates.sort_by(candidate_order);
659 candidates.into_iter().next().map_or_else(
660 || SsrfOastCandidateSelection::NotEligible,
661 |candidate| SsrfOastCandidateSelection::Selected(Box::new(candidate)),
662 )
663}
664
665#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
666fn candidate_order(
667 left: &SsrfOastQueryCandidate,
668 right: &SsrfOastQueryCandidate,
669) -> std::cmp::Ordering {
670 left.source
671 .rank()
672 .cmp(&right.source.rank())
673 .then(
674 left.execution_resource
675 .path()
676 .len()
677 .cmp(&right.execution_resource.path().len()),
678 )
679 .then(left.subject_identity.cmp(&right.subject_identity))
680 .then(left.parameter_id.cmp(&right.parameter_id))
681}
682
683fn build_candidate(
684 source: SsrfOastCandidateSource,
685 execution_resource: Url,
686 parameter_name: String,
687 selected_pair_index: Option<usize>,
688 subject_identity: &str,
689 parameter_position: usize,
690) -> SsrfOastQueryCandidate {
691 let resource_id = prefixed_digest(
692 "ssrf-oast-resource-sha256:",
693 RESOURCE_ID_DOMAIN,
694 &[
695 canonical_origin_string(&execution_resource).as_bytes(),
696 execution_resource.path().as_bytes(),
697 ],
698 );
699 let position = u64::try_from(parameter_position)
700 .unwrap_or(u64::MAX)
701 .to_be_bytes();
702 let parameter_id = prefixed_digest(
703 "ssrf-oast-parameter-sha256:",
704 PARAMETER_ID_DOMAIN,
705 &[resource_id.as_bytes(), parameter_name.as_bytes(), &position],
706 );
707 let selection_id = prefixed_digest(
708 "ssrf-oast-selection-sha256:",
709 SELECTION_ID_DOMAIN,
710 &[
711 source.as_str().as_bytes(),
712 subject_identity.as_bytes(),
713 resource_id.as_bytes(),
714 parameter_id.as_bytes(),
715 ],
716 );
717 SsrfOastQueryCandidate {
718 source,
719 execution_resource,
720 parameter_name,
721 selected_pair_index,
722 parameter_id,
723 selection_id,
724 subject_identity: subject_identity.to_owned(),
725 }
726}
727
728#[derive(Clone, Copy, Debug, Eq, PartialEq)]
730pub(crate) enum SsrfOastTargetLeg {
731 Candidate,
733 Replay,
735}
736
737pub(crate) struct SsrfOastMutationPlan {
739 candidate: Url,
740 replay: Url,
741}
742
743impl SsrfOastMutationPlan {
744 pub(crate) fn new(
746 selected: SsrfOastQueryCandidate,
747 control_seed: [u8; 32],
748 candidate_target: &CallbackTarget,
749 replay_target: &CallbackTarget,
750 provider_origin: &PublicOrigin,
751 ) -> Result<Self, SsrfOastContractError> {
752 Self::from_callback_strings(
753 selected,
754 control_seed,
755 candidate_target.as_str(),
756 replay_target.as_str(),
757 provider_origin,
758 )
759 }
760
761 #[cfg(test)]
765 pub(crate) fn new_for_loopback(
766 selected: SsrfOastQueryCandidate,
767 control_seed: [u8; 32],
768 candidate_target: &CallbackTarget,
769 replay_target: &CallbackTarget,
770 provider_origin: &PublicOrigin,
771 ) -> Result<Self, SsrfOastContractError> {
772 let provider = Url::parse(provider_origin.as_str())
773 .map_err(|_| SsrfOastContractError::InvalidCallbackTarget)?;
774 if !is_http_loopback_origin(&provider) {
775 return Err(SsrfOastContractError::InvalidCallbackTarget);
776 }
777 Self::from_callback_strings_inner(
778 selected,
779 control_seed,
780 candidate_target.as_str(),
781 replay_target.as_str(),
782 provider_origin,
783 true,
784 )
785 }
786
787 pub(crate) fn from_callback_strings(
790 selected: SsrfOastQueryCandidate,
791 control_seed: [u8; 32],
792 candidate_target: &str,
793 replay_target: &str,
794 provider_origin: &PublicOrigin,
795 ) -> Result<Self, SsrfOastContractError> {
796 Self::from_callback_strings_inner(
797 selected,
798 control_seed,
799 candidate_target,
800 replay_target,
801 provider_origin,
802 false,
803 )
804 }
805
806 fn from_callback_strings_inner(
807 selected: SsrfOastQueryCandidate,
808 control_seed: [u8; 32],
809 candidate_target: &str,
810 replay_target: &str,
811 provider_origin: &PublicOrigin,
812 allow_test_loopback: bool,
813 ) -> Result<Self, SsrfOastContractError> {
814 if candidate_target == replay_target {
815 return Err(SsrfOastContractError::CallbackIdentityConflict);
816 }
817 let candidate_callback =
818 validate_callback_target(candidate_target, provider_origin, allow_test_loopback)?;
819 let replay_callback =
820 validate_callback_target(replay_target, provider_origin, allow_test_loopback)?;
821 let control = selected.control_execution_url(control_seed)?;
822 let candidate = materialize_query(&selected, candidate_callback.as_str())?;
823 let replay = materialize_query(&selected, replay_callback.as_str())?;
824 if candidate == replay
825 || !same_origin(&selected.execution_resource, &control)
826 || !same_origin(&selected.execution_resource, &candidate)
827 || !same_origin(&selected.execution_resource, &replay)
828 || selected.execution_resource.path() != control.path()
829 || selected.execution_resource.path() != candidate.path()
830 || selected.execution_resource.path() != replay.path()
831 {
832 return Err(SsrfOastContractError::MutationInvariant);
833 }
834 Ok(Self { candidate, replay })
835 }
836
837 pub(crate) const fn execution_url(&self, leg: SsrfOastTargetLeg) -> &Url {
839 match leg {
840 SsrfOastTargetLeg::Candidate => &self.candidate,
841 SsrfOastTargetLeg::Replay => &self.replay,
842 }
843 }
844}
845
846impl fmt::Debug for SsrfOastMutationPlan {
847 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
848 formatter
849 .debug_struct("SsrfOastMutationPlan")
850 .field("candidate", &"<redacted>")
851 .field("replay", &"<redacted>")
852 .finish()
853 }
854}
855
856pub(crate) struct SsrfOastCorrelationMaterial {
858 epoch: OastAuthorityEpoch,
859 candidate: OastCorrelationToken,
860 replay: OastCorrelationToken,
861}
862
863pub(crate) struct SsrfOastCorrelationBinding<'a> {
865 assessment_identity: &'a str,
866 action_identity: &'a str,
867 case_identity: &'a str,
868}
869
870impl<'a> SsrfOastCorrelationBinding<'a> {
871 pub(crate) const fn new(
874 assessment_identity: &'a str,
875 action_identity: &'a str,
876 case_identity: &'a str,
877 ) -> Self {
878 Self {
879 assessment_identity,
880 action_identity,
881 case_identity,
882 }
883 }
884}
885
886pub(crate) struct SsrfOastCorrelationEntropy {
888 epoch: [u8; 32],
889 candidate: [u8; 32],
890 replay: [u8; 32],
891}
892
893impl SsrfOastCorrelationEntropy {
894 pub(crate) const fn new(epoch: [u8; 32], candidate: [u8; 32], replay: [u8; 32]) -> Self {
896 Self {
897 epoch,
898 candidate,
899 replay,
900 }
901 }
902}
903
904impl Drop for SsrfOastCorrelationEntropy {
905 fn drop(&mut self) {
906 self.epoch.zeroize();
907 self.candidate.zeroize();
908 self.replay.zeroize();
909 }
910}
911
912impl SsrfOastCorrelationMaterial {
913 pub(crate) fn derive(
915 policy: &SsrfOastReviewPolicy,
916 candidate: &SsrfOastQueryCandidate,
917 binding: SsrfOastCorrelationBinding<'_>,
918 entropy: SsrfOastCorrelationEntropy,
919 ) -> Result<Self, SsrfOastContractError> {
920 if !valid_binding_identity(binding.assessment_identity)
921 || !valid_binding_identity(binding.action_identity)
922 || !valid_binding_identity(binding.case_identity)
923 || entropy.epoch.iter().all(|byte| *byte == 0)
924 || entropy.candidate.iter().all(|byte| *byte == 0)
925 || entropy.replay.iter().all(|byte| *byte == 0)
926 || entropy.candidate == entropy.replay
927 {
928 return Err(SsrfOastContractError::InvalidCorrelationMaterial);
929 }
930 let context = [
931 policy.policy_id.to_wire(),
932 candidate.selection_id.clone(),
933 binding.assessment_identity.to_owned(),
934 binding.action_identity.to_owned(),
935 binding.case_identity.to_owned(),
936 ];
937 let context = context.iter().map(String::as_bytes).collect::<Vec<_>>();
938 let mut epoch_bytes = digest_parts(EPOCH_DOMAIN, &context, &entropy.epoch);
939 let mut candidate_bytes =
940 digest_parts(CANDIDATE_TOKEN_DOMAIN, &context, &entropy.candidate);
941 let mut replay_bytes = digest_parts(REPLAY_TOKEN_DOMAIN, &context, &entropy.replay);
942 if candidate_bytes == replay_bytes {
943 epoch_bytes.zeroize();
944 candidate_bytes.zeroize();
945 replay_bytes.zeroize();
946 return Err(SsrfOastContractError::InvalidCorrelationMaterial);
947 }
948 let epoch_result = OastAuthorityEpoch::new(epoch_bytes);
949 epoch_bytes.zeroize();
950 let candidate_result = OastCorrelationToken::new(candidate_bytes);
951 candidate_bytes.zeroize();
952 let replay_result = OastCorrelationToken::new(replay_bytes);
953 replay_bytes.zeroize();
954 let epoch = epoch_result.map_err(|_| SsrfOastContractError::InvalidCorrelationMaterial)?;
955 let candidate =
956 candidate_result.map_err(|_| SsrfOastContractError::InvalidCorrelationMaterial)?;
957 let replay =
958 replay_result.map_err(|_| SsrfOastContractError::InvalidCorrelationMaterial)?;
959 Ok(Self {
960 epoch,
961 candidate,
962 replay,
963 })
964 }
965
966 pub(crate) fn into_parts(
968 self,
969 ) -> (
970 OastAuthorityEpoch,
971 OastCorrelationToken,
972 OastCorrelationToken,
973 ) {
974 (self.epoch, self.candidate, self.replay)
975 }
976}
977
978impl fmt::Debug for SsrfOastCorrelationMaterial {
979 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
980 formatter.write_str("SsrfOastCorrelationMaterial(<redacted>)")
981 }
982}
983
984#[derive(Clone, Copy, Debug, Eq, PartialEq)]
986pub(crate) enum SsrfOastTerminalState {
987 DefensiveInterference,
988 RateLimited,
989 ProviderAuthenticationFailed,
990 MalformedProviderResponse,
991 PollExhausted,
992 Expired,
993 Cancelled,
994 BudgetExhausted,
995 Incomplete,
996 TargetTimeoutAfterDispatch,
998}
999
1000#[derive(Clone, Eq, PartialEq)]
1002pub(crate) struct SsrfOastCallbackIdentity([u8; 32]);
1003
1004impl SsrfOastCallbackIdentity {
1005 pub(crate) fn from_native(callback_id: &CallbackId) -> Self {
1007 Self(digest_bytes(
1008 CALLBACK_ID_DOMAIN,
1009 callback_id.as_str().as_bytes(),
1010 ))
1011 }
1012}
1013
1014impl fmt::Debug for SsrfOastCallbackIdentity {
1015 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1016 formatter.write_str("SsrfOastCallbackIdentity(<redacted>)")
1017 }
1018}
1019
1020#[derive(Clone, Eq, PartialEq)]
1022pub(crate) struct SsrfOastObservedEvent {
1023 callback: SsrfOastCallbackIdentity,
1024 event: [u8; 32],
1025}
1026
1027impl SsrfOastObservedEvent {
1028 pub(crate) fn from_reduced(callback_id: &CallbackId, event_key: &OastEventKey) -> Self {
1030 Self {
1031 callback: SsrfOastCallbackIdentity::from_native(callback_id),
1032 event: *event_key.as_bytes(),
1033 }
1034 }
1035}
1036
1037impl fmt::Debug for SsrfOastObservedEvent {
1038 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1039 formatter.write_str("SsrfOastObservedEvent(<redacted>)")
1040 }
1041}
1042
1043pub(crate) struct SsrfOastReviewFacts {
1045 pub(crate) control_complete: bool,
1046 pub(crate) provider_registered: bool,
1047 pub(crate) allocations_complete: bool,
1048 pub(crate) preflight_clean: bool,
1049 pub(crate) candidate_dispatched: bool,
1050 pub(crate) replay_dispatched: bool,
1051 pub(crate) expected_candidate: SsrfOastCallbackIdentity,
1052 pub(crate) expected_replay: SsrfOastCallbackIdentity,
1053 pub(crate) candidate_event: Option<SsrfOastObservedEvent>,
1054 pub(crate) replay_event: Option<SsrfOastObservedEvent>,
1055 pub(crate) correlations_distinct: bool,
1056 pub(crate) same_correlation_scope: bool,
1057 pub(crate) duplicate_only_substitution: bool,
1058 pub(crate) cleanup_verified: bool,
1059 pub(crate) target_accounting_complete: bool,
1060 pub(crate) provider_accounting_complete: bool,
1061 pub(crate) truncated: bool,
1062 pub(crate) terminal: Option<SsrfOastTerminalState>,
1063}
1064
1065impl SsrfOastReviewFacts {
1066 pub(crate) fn new(candidate: &CallbackId, replay: &CallbackId) -> Self {
1068 Self {
1069 control_complete: false,
1070 provider_registered: false,
1071 allocations_complete: false,
1072 preflight_clean: false,
1073 candidate_dispatched: false,
1074 replay_dispatched: false,
1075 expected_candidate: SsrfOastCallbackIdentity::from_native(candidate),
1076 expected_replay: SsrfOastCallbackIdentity::from_native(replay),
1077 candidate_event: None,
1078 replay_event: None,
1079 correlations_distinct: false,
1080 same_correlation_scope: false,
1081 duplicate_only_substitution: false,
1082 cleanup_verified: false,
1083 target_accounting_complete: false,
1084 provider_accounting_complete: false,
1085 truncated: false,
1086 terminal: None,
1087 }
1088 }
1089}
1090
1091impl fmt::Debug for SsrfOastReviewFacts {
1092 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1093 formatter
1094 .debug_struct("SsrfOastReviewFacts")
1095 .field("control_complete", &self.control_complete)
1096 .field("provider_registered", &self.provider_registered)
1097 .field("allocations_complete", &self.allocations_complete)
1098 .field("preflight_clean", &self.preflight_clean)
1099 .field("candidate_dispatched", &self.candidate_dispatched)
1100 .field("replay_dispatched", &self.replay_dispatched)
1101 .field("candidate_event", &self.candidate_event.is_some())
1102 .field("replay_event", &self.replay_event.is_some())
1103 .field("correlations_distinct", &self.correlations_distinct)
1104 .field("same_correlation_scope", &self.same_correlation_scope)
1105 .field(
1106 "duplicate_only_substitution",
1107 &self.duplicate_only_substitution,
1108 )
1109 .field("cleanup_verified", &self.cleanup_verified)
1110 .field(
1111 "target_accounting_complete",
1112 &self.target_accounting_complete,
1113 )
1114 .field(
1115 "provider_accounting_complete",
1116 &self.provider_accounting_complete,
1117 )
1118 .field("truncated", &self.truncated)
1119 .field("terminal", &self.terminal)
1120 .finish()
1121 }
1122}
1123
1124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1126#[non_exhaustive]
1127pub(crate) enum SsrfOastReviewOutcome {
1128 NotEligible,
1129 ControlIncomplete,
1130 RegistrationIncomplete,
1131 AllocationIncomplete,
1132 PreflightContaminated,
1133 TargetNotDispatched,
1134 NoCallback,
1135 CandidateOnly,
1136 ReplayOnly,
1137 WrongCallback,
1138 EventIdentityConflict,
1139 CorrelationMismatch,
1140 DuplicateOnly,
1141 CleanupIncomplete,
1142 DefensiveInterference,
1143 RateLimited,
1144 ProviderAuthenticationFailed,
1145 MalformedProviderResponse,
1146 PollExhausted,
1147 Expired,
1148 Cancelled,
1149 BudgetExhausted,
1150 Truncated,
1151 Incomplete,
1152 RepeatedCallbacksObserved,
1153}
1154
1155impl SsrfOastReviewOutcome {
1156 pub(crate) const fn projects_item(self) -> bool {
1158 matches!(self, Self::RepeatedCallbacksObserved)
1159 }
1160}
1161
1162pub(crate) fn evaluate_ssrf_oast_review(
1164 facts: &SsrfOastReviewFacts,
1165) -> Result<SsrfOastReviewOutcome, SsrfOastContractError> {
1166 if facts.expected_candidate == facts.expected_replay {
1167 return Err(SsrfOastContractError::CallbackIdentityConflict);
1168 }
1169 if let Some(terminal) = facts.terminal {
1170 let outcome = match terminal {
1171 SsrfOastTerminalState::DefensiveInterference => {
1172 SsrfOastReviewOutcome::DefensiveInterference
1173 },
1174 SsrfOastTerminalState::RateLimited => SsrfOastReviewOutcome::RateLimited,
1175 SsrfOastTerminalState::ProviderAuthenticationFailed => {
1176 SsrfOastReviewOutcome::ProviderAuthenticationFailed
1177 },
1178 SsrfOastTerminalState::MalformedProviderResponse => {
1179 SsrfOastReviewOutcome::MalformedProviderResponse
1180 },
1181 SsrfOastTerminalState::PollExhausted => SsrfOastReviewOutcome::PollExhausted,
1182 SsrfOastTerminalState::Expired => SsrfOastReviewOutcome::Expired,
1183 SsrfOastTerminalState::Cancelled => SsrfOastReviewOutcome::Cancelled,
1184 SsrfOastTerminalState::BudgetExhausted => SsrfOastReviewOutcome::BudgetExhausted,
1185 SsrfOastTerminalState::Incomplete => SsrfOastReviewOutcome::Incomplete,
1186 SsrfOastTerminalState::TargetTimeoutAfterDispatch => {
1187 SsrfOastReviewOutcome::NotEligible
1189 },
1190 };
1191 if !matches!(terminal, SsrfOastTerminalState::TargetTimeoutAfterDispatch) {
1192 return Ok(outcome);
1193 }
1194 }
1195 if !facts.control_complete {
1196 return Ok(SsrfOastReviewOutcome::ControlIncomplete);
1197 }
1198 if !facts.provider_registered {
1199 return Ok(SsrfOastReviewOutcome::RegistrationIncomplete);
1200 }
1201 if !facts.allocations_complete {
1202 return Ok(SsrfOastReviewOutcome::AllocationIncomplete);
1203 }
1204 if !facts.preflight_clean {
1205 return Ok(SsrfOastReviewOutcome::PreflightContaminated);
1206 }
1207 if !facts.candidate_dispatched || !facts.replay_dispatched {
1208 return Ok(SsrfOastReviewOutcome::TargetNotDispatched);
1209 }
1210 if facts.duplicate_only_substitution {
1211 return Ok(SsrfOastReviewOutcome::DuplicateOnly);
1212 }
1213 let candidate_exact = facts
1214 .candidate_event
1215 .as_ref()
1216 .is_some_and(|event| event.callback == facts.expected_candidate);
1217 let replay_exact = facts
1218 .replay_event
1219 .as_ref()
1220 .is_some_and(|event| event.callback == facts.expected_replay);
1221 if facts.candidate_event.is_some() && !candidate_exact
1222 || facts.replay_event.is_some() && !replay_exact
1223 {
1224 return Ok(SsrfOastReviewOutcome::WrongCallback);
1225 }
1226 match (candidate_exact, replay_exact) {
1227 (false, false) => return Ok(SsrfOastReviewOutcome::NoCallback),
1228 (true, false) => return Ok(SsrfOastReviewOutcome::CandidateOnly),
1229 (false, true) => return Ok(SsrfOastReviewOutcome::ReplayOnly),
1230 (true, true) => {},
1231 }
1232 if facts
1233 .candidate_event
1234 .as_ref()
1235 .zip(facts.replay_event.as_ref())
1236 .is_some_and(|(candidate, replay)| candidate.event == replay.event)
1237 {
1238 return Ok(SsrfOastReviewOutcome::EventIdentityConflict);
1239 }
1240 if !facts.correlations_distinct || !facts.same_correlation_scope {
1241 return Ok(SsrfOastReviewOutcome::CorrelationMismatch);
1242 }
1243 if !facts.cleanup_verified {
1244 return Ok(SsrfOastReviewOutcome::CleanupIncomplete);
1245 }
1246 if facts.truncated {
1247 return Ok(SsrfOastReviewOutcome::Truncated);
1248 }
1249 if !facts.target_accounting_complete || !facts.provider_accounting_complete {
1250 return Ok(SsrfOastReviewOutcome::Incomplete);
1251 }
1252 Ok(SsrfOastReviewOutcome::RepeatedCallbacksObserved)
1253}
1254
1255#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
1257pub(crate) enum SsrfOastContractError {
1258 #[error("SSRF OAST control seed is invalid")]
1259 InvalidControlSeed,
1260 #[error("SSRF OAST callback target is invalid")]
1261 InvalidCallbackTarget,
1262 #[error("SSRF OAST callback identities must be distinct")]
1263 CallbackIdentityConflict,
1264 #[error("SSRF OAST query mutation contract is invalid")]
1265 MutationInvariant,
1266 #[error("SSRF OAST correlation material is invalid")]
1267 InvalidCorrelationMaterial,
1268}
1269
1270#[derive(Clone)]
1271struct ParsedQueryPair {
1272 name: String,
1273 value: String,
1274}
1275
1276fn parse_query_pairs(query: &str) -> Result<Vec<ParsedQueryPair>, ()> {
1277 if query.is_empty() || !valid_percent_encoding(query.as_bytes()) {
1278 return Err(());
1279 }
1280 let raw_pairs = query.split('&').collect::<Vec<_>>();
1281 if raw_pairs.iter().any(|pair| pair.is_empty()) {
1282 return Err(());
1283 }
1284 let decoded = url::form_urlencoded::parse(query.as_bytes()).collect::<Vec<_>>();
1285 if decoded.len() != raw_pairs.len() {
1286 return Err(());
1287 }
1288 decoded
1289 .into_iter()
1290 .map(|(name, value)| {
1291 if name.is_empty() {
1292 Err(())
1293 } else {
1294 Ok(ParsedQueryPair {
1295 name: name.into_owned(),
1296 value: value.into_owned(),
1297 })
1298 }
1299 })
1300 .collect()
1301}
1302
1303fn valid_percent_encoding(bytes: &[u8]) -> bool {
1304 let mut index = 0;
1305 while index < bytes.len() {
1306 if bytes[index] == b'%' {
1307 if index + 2 >= bytes.len()
1308 || !bytes[index + 1].is_ascii_hexdigit()
1309 || !bytes[index + 2].is_ascii_hexdigit()
1310 {
1311 return false;
1312 }
1313 index += 3;
1314 } else {
1315 index += 1;
1316 }
1317 }
1318 true
1319}
1320
1321fn absolute_http_url_value(value: &str) -> bool {
1322 Url::parse(value).is_ok_and(|url| {
1323 matches!(url.scheme(), "http" | "https")
1324 && url.host().is_some()
1325 && url.username().is_empty()
1326 && url.password().is_none()
1327 && url.fragment().is_none()
1328 })
1329}
1330
1331fn safe_exact_origin_resource(origin: &Url, resource: &Url) -> bool {
1332 resource.as_str().len() <= MAX_TARGET_BYTES
1333 && matches!(resource.scheme(), "http" | "https")
1334 && resource.host().is_some()
1335 && resource.username().is_empty()
1336 && resource.password().is_none()
1337 && resource.fragment().is_none()
1338 && same_origin(origin, resource)
1339 && safe_path(resource.path())
1340}
1341
1342fn safe_path(path: &str) -> bool {
1343 path.starts_with('/')
1344 && !path.contains(['\\', '\r', '\n', '\0'])
1345 && !path.split('/').any(|segment| matches!(segment, "." | ".."))
1346}
1347
1348fn valid_subject_identity(identity: &str) -> bool {
1349 !identity.is_empty()
1350 && identity.len() <= MAX_SUBJECT_IDENTITY_BYTES
1351 && identity.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
1352}
1353
1354fn valid_binding_identity(identity: &str) -> bool {
1355 valid_subject_identity(identity)
1356}
1357
1358fn valid_parameter_name(name: &str) -> bool {
1359 !name.is_empty()
1360 && name.len() <= MAX_PARAMETER_NAME_BYTES
1361 && name.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
1362 && !name.contains(['&', '=', '#'])
1363}
1364
1365fn validate_callback_target(
1366 source: &str,
1367 provider_origin: &PublicOrigin,
1368 allow_test_loopback: bool,
1369) -> Result<Url, SsrfOastContractError> {
1370 let parsed = Url::parse(source).map_err(|_| SsrfOastContractError::InvalidCallbackTarget)?;
1371 let provider = Url::parse(provider_origin.as_str())
1372 .map_err(|_| SsrfOastContractError::InvalidCallbackTarget)?;
1373 let scheme_allowed =
1374 parsed.scheme() == "https" || (allow_test_loopback && is_http_loopback_origin(&parsed));
1375 if !scheme_allowed
1376 || !same_origin(&parsed, &provider)
1377 || !parsed.username().is_empty()
1378 || parsed.password().is_some()
1379 || parsed.query().is_some()
1380 || parsed.fragment().is_some()
1381 || !matches!(
1382 NativeOastRoute::from_str(parsed.path()),
1383 Ok(NativeOastRoute::Callback { .. })
1384 )
1385 {
1386 return Err(SsrfOastContractError::InvalidCallbackTarget);
1387 }
1388 Ok(parsed)
1389}
1390
1391fn is_http_loopback_origin(url: &Url) -> bool {
1392 url.scheme() == "http"
1393 && match url.host() {
1394 Some(Host::Ipv4(address)) => address.is_loopback(),
1395 Some(Host::Ipv6(address)) => address.is_loopback(),
1396 _ => false,
1397 }
1398}
1399
1400fn inert_control_url(seed: [u8; 32], selection_id: &str) -> Result<Url, SsrfOastContractError> {
1401 let digest = digest_parts(CONTROL_LABEL_DOMAIN, &[selection_id.as_bytes()], &seed);
1402 let mut label = hex(digest);
1406 label.truncate(60);
1407 Url::parse(&format!("https://c-{label}.invalid/"))
1408 .map_err(|_| SsrfOastContractError::MutationInvariant)
1409}
1410
1411fn materialize_query(
1412 selected: &SsrfOastQueryCandidate,
1413 replacement: &str,
1414) -> Result<Url, SsrfOastContractError> {
1415 let encoded = encode_query_component(replacement.as_bytes());
1416 let query = match (
1417 selected.execution_resource.query(),
1418 selected.selected_pair_index,
1419 ) {
1420 (Some(original), Some(selected_index)) => {
1421 let mut pairs = original.split('&').map(str::to_owned).collect::<Vec<_>>();
1422 let pair = pairs
1423 .get_mut(selected_index)
1424 .ok_or(SsrfOastContractError::MutationInvariant)?;
1425 let raw_name = pair.split_once('=').map_or(pair.as_str(), |(name, _)| name);
1426 *pair = format!("{raw_name}={encoded}");
1427 pairs.join("&")
1428 },
1429 (None, None) => format!(
1430 "{}={encoded}",
1431 encode_query_component(selected.parameter_name.as_bytes())
1432 ),
1433 _ => return Err(SsrfOastContractError::MutationInvariant),
1434 };
1435 let mut target = selected.execution_resource.clone();
1436 target.set_query(Some(&query));
1437 if target.query() != Some(query.as_str()) || target.as_str().len() > MAX_TARGET_BYTES {
1438 return Err(SsrfOastContractError::MutationInvariant);
1439 }
1440 Ok(target)
1441}
1442
1443fn encode_query_component(bytes: &[u8]) -> String {
1444 const HEX: &[u8; 16] = b"0123456789ABCDEF";
1445 let mut encoded = String::with_capacity(bytes.len().saturating_mul(3));
1446 for byte in bytes {
1447 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
1448 encoded.push(char::from(*byte));
1449 } else {
1450 encoded.push('%');
1451 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
1452 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
1453 }
1454 }
1455 encoded
1456}
1457
1458fn parse_declared_target_origin(source: &str) -> Result<Url, SsrfOastReviewPolicyError> {
1459 if source.is_empty() || source.len() > MAX_TARGET_BYTES || !source.is_ascii() {
1460 return Err(SsrfOastReviewPolicyError::TargetOriginMismatch);
1461 }
1462 let parsed = Url::parse(source).map_err(|_| SsrfOastReviewPolicyError::TargetOriginMismatch)?;
1463 if !matches!(parsed.scheme(), "http" | "https")
1464 || parsed.host().is_none()
1465 || !parsed.username().is_empty()
1466 || parsed.password().is_some()
1467 || parsed.path() != "/"
1468 || parsed.query().is_some()
1469 || parsed.fragment().is_some()
1470 {
1471 return Err(SsrfOastReviewPolicyError::TargetOriginMismatch);
1472 }
1473 Ok(parsed)
1474}
1475
1476fn canonical_target_origin(source: &Url) -> Option<Url> {
1477 if !matches!(source.scheme(), "http" | "https")
1478 || source.host().is_none()
1479 || !source.username().is_empty()
1480 || source.password().is_some()
1481 {
1482 return None;
1483 }
1484 let mut origin = source.clone();
1485 origin.set_path("/");
1486 origin.set_query(None);
1487 origin.set_fragment(None);
1488 Some(origin)
1489}
1490
1491fn same_origin(left: &Url, right: &Url) -> bool {
1492 left.scheme() == right.scheme()
1493 && normalized_host(left) == normalized_host(right)
1494 && left.port_or_known_default() == right.port_or_known_default()
1495}
1496
1497fn normalized_host(url: &Url) -> Option<String> {
1498 match url.host()? {
1499 Host::Domain(value) => Some(value.to_ascii_lowercase()),
1500 Host::Ipv4(value) => Some(value.to_string()),
1501 Host::Ipv6(value) => Some(value.to_string()),
1502 }
1503}
1504
1505fn canonical_origin_string(url: &Url) -> String {
1506 canonical_target_origin(url).map_or_else(String::new, |value| value.to_string())
1507}
1508
1509fn policy_identity(
1510 target: &Url,
1511 provider: &Url,
1512 polls_per_leg: u16,
1513 poll_interval_ms: u64,
1514 lifetime_ms: u64,
1515) -> SsrfOastPolicyId {
1516 SsrfOastPolicyId(digest_many(
1517 POLICY_ID_DOMAIN,
1518 &[
1519 SSRF_OAST_REVIEW_POLICY_SCHEMA.as_bytes(),
1520 SSRF_OAST_REVIEW_ALGORITHM.as_bytes(),
1521 target.as_str().as_bytes(),
1522 provider.as_str().as_bytes(),
1523 &polls_per_leg.to_be_bytes(),
1524 &poll_interval_ms.to_be_bytes(),
1525 &lifetime_ms.to_be_bytes(),
1526 ],
1527 ))
1528}
1529
1530fn prefixed_digest(prefix: &str, domain: &[u8], values: &[&[u8]]) -> String {
1531 format!("{prefix}{}", hex(digest_many(domain, values)))
1532}
1533
1534fn digest_many(domain: &[u8], values: &[&[u8]]) -> [u8; 32] {
1535 let mut digest = Sha256::new();
1536 update_framed(&mut digest, domain);
1537 for value in values {
1538 update_framed(&mut digest, value);
1539 }
1540 digest.finalize().into()
1541}
1542
1543fn digest_parts(domain: &[u8], context: &[&[u8]], entropy: &[u8; 32]) -> [u8; 32] {
1544 let mut values = context.to_vec();
1545 values.push(entropy);
1546 digest_many(domain, &values)
1547}
1548
1549fn digest_bytes(domain: &[u8], value: &[u8]) -> [u8; 32] {
1550 digest_many(domain, &[value])
1551}
1552
1553fn update_framed(digest: &mut Sha256, value: &[u8]) {
1554 digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
1555 digest.update(value);
1556}
1557
1558fn hex(bytes: [u8; 32]) -> String {
1559 const HEX: &[u8; 16] = b"0123456789abcdef";
1560 let mut output = String::with_capacity(64);
1561 for byte in bytes {
1562 output.push(char::from(HEX[usize::from(byte >> 4)]));
1563 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1564 }
1565 output
1566}
1567
1568#[cfg(test)]
1569mod tests {
1570 use super::*;
1571 #[cfg(feature = "openapi-review")]
1572 use crate::openapi_review::{parse_openapi_document, OpenApiParseOutcome};
1573 #[cfg(feature = "openapi-review")]
1574 use serde_json::json;
1575
1576 const TARGET: &str = "https://target.example.test/";
1577 const PROVIDER: &str = "https://oast.example.test/";
1578 const ADMIN_SENTINEL: &[u8] = b"SSRF-OAST-ADMIN-MUST-NOT-LEAK-71B4A9";
1579
1580 fn policy_source() -> Vec<u8> {
1581 format!(
1582 "schema = \"{SSRF_OAST_REVIEW_POLICY_SCHEMA}\"\n\
1583 target_origin = \"{TARGET}\"\n\
1584 provider_origin = \"{PROVIDER}\"\n\
1585 acknowledge_external_interaction = true\n\
1586 polls_per_leg = 3\n\
1587 poll_interval_ms = 1000\n\
1588 lifetime_ms = 20000\n"
1589 )
1590 .into_bytes()
1591 }
1592
1593 fn policy() -> SsrfOastReviewPolicy {
1594 SsrfOastReviewPolicy::parse_toml(&Url::parse(TARGET).unwrap(), &policy_source()).unwrap()
1595 }
1596
1597 fn observed(url: &str) -> SsrfOastQueryCandidate {
1598 let SsrfOastCandidateSelection::Selected(candidate) = select_observed_query_candidate(
1599 &Url::parse(TARGET).unwrap(),
1600 &Url::parse(url).unwrap(),
1601 "subject-sha256:abc",
1602 true,
1603 true,
1604 true,
1605 ) else {
1606 panic!("expected candidate")
1607 };
1608 *candidate
1609 }
1610
1611 fn callback(index: u8) -> String {
1612 let session = if index == 1 {
1613 "AQEBAQEBAQEBAQEBAQEBAQ"
1614 } else {
1615 "AgICAgICAgICAgICAgICAg"
1616 };
1617 let callback = if index == 1 {
1618 "AwMDAwMDAwMDAwMDAwMDAw"
1619 } else {
1620 "BAQEBAQEBAQEBAQEBAQEBA"
1621 };
1622 format!("{PROVIDER}c/{session}/{callback}")
1623 }
1624
1625 #[test]
1626 fn strict_policy_parses_and_redacts_origins() {
1627 let policy = policy();
1628 assert_eq!(policy.polls_per_leg(), 3);
1629 assert_eq!(policy.poll_interval_ms(), 1_000);
1630 assert_eq!(policy.lifetime_ms(), 20_000);
1631 assert!(policy
1632 .policy_id()
1633 .to_wire()
1634 .starts_with("ssrf-oast-policy-sha256:"));
1635 let debug = format!("{policy:?}");
1636 assert!(!debug.contains("target.example"));
1637 assert!(!debug.contains("oast.example"));
1638 }
1639
1640 #[test]
1641 fn policy_parser_rejects_unknown_schema_field_and_false_acknowledgement() {
1642 let target = Url::parse(TARGET).unwrap();
1643 let mut unknown = String::from_utf8(policy_source()).unwrap();
1644 unknown.push_str("payload = \"forbidden\"\n");
1645 assert_eq!(
1646 SsrfOastReviewPolicy::parse_toml(&target, unknown.as_bytes()).unwrap_err(),
1647 SsrfOastReviewPolicyError::MalformedPolicy
1648 );
1649 for (needle, replacement, expected) in [
1650 (
1651 SSRF_OAST_REVIEW_POLICY_SCHEMA,
1652 "security.ssrf-oast-review-policy/v2",
1653 SsrfOastReviewPolicyError::UnsupportedSchema,
1654 ),
1655 (
1656 "acknowledge_external_interaction = true",
1657 "acknowledge_external_interaction = false",
1658 SsrfOastReviewPolicyError::AcknowledgementRequired,
1659 ),
1660 ] {
1661 let changed = String::from_utf8(policy_source())
1662 .unwrap()
1663 .replace(needle, replacement);
1664 assert_eq!(
1665 SsrfOastReviewPolicy::parse_toml(&target, changed.as_bytes()).unwrap_err(),
1666 expected
1667 );
1668 }
1669 }
1670
1671 #[test]
1672 fn policy_limits_are_closed_and_inclusive() {
1673 let target = Url::parse(TARGET).unwrap();
1674 for (needle, values) in [
1675 (
1676 "polls_per_leg = 3",
1677 vec!["polls_per_leg = 0", "polls_per_leg = 5"],
1678 ),
1679 (
1680 "poll_interval_ms = 1000",
1681 vec!["poll_interval_ms = 249", "poll_interval_ms = 2001"],
1682 ),
1683 (
1684 "lifetime_ms = 20000",
1685 vec!["lifetime_ms = 4999", "lifetime_ms = 30001"],
1686 ),
1687 ] {
1688 for replacement in values {
1689 let changed = String::from_utf8(policy_source())
1690 .unwrap()
1691 .replace(needle, replacement);
1692 assert_eq!(
1693 SsrfOastReviewPolicy::parse_toml(&target, changed.as_bytes()).unwrap_err(),
1694 SsrfOastReviewPolicyError::InvalidLimits
1695 );
1696 }
1697 }
1698 for changed in [
1699 String::from_utf8(policy_source())
1700 .unwrap()
1701 .replace("polls_per_leg = 3", "polls_per_leg = 1"),
1702 String::from_utf8(policy_source())
1703 .unwrap()
1704 .replace("polls_per_leg = 3", "polls_per_leg = 4"),
1705 ] {
1706 SsrfOastReviewPolicy::parse_toml(&target, changed.as_bytes()).unwrap();
1707 }
1708 }
1709
1710 #[test]
1711 fn policy_requires_exact_distinct_origins() {
1712 let target = Url::parse(TARGET).unwrap();
1713 let mismatched = String::from_utf8(policy_source())
1714 .unwrap()
1715 .replace(TARGET, "https://other.example.test/");
1716 assert_eq!(
1717 SsrfOastReviewPolicy::parse_toml(&target, mismatched.as_bytes()).unwrap_err(),
1718 SsrfOastReviewPolicyError::TargetOriginMismatch
1719 );
1720 let same = String::from_utf8(policy_source())
1721 .unwrap()
1722 .replace(PROVIDER, TARGET);
1723 assert_eq!(
1724 SsrfOastReviewPolicy::parse_toml(&target, same.as_bytes()).unwrap_err(),
1725 SsrfOastReviewPolicyError::ProviderOriginMatchesTarget
1726 );
1727 for invalid in [
1728 "http://oast.example.test/",
1729 "https://127.0.0.1/",
1730 "https://oast.example.test/path",
1731 "https://oast.example.test/?secret=yes",
1732 ] {
1733 let changed = String::from_utf8(policy_source())
1734 .unwrap()
1735 .replace(PROVIDER, invalid);
1736 assert_eq!(
1737 SsrfOastReviewPolicy::parse_toml(&target, changed.as_bytes()).unwrap_err(),
1738 SsrfOastReviewPolicyError::InvalidProviderOrigin
1739 );
1740 }
1741 }
1742
1743 #[test]
1744 fn policy_is_bounded_and_identity_is_semantic() {
1745 let target = Url::parse(TARGET).unwrap();
1746 assert_eq!(
1747 SsrfOastReviewPolicy::parse_toml(
1748 &target,
1749 &vec![b'a'; MAX_SSRF_OAST_REVIEW_POLICY_BYTES + 1]
1750 )
1751 .unwrap_err(),
1752 SsrfOastReviewPolicyError::PolicyTooLarge
1753 );
1754 assert_eq!(
1755 SsrfOastReviewPolicy::parse_toml(&target, &[0xff]).unwrap_err(),
1756 SsrfOastReviewPolicyError::MalformedPolicy
1757 );
1758 let first = policy();
1759 let reordered = format!(
1760 "lifetime_ms=20000\npolls_per_leg=3\nschema=\"{SSRF_OAST_REVIEW_POLICY_SCHEMA}\"\n\
1761 provider_origin=\"{PROVIDER}\"\nacknowledge_external_interaction=true\n\
1762 target_origin=\"{TARGET}\"\npoll_interval_ms=1000\n"
1763 );
1764 let second = SsrfOastReviewPolicy::parse_toml(&target, reordered.as_bytes()).unwrap();
1765 assert_eq!(first.policy_id(), second.policy_id());
1766 let changed = String::from_utf8(policy_source())
1767 .unwrap()
1768 .replace("lifetime_ms = 20000", "lifetime_ms = 21000");
1769 assert_ne!(
1770 first.policy_id(),
1771 SsrfOastReviewPolicy::parse_toml(&target, changed.as_bytes())
1772 .unwrap()
1773 .policy_id()
1774 );
1775 }
1776
1777 #[test]
1778 fn administrator_secret_is_move_only_bounded_and_redacted() {
1779 let token = SsrfOastAdminToken::new(ADMIN_SENTINEL.to_vec()).unwrap();
1780 assert_eq!(format!("{token:?}"), "SsrfOastAdminToken(<redacted>)");
1781 assert_eq!(token.into_bytes(), ADMIN_SENTINEL);
1782 for invalid in [
1783 vec![b'a'; MIN_SSRF_OAST_ADMIN_TOKEN_BYTES - 1],
1784 vec![b'a'; MAX_SSRF_OAST_ADMIN_TOKEN_BYTES + 1],
1785 [vec![b'a'; MIN_SSRF_OAST_ADMIN_TOKEN_BYTES], vec![b'\n']].concat(),
1786 ] {
1787 assert_eq!(
1788 SsrfOastAdminToken::new(invalid).unwrap_err(),
1789 SsrfOastAdminTokenError::Invalid
1790 );
1791 }
1792 assert!(!format!("{:?}", SsrfOastAdminTokenError::Invalid).contains("MUST-NOT-LEAK"));
1793 }
1794
1795 #[test]
1796 fn observed_candidate_requires_one_absolute_url_occurrence() {
1797 let selected = observed(
1798 "https://target.example.test/fetch?keep=a%2Fb&url=https%3A%2F%2Fold.example%2Fx&tail=z",
1799 );
1800 assert_eq!(selected.source(), SsrfOastCandidateSource::ObservedUrlQuery);
1801 assert!(selected
1802 .parameter_id()
1803 .starts_with("ssrf-oast-parameter-sha256:"));
1804 assert!(selected
1805 .selection_id()
1806 .starts_with("ssrf-oast-selection-sha256:"));
1807 let debug = format!("{selected:?}");
1808 assert!(!debug.contains("old.example"));
1809 assert!(!debug.contains("/fetch"));
1810 }
1811
1812 #[test]
1813 fn observed_candidate_rejects_ambiguity_and_unproven_inputs() {
1814 let origin = Url::parse(TARGET).unwrap();
1815 for value in [
1816 "https://target.example.test/no-query",
1817 "https://target.example.test/?next=relative/path",
1818 "https://target.example.test/?next=https%3A%2F%2Fx.test%2F%23fragment",
1819 "https://target.example.test/?next=https%3A%2F%2Fx.test%2F&next=https%3A%2F%2Fy.test%2F",
1820 "https://target.example.test/?next=https%3A%2F%2Fx.test%2F&return=https%3A%2F%2Fy.test%2F",
1821 "https://target.example.test/?ne%26xt=https%3A%2F%2Fx.test%2F",
1822 "https://target.example.test/?next=https%ZZ",
1823 "https://other.example.test/?next=https%3A%2F%2Fx.test%2F",
1824 ] {
1825 assert!(matches!(
1826 select_observed_query_candidate(
1827 &origin,
1828 &Url::parse(value).unwrap(),
1829 "subject",
1830 true,
1831 true,
1832 true,
1833 ),
1834 SsrfOastCandidateSelection::NotEligible
1835 ));
1836 }
1837 let resource =
1838 Url::parse("https://target.example.test/?next=https%3A%2F%2Fx.test%2F").unwrap();
1839 for flags in [
1840 (false, true, true),
1841 (true, false, true),
1842 (true, true, false),
1843 ] {
1844 assert!(matches!(
1845 select_observed_query_candidate(
1846 &origin, &resource, "subject", flags.0, flags.1, flags.2,
1847 ),
1848 SsrfOastCandidateSelection::NotEligible
1849 ));
1850 }
1851 }
1852
1853 #[cfg(feature = "openapi-review")]
1854 #[test]
1855 fn observed_ranking_is_deterministic_and_prefers_observed() {
1856 let first = observed("https://target.example.test/x?b=https%3A%2F%2Fb.test%2F");
1857 let second = observed("https://target.example.test/x?b=https%3A%2F%2Fb.test%2F");
1858 assert_eq!(first.parameter_id(), second.parameter_id());
1859 let mut openapi = observed("https://target.example.test/y?u=https%3A%2F%2Fu.test%2F");
1860 openapi.source = SsrfOastCandidateSource::OpenApiOptionalUrlQuery;
1861 let SsrfOastCandidateSelection::Selected(chosen) =
1862 choose_ssrf_oast_query_candidate(Some(first), Some(openapi))
1863 else {
1864 panic!("expected candidate")
1865 };
1866 assert_eq!(chosen.source(), SsrfOastCandidateSource::ObservedUrlQuery);
1867 }
1868
1869 #[cfg(feature = "openapi-review")]
1870 fn openapi_operation(value: serde_json::Value) -> OpenApiOperation {
1871 let document_url = Url::parse("https://target.example.test/openapi.json").unwrap();
1872 let OpenApiParseOutcome::Complete(document) =
1873 parse_openapi_document(&serde_json::to_vec(&value).unwrap(), &document_url)
1874 else {
1875 panic!("expected complete document")
1876 };
1877 document.catalog().operations()[0].clone()
1878 }
1879
1880 #[cfg(feature = "openapi-review")]
1881 #[test]
1882 fn openapi_candidate_requires_exact_optional_url_query_proof() {
1883 let operation = openapi_operation(json!({
1884 "openapi": "3.1.0",
1885 "info": {"title": "fixture", "version": "1"},
1886 "paths": {"/fetch": {"get": {
1887 "parameters": [{"name":"destination","in":"query","required":false,
1888 "schema":{"type":"string","format":"uri"}}],
1889 "responses":{"200":{"content":{"application/json":{"schema":{}}}}}
1890 }}}
1891 }));
1892 let result = select_openapi_query_candidate(
1893 &Url::parse(TARGET).unwrap(),
1894 &Url::parse("https://target.example.test/fetch").unwrap(),
1895 &operation,
1896 "destination",
1897 true,
1898 true,
1899 true,
1900 );
1901 let SsrfOastCandidateSelection::Selected(candidate) = result else {
1902 panic!("expected OpenAPI candidate")
1903 };
1904 assert_eq!(
1905 candidate.source(),
1906 SsrfOastCandidateSource::OpenApiOptionalUrlQuery
1907 );
1908 assert!(matches!(
1909 select_openapi_query_candidate(
1910 &Url::parse(TARGET).unwrap(),
1911 &Url::parse("https://target.example.test/fetch").unwrap(),
1912 &operation,
1913 "guessed-name",
1914 true,
1915 true,
1916 true,
1917 ),
1918 SsrfOastCandidateSelection::NotEligible
1919 ));
1920 }
1921
1922 #[cfg(feature = "openapi-review")]
1923 #[test]
1924 fn openapi_rejects_required_body_auth_and_cross_origin_metadata() {
1925 let cases = [
1926 json!({"parameters":[{"name":"destination","in":"query","required":true,
1927 "schema":{"type":"string","format":"url"}}]}),
1928 json!({"requestBody":{"content":{"application/json":{}}},
1929 "parameters":[{"name":"destination","in":"query","required":false,
1930 "schema":{"type":"string","format":"url"}}]}),
1931 json!({"security":[{"bearer":[]}],
1932 "parameters":[{"name":"destination","in":"query","required":false,
1933 "schema":{"type":"string","format":"url"}}]}),
1934 json!({"servers":[{"url":"https://other.example.test"}],
1935 "parameters":[{"name":"destination","in":"query","required":false,
1936 "schema":{"type":"string","format":"url"}}]}),
1937 ];
1938 for mut body in cases {
1939 let mut root = json!({
1940 "openapi":"3.0.3",
1941 "info":{"title":"fixture","version":"1"},
1942 "components":{"securitySchemes":{"bearer":{"type":"http","scheme":"bearer"}}},
1943 "paths":{"/fetch":{"get":{"responses":{"200":{"description":"ok"}}}}}
1944 });
1945 root["paths"]["/fetch"]["get"]
1946 .as_object_mut()
1947 .unwrap()
1948 .append(body.as_object_mut().unwrap());
1949 let operation = openapi_operation(root);
1950 assert!(matches!(
1951 select_openapi_query_candidate(
1952 &Url::parse(TARGET).unwrap(),
1953 &Url::parse("https://target.example.test/fetch").unwrap(),
1954 &operation,
1955 "destination",
1956 true,
1957 true,
1958 true,
1959 ),
1960 SsrfOastCandidateSelection::NotEligible
1961 ));
1962 }
1963 }
1964
1965 #[test]
1966 fn mutation_plan_preserves_unrelated_query_pairs_and_uses_invalid_control() {
1967 let selected = observed(
1968 "https://target.example.test/fetch?keep=a%2Fb&url=https%3A%2F%2Fold.example%2Fx&tail=z+q",
1969 );
1970 let provider = PublicOrigin::from_str(PROVIDER).unwrap();
1971 let control = selected.control_execution_url([7; 32]).unwrap();
1972 let plan = SsrfOastMutationPlan::from_callback_strings(
1973 selected,
1974 [7; 32],
1975 &callback(1),
1976 &callback(2),
1977 &provider,
1978 )
1979 .unwrap();
1980 let candidate = plan.execution_url(SsrfOastTargetLeg::Candidate);
1981 let replay = plan.execution_url(SsrfOastTargetLeg::Replay);
1982 assert!(control.query().unwrap().contains(".invalid%2F"));
1983 let control_value = control
1984 .query_pairs()
1985 .find(|(name, _)| name == "url")
1986 .map(|(_, value)| value.into_owned())
1987 .unwrap();
1988 let control_url = Url::parse(&control_value).unwrap();
1989 assert!(control_url
1990 .domain()
1991 .unwrap()
1992 .split('.')
1993 .next()
1994 .is_some_and(|label| label.len() <= 63));
1995 assert!(control.query().unwrap().starts_with("keep=a%2Fb&url="));
1996 assert!(control.query().unwrap().ends_with("&tail=z+q"));
1997 assert_ne!(candidate, replay);
1998 assert!(candidate.query().unwrap().contains("oast.example.test"));
1999 assert_eq!(candidate.path(), "/fetch");
2000 let debug = format!("{plan:?}");
2001 assert!(!debug.contains("oast.example"));
2002 assert!(!debug.contains("target.example"));
2003 }
2004
2005 #[cfg(feature = "openapi-review")]
2006 #[test]
2007 fn openapi_materialization_adds_only_the_proven_query() {
2008 let operation = openapi_operation(json!({
2009 "openapi":"3.0.3","info":{"title":"fixture","version":"1"},
2010 "paths":{"/fetch":{"get":{
2011 "parameters":[{"name":"destination","in":"query","required":false,
2012 "schema":{"type":"string","format":"url"}}],
2013 "responses":{"200":{"description":"ok"}}
2014 }}}
2015 }));
2016 let SsrfOastCandidateSelection::Selected(selected) = select_openapi_query_candidate(
2017 &Url::parse(TARGET).unwrap(),
2018 &Url::parse("https://target.example.test/fetch").unwrap(),
2019 &operation,
2020 "destination",
2021 true,
2022 true,
2023 true,
2024 ) else {
2025 panic!("expected selection")
2026 };
2027 let provider = PublicOrigin::from_str(PROVIDER).unwrap();
2028 let plan = SsrfOastMutationPlan::from_callback_strings(
2029 *selected,
2030 [9; 32],
2031 &callback(1),
2032 &callback(2),
2033 &provider,
2034 )
2035 .unwrap();
2036 assert!(plan
2037 .execution_url(SsrfOastTargetLeg::Candidate)
2038 .query()
2039 .unwrap()
2040 .starts_with("destination=https%3A%2F%2F"));
2041 }
2042
2043 #[test]
2044 fn mutation_plan_rejects_bad_or_reused_callback_targets() {
2045 let provider = PublicOrigin::from_str(PROVIDER).unwrap();
2046 assert_eq!(
2047 SsrfOastMutationPlan::from_callback_strings(
2048 observed("https://target.example.test/?url=https%3A%2F%2Fold.test%2F"),
2049 [1; 32],
2050 &callback(1),
2051 &callback(1),
2052 &provider,
2053 )
2054 .unwrap_err(),
2055 SsrfOastContractError::CallbackIdentityConflict
2056 );
2057 for invalid in [
2058 "http://oast.example.test/c/AQEBAQEBAQEBAQEBAQEBAQ/AwMDAwMDAwMDAwMDAwMDAw",
2059 "https://other.example.test/c/AQEBAQEBAQEBAQEBAQEBAQ/AwMDAwMDAwMDAwMDAwMDAw",
2060 "https://oast.example.test/not-a-callback",
2061 "https://oast.example.test/c/AQEBAQEBAQEBAQEBAQEBAQ/AwMDAwMDAwMDAwMDAwMDAw?x=1",
2062 ] {
2063 assert_eq!(
2064 SsrfOastMutationPlan::from_callback_strings(
2065 observed("https://target.example.test/?url=https%3A%2F%2Fold.test%2F"),
2066 [1; 32],
2067 invalid,
2068 &callback(2),
2069 &provider,
2070 )
2071 .unwrap_err(),
2072 SsrfOastContractError::InvalidCallbackTarget
2073 );
2074 }
2075 }
2076
2077 #[test]
2078 fn correlation_material_is_distinct_bound_and_redacted() {
2079 let candidate = observed("https://target.example.test/?url=https%3A%2F%2Fold.test%2F");
2080 let material = SsrfOastCorrelationMaterial::derive(
2081 &policy(),
2082 &candidate,
2083 SsrfOastCorrelationBinding::new("assessment-1", "web.review.ssrf-oast-query", "case-1"),
2084 SsrfOastCorrelationEntropy::new([1; 32], [2; 32], [3; 32]),
2085 )
2086 .unwrap();
2087 assert_eq!(
2088 format!("{material:?}"),
2089 "SsrfOastCorrelationMaterial(<redacted>)"
2090 );
2091 let (_epoch, candidate_token, replay_token) = material.into_parts();
2092 assert_eq!(
2093 format!("{candidate_token:?}"),
2094 "OastCorrelationToken(<redacted>)"
2095 );
2096 assert_eq!(
2097 format!("{replay_token:?}"),
2098 "OastCorrelationToken(<redacted>)"
2099 );
2100 assert_eq!(
2101 SsrfOastCorrelationMaterial::derive(
2102 &policy(),
2103 &candidate,
2104 SsrfOastCorrelationBinding::new("assessment-1", "action", "case"),
2105 SsrfOastCorrelationEntropy::new([1; 32], [2; 32], [2; 32]),
2106 )
2107 .unwrap_err(),
2108 SsrfOastContractError::InvalidCorrelationMaterial
2109 );
2110 }
2111
2112 fn callback_identity(byte: u8) -> SsrfOastCallbackIdentity {
2113 SsrfOastCallbackIdentity([byte; 32])
2114 }
2115
2116 fn event(callback: SsrfOastCallbackIdentity, byte: u8) -> SsrfOastObservedEvent {
2117 SsrfOastObservedEvent {
2118 callback,
2119 event: [byte; 32],
2120 }
2121 }
2122
2123 fn positive_facts() -> SsrfOastReviewFacts {
2124 SsrfOastReviewFacts {
2125 control_complete: true,
2126 provider_registered: true,
2127 allocations_complete: true,
2128 preflight_clean: true,
2129 candidate_dispatched: true,
2130 replay_dispatched: true,
2131 expected_candidate: callback_identity(1),
2132 expected_replay: callback_identity(2),
2133 candidate_event: Some(event(callback_identity(1), 3)),
2134 replay_event: Some(event(callback_identity(2), 4)),
2135 correlations_distinct: true,
2136 same_correlation_scope: true,
2137 duplicate_only_substitution: false,
2138 cleanup_verified: true,
2139 target_accounting_complete: true,
2140 provider_accounting_complete: true,
2141 truncated: false,
2142 terminal: None,
2143 }
2144 }
2145
2146 #[test]
2147 fn policy_and_review_debug_boundaries_remain_raw_free() {
2148 let policy = policy();
2149 let policy_id = policy.policy_id();
2150 assert_ne!(policy_id.as_bytes(), [0; 32]);
2151 assert_eq!(policy_id.to_string(), policy_id.to_wire());
2152
2153 let selected = SsrfOastCandidateSelection::Selected(Box::new(observed(
2154 "https://target.example.test/fetch?url=https%3A%2F%2Fold.example%2Fresource",
2155 )));
2156 let selected_debug = format!("{selected:?}");
2157 assert!(selected_debug.starts_with("Selected("));
2158 assert!(!selected_debug.contains("target.example"));
2159 assert!(!selected_debug.contains("old.example"));
2160 assert_eq!(
2161 format!("{:?}", SsrfOastCandidateSelection::NotEligible),
2162 "NotEligible"
2163 );
2164
2165 let candidate =
2166 observed("https://target.example.test/fetch?url=https%3A%2F%2Fold.example%2Fresource");
2167 assert_eq!(
2168 candidate.control_execution_url([0; 32]).unwrap_err(),
2169 SsrfOastContractError::InvalidControlSeed
2170 );
2171
2172 let callback = callback_identity(7);
2173 assert_eq!(
2174 format!("{callback:?}"),
2175 "SsrfOastCallbackIdentity(<redacted>)"
2176 );
2177 let observed_event = event(callback, 9);
2178 assert_eq!(
2179 format!("{observed_event:?}"),
2180 "SsrfOastObservedEvent(<redacted>)"
2181 );
2182
2183 let facts_debug = format!("{:?}", positive_facts());
2184 assert!(facts_debug.contains("control_complete: true"));
2185 assert!(facts_debug.contains("candidate_event: true"));
2186 assert!(facts_debug.contains("target_accounting_complete: true"));
2187 assert!(!facts_debug.contains("target.example"));
2188 assert!(!facts_debug.contains("old.example"));
2189 }
2190
2191 #[test]
2192 fn loopback_policy_and_origin_helpers_fail_closed() {
2193 let target = Url::parse(TARGET).unwrap();
2194 assert_eq!(
2195 SsrfOastReviewPolicy::for_loopback(
2196 target.clone(),
2197 PublicOrigin::from_str(PROVIDER).unwrap(),
2198 0,
2199 1_000,
2200 20_000,
2201 )
2202 .unwrap_err(),
2203 SsrfOastReviewPolicyError::InvalidLimits
2204 );
2205 assert_eq!(
2206 SsrfOastReviewPolicy::for_loopback(
2207 target,
2208 PublicOrigin::from_str(TARGET).unwrap(),
2209 3,
2210 1_000,
2211 20_000,
2212 )
2213 .unwrap_err(),
2214 SsrfOastReviewPolicyError::ProviderOriginMatchesTarget
2215 );
2216
2217 let ipv6_loopback = Url::parse("http://[::1]:8080/").unwrap();
2218 let ipv6_public = Url::parse("http://[2001:db8::1]:8080/").unwrap();
2219 assert!(is_http_loopback_origin(&ipv6_loopback));
2220 assert!(!is_http_loopback_origin(&ipv6_public));
2221 assert!(!is_http_loopback_origin(
2222 &Url::parse("http://localhost:8080/").unwrap()
2223 ));
2224 assert!(normalized_host(&ipv6_loopback).is_some_and(|host| host == "::1"));
2225
2226 assert_eq!(
2227 parse_declared_target_origin("").unwrap_err(),
2228 SsrfOastReviewPolicyError::TargetOriginMismatch
2229 );
2230 assert_eq!(
2231 parse_declared_target_origin("https://target.example.test/path").unwrap_err(),
2232 SsrfOastReviewPolicyError::TargetOriginMismatch
2233 );
2234 assert!(
2235 canonical_target_origin(&Url::parse("ftp://target.example.test/").unwrap()).is_none()
2236 );
2237 }
2238
2239 #[test]
2240 fn query_materialization_rejects_malformed_private_state() {
2241 assert!(parse_query_pairs("url=value&").is_err());
2242 assert!(parse_query_pairs("=value").is_err());
2243
2244 let mut inconsistent =
2245 observed("https://target.example.test/fetch?url=https%3A%2F%2Fold.example%2F");
2246 inconsistent.selected_pair_index = None;
2247 assert_eq!(
2248 materialize_query(&inconsistent, "https://replacement.example/").unwrap_err(),
2249 SsrfOastContractError::MutationInvariant
2250 );
2251
2252 let selected =
2253 observed("https://target.example.test/fetch?url=https%3A%2F%2Fold.example%2F");
2254 assert_eq!(
2255 materialize_query(&selected, &"x".repeat(MAX_TARGET_BYTES)).unwrap_err(),
2256 SsrfOastContractError::MutationInvariant
2257 );
2258 }
2259
2260 #[test]
2261 fn only_full_repeated_callback_contract_projects() {
2262 let outcome = evaluate_ssrf_oast_review(&positive_facts()).unwrap();
2263 assert_eq!(outcome, SsrfOastReviewOutcome::RepeatedCallbacksObserved);
2264 assert!(outcome.projects_item());
2265 let mut with_timeout = positive_facts();
2266 with_timeout.terminal = Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch);
2267 assert_eq!(
2268 evaluate_ssrf_oast_review(&with_timeout).unwrap(),
2269 SsrfOastReviewOutcome::RepeatedCallbacksObserved
2270 );
2271 }
2272
2273 #[test]
2274 fn one_sided_wrong_and_duplicate_events_never_project() {
2275 let mut candidate_only = positive_facts();
2276 candidate_only.replay_event = None;
2277 let mut replay_only = positive_facts();
2278 replay_only.candidate_event = None;
2279 let mut wrong = positive_facts();
2280 wrong.replay_event = Some(event(callback_identity(9), 4));
2281 let mut duplicate = positive_facts();
2282 duplicate.duplicate_only_substitution = true;
2283 let mut same_event = positive_facts();
2284 same_event.replay_event = Some(event(callback_identity(2), 3));
2285 for (facts, expected) in [
2286 (candidate_only, SsrfOastReviewOutcome::CandidateOnly),
2287 (replay_only, SsrfOastReviewOutcome::ReplayOnly),
2288 (wrong, SsrfOastReviewOutcome::WrongCallback),
2289 (duplicate, SsrfOastReviewOutcome::DuplicateOnly),
2290 (same_event, SsrfOastReviewOutcome::EventIdentityConflict),
2291 ] {
2292 let outcome = evaluate_ssrf_oast_review(&facts).unwrap();
2293 assert_eq!(outcome, expected);
2294 assert!(!outcome.projects_item());
2295 }
2296 }
2297
2298 #[test]
2299 fn lifecycle_incompleteness_and_interference_fail_closed() {
2300 type FactsMutationCase = (Box<dyn Fn(&mut SsrfOastReviewFacts)>, SsrfOastReviewOutcome);
2301 let mutations: Vec<FactsMutationCase> = vec![
2302 (
2303 Box::new(|facts| facts.control_complete = false),
2304 SsrfOastReviewOutcome::ControlIncomplete,
2305 ),
2306 (
2307 Box::new(|facts| facts.provider_registered = false),
2308 SsrfOastReviewOutcome::RegistrationIncomplete,
2309 ),
2310 (
2311 Box::new(|facts| facts.allocations_complete = false),
2312 SsrfOastReviewOutcome::AllocationIncomplete,
2313 ),
2314 (
2315 Box::new(|facts| facts.preflight_clean = false),
2316 SsrfOastReviewOutcome::PreflightContaminated,
2317 ),
2318 (
2319 Box::new(|facts| facts.replay_dispatched = false),
2320 SsrfOastReviewOutcome::TargetNotDispatched,
2321 ),
2322 (
2323 Box::new(|facts| facts.cleanup_verified = false),
2324 SsrfOastReviewOutcome::CleanupIncomplete,
2325 ),
2326 (
2327 Box::new(|facts| facts.truncated = true),
2328 SsrfOastReviewOutcome::Truncated,
2329 ),
2330 (
2331 Box::new(|facts| facts.provider_accounting_complete = false),
2332 SsrfOastReviewOutcome::Incomplete,
2333 ),
2334 (
2335 Box::new(|facts| facts.same_correlation_scope = false),
2336 SsrfOastReviewOutcome::CorrelationMismatch,
2337 ),
2338 ];
2339 for (mutate, expected) in mutations {
2340 let mut facts = positive_facts();
2341 mutate(&mut facts);
2342 let outcome = evaluate_ssrf_oast_review(&facts).unwrap();
2343 assert_eq!(outcome, expected);
2344 assert!(!outcome.projects_item());
2345 }
2346 }
2347
2348 #[test]
2349 fn every_terminal_provider_state_is_typed_and_nonpositive() {
2350 for (terminal, expected) in [
2351 (
2352 SsrfOastTerminalState::DefensiveInterference,
2353 SsrfOastReviewOutcome::DefensiveInterference,
2354 ),
2355 (
2356 SsrfOastTerminalState::RateLimited,
2357 SsrfOastReviewOutcome::RateLimited,
2358 ),
2359 (
2360 SsrfOastTerminalState::ProviderAuthenticationFailed,
2361 SsrfOastReviewOutcome::ProviderAuthenticationFailed,
2362 ),
2363 (
2364 SsrfOastTerminalState::MalformedProviderResponse,
2365 SsrfOastReviewOutcome::MalformedProviderResponse,
2366 ),
2367 (
2368 SsrfOastTerminalState::PollExhausted,
2369 SsrfOastReviewOutcome::PollExhausted,
2370 ),
2371 (
2372 SsrfOastTerminalState::Expired,
2373 SsrfOastReviewOutcome::Expired,
2374 ),
2375 (
2376 SsrfOastTerminalState::Cancelled,
2377 SsrfOastReviewOutcome::Cancelled,
2378 ),
2379 (
2380 SsrfOastTerminalState::BudgetExhausted,
2381 SsrfOastReviewOutcome::BudgetExhausted,
2382 ),
2383 (
2384 SsrfOastTerminalState::Incomplete,
2385 SsrfOastReviewOutcome::Incomplete,
2386 ),
2387 ] {
2388 let mut facts = positive_facts();
2389 facts.terminal = Some(terminal);
2390 let outcome = evaluate_ssrf_oast_review(&facts).unwrap();
2391 assert_eq!(outcome, expected);
2392 assert!(!outcome.projects_item());
2393 }
2394 }
2395
2396 #[test]
2397 fn callback_identity_conflict_is_an_internal_error() {
2398 let mut facts = positive_facts();
2399 facts.expected_replay = facts.expected_candidate.clone();
2400 assert_eq!(
2401 evaluate_ssrf_oast_review(&facts).unwrap_err(),
2402 SsrfOastContractError::CallbackIdentityConflict
2403 );
2404 }
2405}