1use std::{
8 fmt,
9 sync::{Arc, Mutex},
10 time::Duration,
11};
12
13use async_trait::async_trait;
14use serde::Serialize;
15use sha2::{Digest, Sha256};
16use termivar_core::{
17 ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
18 HttpEvidencePredicate, KnowledgePredicate,
19};
20use termivar_oast::{CallbackId, PublicOrigin};
21
22use super::{
23 assessment_defense::{project_assessment_defense_signal, AssessmentDefenseProjectionContext},
24 assessment_item::{
25 AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentItemTarget,
26 AssessmentProjectionContext, StableAssessmentSubjectId,
27 },
28 SharedWebRuntimeAuthority,
29};
30use crate::{
31 http_evidence::HttpRequestBrokerError,
32 native_oast_provider::{
33 NativeOastProviderAdapter, NativeOastProviderConfiguration, NativeOastProviderErrorKind,
34 NativeOastProviderLimits, NativeOastProviderOperation,
35 },
36 oast::{OastCorrelationId, OastEventDisposition},
37 ssrf_oast_review::{
38 evaluate_ssrf_oast_review, SsrfOastAdminToken, SsrfOastCandidateSelection,
39 SsrfOastCandidateSource, SsrfOastCorrelationBinding, SsrfOastCorrelationEntropy,
40 SsrfOastCorrelationMaterial, SsrfOastMutationPlan, SsrfOastObservedEvent,
41 SsrfOastQueryCandidate, SsrfOastReviewFacts, SsrfOastReviewOutcome, SsrfOastReviewPolicy,
42 SsrfOastTargetLeg, SsrfOastTerminalState, MAX_SSRF_OAST_PROVIDER_REQUESTS,
43 SSRF_OAST_ACTIVE_VERIFICATIONS, SSRF_OAST_TARGET_REQUESTS,
44 },
45 DecisionActionExecutor, DecisionActionOrigin, DecisionExecutionFailureKind,
46 DecisionExecutionRequest, DecisionExecutionStage, DecisionExecutorError,
47 DecisionExecutorRegistry, HttpProbe, HttpProbeMethod, KnowledgeBase, RuntimeLimitExceeded,
48 TransportDispatchAudit, TransportDispatchOutcome, VerificationCase,
49};
50
51pub const SSRF_OAST_REVIEW_ACTION_ID: &str = "web.review.ssrf.oast-query@1";
52pub const SSRF_OAST_REVIEW_CAPABILITY_ID: &str = "ssrf.oast-repeated-outbound-interaction@1";
53pub const MAX_SSRF_OAST_REVIEW_RESOURCES: usize = 1;
54pub const MAX_SSRF_OAST_REVIEW_PARAMETERS: usize = 1;
55pub const MAX_SSRF_OAST_REVIEW_REQUESTS: usize = SSRF_OAST_TARGET_REQUESTS;
56pub const MAX_SSRF_OAST_REVIEW_PROVIDER_REQUESTS: usize = MAX_SSRF_OAST_PROVIDER_REQUESTS;
57pub const MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS: usize = SSRF_OAST_ACTIVE_VERIFICATIONS;
58pub(super) const SSRF_OAST_REVIEW_ACTION_CYCLE_ALLOWANCE: u32 = 1;
59
60const SSRF_OAST_EXECUTOR_ID: &str = "http.ssrf-oast-review";
61const SSRF_OAST_EVIDENCE_NAMESPACE: &str = "web.ssrf-oast-review.transport";
62const CONTROL_COMPLETE: &str = "control-complete";
63const PROVIDER_REGISTERED: &str = "provider-registered";
64const ALLOCATIONS_COMPLETE: &str = "allocations-complete";
65const PREFLIGHT_CLEAN: &str = "preflight-clean";
66const CANDIDATE_DISPATCHED: &str = "candidate-dispatched";
67const CANDIDATE_CALLBACK_CORRELATED: &str = "candidate-callback-correlated";
68const REPLAY_DISPATCHED: &str = "replay-dispatched";
69const REPLAY_CALLBACK_CORRELATED: &str = "replay-callback-correlated";
70const REPEATED_CALLBACKS_CORRELATED: &str = "repeated-callbacks-correlated";
71const CLEANUP_VERIFIED: &str = "cleanup-verified";
72const TARGET_ACCOUNTING_COMPLETE: &str = "target-accounting-complete";
73const PROVIDER_ACCOUNTING_COMPLETE: &str = "provider-accounting-complete";
74const MAX_POST_DISPATCH_POLLS: u16 = 7;
75const PROVIDER_REQUEST_BYTES: u64 = 64 * 1024;
76const PROVIDER_RESPONSE_BYTES: u64 = 1024 * 1024;
77
78const SSRF_OAST_CAPABILITY: AssessmentCapabilityDescriptor =
79 AssessmentCapabilityDescriptor::differential_review(
80 SSRF_OAST_REVIEW_CAPABILITY_ID,
81 "Repeated out-of-band interaction observed",
82 "server-side request behavior",
83 "Two independently allocated callback targets received exact correlated HTTP interactions after candidate and replay query mutations; exploitability and business impact require review.",
84 None,
85 1_000_000,
86 Some("CWE-918"),
87 "web.remediation.outbound-request-policy@1",
88 "Constrain server-side outbound destinations and manually validate the exact request path before treating this observation as a vulnerability.",
89 );
90
91#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
93#[serde(rename_all = "snake_case")]
94pub enum SsrfOastRuntimeOutcome {
95 NotEligible,
96 ControlIncomplete,
97 RegistrationIncomplete,
98 AllocationIncomplete,
99 PreflightContaminated,
100 TargetNotDispatched,
101 NoCallback,
102 CandidateOnly,
103 ReplayOnly,
104 WrongCallback,
105 EventIdentityConflict,
106 CorrelationMismatch,
107 DuplicateOnly,
108 CleanupIncomplete,
109 DefensiveInterference,
110 RateLimited,
111 ProviderAuthenticationFailed,
112 MalformedProviderResponse,
113 PollExhausted,
114 Expired,
115 Cancelled,
116 BudgetExhausted,
117 Truncated,
118 Incomplete,
119 RepeatedCallbacksObserved,
120}
121
122impl From<SsrfOastReviewOutcome> for SsrfOastRuntimeOutcome {
123 fn from(value: SsrfOastReviewOutcome) -> Self {
124 match value {
125 SsrfOastReviewOutcome::NotEligible => Self::NotEligible,
126 SsrfOastReviewOutcome::ControlIncomplete => Self::ControlIncomplete,
127 SsrfOastReviewOutcome::RegistrationIncomplete => Self::RegistrationIncomplete,
128 SsrfOastReviewOutcome::AllocationIncomplete => Self::AllocationIncomplete,
129 SsrfOastReviewOutcome::PreflightContaminated => Self::PreflightContaminated,
130 SsrfOastReviewOutcome::TargetNotDispatched => Self::TargetNotDispatched,
131 SsrfOastReviewOutcome::NoCallback => Self::NoCallback,
132 SsrfOastReviewOutcome::CandidateOnly => Self::CandidateOnly,
133 SsrfOastReviewOutcome::ReplayOnly => Self::ReplayOnly,
134 SsrfOastReviewOutcome::WrongCallback => Self::WrongCallback,
135 SsrfOastReviewOutcome::EventIdentityConflict => Self::EventIdentityConflict,
136 SsrfOastReviewOutcome::CorrelationMismatch => Self::CorrelationMismatch,
137 SsrfOastReviewOutcome::DuplicateOnly => Self::DuplicateOnly,
138 SsrfOastReviewOutcome::CleanupIncomplete => Self::CleanupIncomplete,
139 SsrfOastReviewOutcome::DefensiveInterference => Self::DefensiveInterference,
140 SsrfOastReviewOutcome::RateLimited => Self::RateLimited,
141 SsrfOastReviewOutcome::ProviderAuthenticationFailed => {
142 Self::ProviderAuthenticationFailed
143 },
144 SsrfOastReviewOutcome::MalformedProviderResponse => Self::MalformedProviderResponse,
145 SsrfOastReviewOutcome::PollExhausted => Self::PollExhausted,
146 SsrfOastReviewOutcome::Expired => Self::Expired,
147 SsrfOastReviewOutcome::Cancelled => Self::Cancelled,
148 SsrfOastReviewOutcome::BudgetExhausted => Self::BudgetExhausted,
149 SsrfOastReviewOutcome::Truncated => Self::Truncated,
150 SsrfOastReviewOutcome::Incomplete => Self::Incomplete,
151 SsrfOastReviewOutcome::RepeatedCallbacksObserved => Self::RepeatedCallbacksObserved,
152 }
153 }
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
161#[non_exhaustive]
162#[serde(rename_all = "snake_case")]
163pub enum SsrfOastProviderFailure {
164 CredentialInputInvalid,
165 AccessRejected,
166 Throttled,
167 NotFound,
168 Gone,
169 RedirectRefused,
170 ServerFailure,
171 TransportFailure,
172 RequestConstruction,
173 ClientInitialization,
174 UnexpectedStatus,
175 MalformedResponse,
176 Cancelled,
177 Timeout,
178 Expired,
179 BudgetRejected,
180 PollLimit,
181 LocalRejected,
182 CleanupUnverified,
183 InternalInvariant,
184}
185
186impl From<NativeOastProviderErrorKind> for SsrfOastProviderFailure {
187 fn from(kind: NativeOastProviderErrorKind) -> Self {
188 match kind {
189 NativeOastProviderErrorKind::ProviderRejected => Self::CredentialInputInvalid,
190 NativeOastProviderErrorKind::ProviderAccessRejected => Self::AccessRejected,
191 NativeOastProviderErrorKind::ProviderThrottled => Self::Throttled,
192 NativeOastProviderErrorKind::ProviderNotFound => Self::NotFound,
193 NativeOastProviderErrorKind::ProviderGone => Self::Gone,
194 NativeOastProviderErrorKind::ProviderRedirectRefused => Self::RedirectRefused,
195 NativeOastProviderErrorKind::ProviderServerFailure => Self::ServerFailure,
196 NativeOastProviderErrorKind::ProviderTransportFailure => Self::TransportFailure,
197 NativeOastProviderErrorKind::ProviderRequestConstruction => Self::RequestConstruction,
198 NativeOastProviderErrorKind::ProviderClientInitialization => Self::ClientInitialization,
199 NativeOastProviderErrorKind::ProviderUnexpectedStatus => Self::UnexpectedStatus,
200 NativeOastProviderErrorKind::ProviderResponseInvalid
201 | NativeOastProviderErrorKind::ProviderSessionMismatch
202 | NativeOastProviderErrorKind::ProviderCallbackMismatch
203 | NativeOastProviderErrorKind::ProviderPageIncomplete
204 | NativeOastProviderErrorKind::CorrelationRejected => Self::MalformedResponse,
205 NativeOastProviderErrorKind::Cancelled => Self::Cancelled,
206 NativeOastProviderErrorKind::DeadlineExceeded => Self::Timeout,
207 NativeOastProviderErrorKind::ProviderExpired => Self::Expired,
208 NativeOastProviderErrorKind::RuntimeBudget(_)
209 | NativeOastProviderErrorKind::ParentBudgetTooSmall
210 | NativeOastProviderErrorKind::RequestLimit
211 | NativeOastProviderErrorKind::RequestByteLimit
212 | NativeOastProviderErrorKind::ResponseByteLimit => Self::BudgetRejected,
213 NativeOastProviderErrorKind::PollLimit => Self::PollLimit,
214 NativeOastProviderErrorKind::CleanupUnverified => Self::CleanupUnverified,
215 NativeOastProviderErrorKind::InternalInvariant => Self::InternalInvariant,
216 NativeOastProviderErrorKind::InvalidLimits
217 | NativeOastProviderErrorKind::InvalidProviderOrigin
218 | NativeOastProviderErrorKind::ProviderTargetOriginOverlap
219 | NativeOastProviderErrorKind::AuthorityAlreadyMinted
220 | NativeOastProviderErrorKind::OperationNotPermitted
221 | NativeOastProviderErrorKind::InvalidLifecycle
222 | NativeOastProviderErrorKind::RegistrationLimit
223 | NativeOastProviderErrorKind::CallbackLimit => Self::LocalRejected,
224 }
225 }
226}
227
228#[derive(Clone, Eq, PartialEq, Serialize)]
230pub struct WebAssessmentSsrfOastAudit {
231 outcome: SsrfOastRuntimeOutcome,
232 policy_id: String,
233 candidate_source: Option<&'static str>,
234 target_request_count: u8,
235 provider_request_count: u8,
236 #[serde(skip_serializing_if = "Option::is_none")]
237 provider_failure: Option<SsrfOastProviderFailure>,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 cleanup_failure: Option<SsrfOastProviderFailure>,
240 active_verification_count: u8,
241 preflight_clean: bool,
242 candidate_callback_observed: bool,
243 replay_callback_observed: bool,
244 cleanup_verified: bool,
245 item_projected: bool,
246}
247
248impl WebAssessmentSsrfOastAudit {
249 pub const fn outcome(&self) -> SsrfOastRuntimeOutcome {
250 self.outcome
251 }
252 pub fn policy_id(&self) -> &str {
253 &self.policy_id
254 }
255 pub const fn candidate_source(&self) -> Option<&'static str> {
256 self.candidate_source
257 }
258 pub const fn target_request_count(&self) -> u8 {
259 self.target_request_count
260 }
261 pub const fn provider_request_count(&self) -> u8 {
264 self.provider_request_count
265 }
266 pub const fn provider_failure(&self) -> Option<SsrfOastProviderFailure> {
268 self.provider_failure
269 }
270 pub const fn cleanup_failure(&self) -> Option<SsrfOastProviderFailure> {
272 self.cleanup_failure
273 }
274 pub const fn active_verification_count(&self) -> u8 {
275 self.active_verification_count
276 }
277 pub const fn preflight_clean(&self) -> bool {
278 self.preflight_clean
279 }
280 pub const fn candidate_callback_observed(&self) -> bool {
281 self.candidate_callback_observed
282 }
283 pub const fn replay_callback_observed(&self) -> bool {
284 self.replay_callback_observed
285 }
286 pub const fn cleanup_verified(&self) -> bool {
287 self.cleanup_verified
288 }
289 pub const fn item_projected(&self) -> bool {
290 self.item_projected
291 }
292}
293
294impl fmt::Debug for WebAssessmentSsrfOastAudit {
295 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296 f.debug_struct("WebAssessmentSsrfOastAudit")
297 .field("outcome", &self.outcome)
298 .field("policy_id", &self.policy_id)
299 .field("candidate_source", &self.candidate_source)
300 .field("target_request_count", &self.target_request_count)
301 .field("provider_request_count", &self.provider_request_count)
302 .field("provider_failure", &self.provider_failure)
303 .field("cleanup_failure", &self.cleanup_failure)
304 .field("active_verification_count", &self.active_verification_count)
305 .field("preflight_clean", &self.preflight_clean)
306 .field(
307 "candidate_callback_observed",
308 &self.candidate_callback_observed,
309 )
310 .field("replay_callback_observed", &self.replay_callback_observed)
311 .field("cleanup_verified", &self.cleanup_verified)
312 .field("item_projected", &self.item_projected)
313 .finish()
314 }
315}
316
317pub(super) struct SsrfOastReviewConfig {
318 policy: SsrfOastReviewPolicy,
319 administrator: SsrfOastAdminToken,
320 selection: StableSsrfOastSelectionSlot,
321}
322
323impl SsrfOastReviewConfig {
324 pub(super) fn new(
325 policy: SsrfOastReviewPolicy,
326 administrator: SsrfOastAdminToken,
327 selection: SsrfOastCandidateSelection,
328 ) -> Self {
329 Self {
330 policy,
331 administrator,
332 selection: StableSsrfOastSelectionSlot::new(selection),
333 }
334 }
335}
336
337impl fmt::Debug for SsrfOastReviewConfig {
338 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339 f.write_str("SsrfOastReviewConfig(<redacted>)")
340 }
341}
342
343#[derive(Clone, Default)]
344pub(super) struct StableSsrfOastSelectionSlot {
345 candidate: Arc<Mutex<Option<SsrfOastQueryCandidate>>>,
346}
347
348impl StableSsrfOastSelectionSlot {
349 fn new(selection: SsrfOastCandidateSelection) -> Self {
350 let candidate = match selection {
351 SsrfOastCandidateSelection::Selected(candidate) => Some(*candidate),
352 SsrfOastCandidateSelection::NotEligible => None,
353 };
354 Self {
355 candidate: Arc::new(Mutex::new(candidate)),
356 }
357 }
358
359 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
360 pub(super) fn commit_openapi(
361 &self,
362 selection: SsrfOastCandidateSelection,
363 ) -> Result<(), SsrfOastRuntimeInvariantError> {
364 let SsrfOastCandidateSelection::Selected(candidate) = selection else {
365 return Ok(());
366 };
367 let mut slot = self
368 .candidate
369 .lock()
370 .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)?;
371 if slot.is_none() {
372 *slot = Some(*candidate);
373 }
374 Ok(())
375 }
376
377 fn take(&self) -> Result<Option<SsrfOastQueryCandidate>, SsrfOastRuntimeInvariantError> {
378 self.candidate
379 .lock()
380 .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)
381 .map(|mut value| value.take())
382 }
383}
384
385impl fmt::Debug for StableSsrfOastSelectionSlot {
386 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387 f.write_str("StableSsrfOastSelectionSlot(<redacted-single-assignment>)")
388 }
389}
390
391pub(super) struct SsrfOastRuntimeBinding {
392 executor: Arc<SsrfOastDecisionExecutor>,
393 subject: EntityId,
394}
395
396impl SsrfOastRuntimeBinding {
397 pub(super) fn new(
398 config: SsrfOastReviewConfig,
399 authority: SharedWebRuntimeAuthority,
400 subject: EntityId,
401 ) -> Self {
402 let executor = Arc::new(SsrfOastDecisionExecutor {
403 authority,
404 policy: config.policy,
405 administrator: Mutex::new(Some(config.administrator)),
406 selection: config.selection,
407 subject: subject.clone(),
408 state: Mutex::new(SsrfOastExecutionState::default()),
409 });
410 Self { executor, subject }
411 }
412
413 #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
414 pub(super) fn selection_slot(&self) -> StableSsrfOastSelectionSlot {
415 self.executor.selection.clone()
416 }
417
418 pub(super) fn install_into_parent_registry(
419 &self,
420 registry: &mut DecisionExecutorRegistry,
421 ) -> Result<(), SsrfOastRuntimeInvariantError> {
422 let before = registry.len();
423 let executor: Arc<dyn DecisionActionExecutor> = self.executor.clone();
424 registry
425 .register(executor)
426 .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)?;
427 for stage in [
428 DecisionExecutionStage::Passive,
429 DecisionExecutionStage::Active,
430 ] {
431 registry
432 .route_action(stage, SSRF_OAST_REVIEW_ACTION_ID, SSRF_OAST_EXECUTOR_ID)
433 .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)?;
434 }
435 if registry.len() != before + 1 {
436 return Err(SsrfOastRuntimeInvariantError::Catalog);
437 }
438 Ok(())
439 }
440
441 pub(super) fn finalize(
442 self,
443 knowledge: &KnowledgeBase,
444 transport: &TransportDispatchAudit,
445 forced_terminal: Option<SsrfOastTerminalState>,
446 forced_limit: Option<RuntimeLimitExceeded>,
447 ) -> Result<SsrfOastRuntimeResult, SsrfOastRuntimeInvariantError> {
448 if transport.omitted_receipt_count() != 0 {
449 return Err(SsrfOastRuntimeInvariantError::Catalog);
450 }
451 let receipts = transport
452 .receipts()
453 .iter()
454 .filter(|receipt| receipt.action_id() == SSRF_OAST_REVIEW_ACTION_ID)
455 .collect::<Vec<_>>();
456 if !target_receipt_prefix_is_valid(&receipts) {
457 return Err(SsrfOastRuntimeInvariantError::Catalog);
458 }
459 let mut state = self.executor.take_state()?;
460 if let Some(terminal) = forced_terminal {
461 if let Some(facts) = state.facts.as_mut() {
462 facts.terminal = Some(terminal);
463 }
464 }
465 let target_count = u8::try_from(receipts.len()).unwrap_or(u8::MAX);
466 let target_accounting_complete = receipts.len() == SSRF_OAST_TARGET_REQUESTS
467 && receipts
468 .iter()
469 .all(|receipt| target_dispatch_outcome_is_accounted(receipt.outcome()));
470 if let Some(facts) = state.facts.as_mut() {
471 facts.target_accounting_complete = target_accounting_complete;
472 }
473 let outcome = match state.facts.as_ref() {
474 Some(facts) => evaluate_ssrf_oast_review(facts)
475 .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)?,
476 None => {
477 if state.terminal.is_some() {
478 SsrfOastReviewOutcome::Incomplete
479 } else {
480 SsrfOastReviewOutcome::NotEligible
481 }
482 },
483 };
484 let projected = outcome.projects_item();
485 if projected
486 && (state.evidence_ids.is_empty()
487 || state
488 .evidence_ids
489 .iter()
490 .any(|id| knowledge.evidence(id).is_none()))
491 {
492 return Err(SsrfOastRuntimeInvariantError::Catalog);
493 }
494 let audit = audit(
495 &self.executor.policy,
496 &state,
497 outcome.into(),
498 target_count,
499 projected,
500 );
501 let committed = CommittedSsrfOastReview {
502 subject: self.subject,
503 parameter_identity: state.parameter_identity,
504 outcome: outcome.into(),
505 evidence_ids: state.evidence_ids,
506 audit,
507 };
508 if matches!(
509 outcome,
510 SsrfOastReviewOutcome::Cancelled
511 | SsrfOastReviewOutcome::BudgetExhausted
512 | SsrfOastReviewOutcome::Truncated
513 | SsrfOastReviewOutcome::Incomplete
514 ) {
515 return Ok(SsrfOastRuntimeResult::Stopped {
516 audit: committed.audit,
517 runtime_limit: forced_limit.or(state.runtime_limit),
518 });
519 }
520 Ok(SsrfOastRuntimeResult::Complete(committed))
521 }
522}
523
524fn target_receipt_prefix_is_valid(receipts: &[&crate::TransportDispatchReceipt]) -> bool {
525 receipts.len() <= SSRF_OAST_TARGET_REQUESTS
526 && receipts.iter().enumerate().all(|(index, receipt)| {
527 let expected_stage = if index == 1 {
528 DecisionExecutionStage::Active
529 } else {
530 DecisionExecutionStage::Passive
531 };
532 let expected_origin = (expected_stage == DecisionExecutionStage::Passive)
533 .then_some(DecisionActionOrigin::Planned);
534 receipt.stage() == expected_stage
535 && receipt.origin() == expected_origin
536 && receipt.request_body_bytes() == 0
537 })
538}
539
540#[derive(Debug, thiserror::Error)]
541pub(super) enum SsrfOastRuntimeInvariantError {
542 #[error("SSRF OAST runtime invariant failed")]
543 Catalog,
544}
545
546pub(super) enum SsrfOastRuntimeResult {
547 Complete(CommittedSsrfOastReview),
548 Stopped {
549 audit: WebAssessmentSsrfOastAudit,
550 runtime_limit: Option<RuntimeLimitExceeded>,
551 },
552}
553
554pub(super) struct CommittedSsrfOastReview {
555 subject: EntityId,
556 parameter_identity: Option<String>,
557 outcome: SsrfOastRuntimeOutcome,
558 evidence_ids: Vec<EvidenceId>,
559 audit: WebAssessmentSsrfOastAudit,
560}
561
562impl CommittedSsrfOastReview {
563 pub(super) const fn audit(&self) -> &WebAssessmentSsrfOastAudit {
564 &self.audit
565 }
566}
567
568#[derive(Default)]
569struct SsrfOastExecutionState {
570 prepared: Option<PreparedSsrfOast>,
571 facts: Option<SsrfOastReviewFacts>,
572 terminal: Option<SsrfOastTerminalState>,
573 runtime_limit: Option<RuntimeLimitExceeded>,
574 provider_request_count: u8,
575 provider_failure: Option<SsrfOastProviderFailure>,
576 cleanup_failure: Option<SsrfOastProviderFailure>,
577 source: Option<SsrfOastCandidateSource>,
578 parameter_identity: Option<String>,
579 evidence_ids: Vec<EvidenceId>,
580 cleanup_verified: bool,
581}
582
583impl SsrfOastExecutionState {
584 fn retain_provider_audit(&mut self, provider: &NativeOastProviderAdapter) {
585 self.provider_request_count =
586 u8::try_from(provider.admitted_provider_requests()).unwrap_or(u8::MAX);
587 self.retain_provider_failures(provider.provider_failure(), provider.cleanup_failure());
588 }
589
590 fn retain_provider_failures(
591 &mut self,
592 provider_failure: Option<NativeOastProviderErrorKind>,
593 cleanup_failure: Option<NativeOastProviderErrorKind>,
594 ) {
595 self.provider_failure = self.provider_failure.or(provider_failure.map(Into::into));
596 self.cleanup_failure = self.cleanup_failure.or(cleanup_failure.map(Into::into));
597 }
598}
599
600struct PreparedSsrfOast {
601 provider: NativeOastProviderAdapter,
602 plan: SsrfOastMutationPlan,
603 candidate_callback: CallbackId,
604 replay_callback: CallbackId,
605 candidate_correlation: OastCorrelationId,
606 replay_correlation: OastCorrelationId,
607 facts: SsrfOastReviewFacts,
608}
609
610struct ProviderTerminalContext {
611 source: SsrfOastCandidateSource,
612 parameter_identity: String,
613 cleanup_verified: bool,
614}
615
616struct SsrfOastDecisionExecutor {
617 authority: SharedWebRuntimeAuthority,
618 policy: SsrfOastReviewPolicy,
619 administrator: Mutex<Option<SsrfOastAdminToken>>,
620 selection: StableSsrfOastSelectionSlot,
621 subject: EntityId,
622 state: Mutex<SsrfOastExecutionState>,
623}
624
625impl SsrfOastDecisionExecutor {
626 fn take_state(&self) -> Result<SsrfOastExecutionState, SsrfOastRuntimeInvariantError> {
627 Ok(std::mem::take(
628 &mut *self
629 .state
630 .lock()
631 .map_err(|_| SsrfOastRuntimeInvariantError::Catalog)?,
632 ))
633 }
634
635 fn stop(
636 &self,
637 terminal: SsrfOastTerminalState,
638 limit: Option<RuntimeLimitExceeded>,
639 ) -> Result<(), DecisionExecutorError> {
640 let mut state = self
641 .state
642 .lock()
643 .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
644 if state.terminal.replace(terminal).is_some() {
645 return Err(DecisionExecutorError::new(
646 "SSRF OAST terminal state is duplicated",
647 ));
648 }
649 state.runtime_limit = limit;
650 Ok(())
651 }
652
653 async fn execute_passive(
654 &self,
655 request: &DecisionExecutionRequest,
656 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
657 let Some(candidate) = self
658 .selection
659 .take()
660 .map_err(|_| DecisionExecutorError::new("SSRF OAST selection failed"))?
661 else {
662 self.stop(SsrfOastTerminalState::Incomplete, None)?;
663 return phase_evidence(request, 0, SsrfOastLifecycleEvidence::terminal(), None);
664 };
665 self.authority
666 .authorize_target(candidate.execution_resource())
667 .map_err(|_| DecisionExecutorError::new("SSRF OAST target authority failed"))?;
668 let source = candidate.source();
669 let parameter_identity = candidate.parameter_id().to_owned();
670 let control_seed = random_entropy()?;
671 let correlation = SsrfOastCorrelationMaterial::derive(
672 &self.policy,
673 &candidate,
674 SsrfOastCorrelationBinding::new(
675 self.subject.as_str(),
676 SSRF_OAST_REVIEW_ACTION_ID,
677 request.case().id(),
678 ),
679 SsrfOastCorrelationEntropy::new(
680 random_entropy()?,
681 random_entropy()?,
682 random_entropy()?,
683 ),
684 )
685 .map_err(|_| DecisionExecutorError::new("SSRF OAST correlation derivation failed"))?;
686 let (epoch, candidate_token, replay_token) = correlation.into_parts();
687 let control_url = candidate
688 .control_execution_url(control_seed)
689 .map_err(|_| DecisionExecutorError::new("SSRF OAST control construction failed"))?;
690 let control_response = match collect_target(
691 &self.authority,
692 request,
693 DecisionExecutionStage::Passive,
694 Some(DecisionActionOrigin::Planned),
695 &control_url,
696 )
697 .await
698 {
699 Ok(response) => response,
700 Err(TargetFailure::Limit(limit)) => {
701 self.stop(SsrfOastTerminalState::BudgetExhausted, Some(limit))?;
702 return phase_evidence(request, 0, SsrfOastLifecycleEvidence::terminal(), None);
703 },
704 Err(TargetFailure::TimeoutAfterDispatch | TargetFailure::Transport) => {
705 self.stop(SsrfOastTerminalState::Incomplete, None)?;
706 return phase_evidence(request, 0, SsrfOastLifecycleEvidence::terminal(), None);
707 },
708 };
709 let control_bytes = u64::try_from(control_response.body().len()).unwrap_or(u64::MAX);
710 let signal = control_response.ssrf_oast_defense_signal();
711 let terminal = classify_target_response(&control_response, &control_url, &signal);
712 if let Some(terminal) = terminal {
713 self.stop(terminal, None)?;
714 return phase_evidence(
715 request,
716 control_bytes,
717 SsrfOastLifecycleEvidence::terminal(),
718 Some((&signal, control_response.status())),
719 );
720 }
721
722 let administrator = self
723 .administrator
724 .lock()
725 .map_err(|_| DecisionExecutorError::new("SSRF OAST credential is unavailable"))?
726 .take()
727 .ok_or_else(|| {
728 DecisionExecutorError::new("SSRF OAST credential was already consumed")
729 })?;
730 let limits = NativeOastProviderLimits::new(
731 1,
732 2,
733 u16::try_from(MAX_SSRF_OAST_PROVIDER_REQUESTS).unwrap_or(u16::MAX),
734 8,
735 PROVIDER_REQUEST_BYTES,
736 PROVIDER_RESPONSE_BYTES,
737 self.policy.lifetime_ms(),
738 )
739 .map_err(|_| DecisionExecutorError::new("SSRF OAST provider limits failed"))?;
740 let configuration = provider_configuration(
741 self.policy.provider_origin(),
742 &assessment_identity(&self.subject),
743 epoch.into_bytes(),
744 administrator.into_bytes(),
745 limits,
746 )
747 .map_err(|_| DecisionExecutorError::new("SSRF OAST provider configuration failed"))?;
748 let candidate_case = callback_case(request, "candidate")?;
749 let replay_case = callback_case(request, "replay")?;
750 let mut provider = match self.authority.mint_native_oast_provider(configuration) {
751 Ok(provider) => provider,
752 Err(error) => {
753 self.stop(provider_terminal(error.kind()), None)?;
754 self.state
755 .lock()
756 .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?
757 .retain_provider_failures(Some(error.kind()), None);
758 return phase_evidence(
759 request,
760 control_bytes,
761 SsrfOastLifecycleEvidence {
762 control_complete: true,
763 phase_terminal: true,
764 ..SsrfOastLifecycleEvidence::default()
765 },
766 Some((&signal, control_response.status())),
767 );
768 },
769 };
770 if let Err(error) = provider.register().await {
771 self.store_provider_terminal(
772 provider,
773 None,
774 provider_terminal(error.kind()),
775 None,
776 ProviderTerminalContext {
777 source,
778 parameter_identity,
779 cleanup_verified: false,
780 },
781 )?;
782 return phase_evidence(
783 request,
784 control_bytes,
785 SsrfOastLifecycleEvidence {
786 control_complete: true,
787 phase_terminal: true,
788 ..SsrfOastLifecycleEvidence::default()
789 },
790 Some((&signal, control_response.status())),
791 );
792 }
793 let candidate_allocation = match provider
794 .allocate_callback(candidate_case, candidate_token)
795 .await
796 {
797 Ok(allocation) => allocation,
798 Err(error) => {
799 let terminal = provider_terminal(error.kind());
800 let cleanup_verified = cleanup_provider(&mut provider).await;
801 self.store_provider_terminal(
802 provider,
803 None,
804 terminal,
805 None,
806 ProviderTerminalContext {
807 source,
808 parameter_identity,
809 cleanup_verified,
810 },
811 )?;
812 return phase_evidence(
813 request,
814 control_bytes,
815 SsrfOastLifecycleEvidence {
816 control_complete: true,
817 provider_registered: true,
818 cleanup_verified,
819 phase_terminal: true,
820 ..SsrfOastLifecycleEvidence::default()
821 },
822 Some((&signal, control_response.status())),
823 );
824 },
825 };
826 let replay_allocation = match provider.allocate_callback(replay_case, replay_token).await {
827 Ok(allocation) => allocation,
828 Err(error) => {
829 let terminal = provider_terminal(error.kind());
830 let cleanup_verified = cleanup_provider(&mut provider).await;
831 self.store_provider_terminal(
832 provider,
833 None,
834 terminal,
835 None,
836 ProviderTerminalContext {
837 source,
838 parameter_identity,
839 cleanup_verified,
840 },
841 )?;
842 return phase_evidence(
843 request,
844 control_bytes,
845 SsrfOastLifecycleEvidence {
846 control_complete: true,
847 provider_registered: true,
848 cleanup_verified,
849 phase_terminal: true,
850 ..SsrfOastLifecycleEvidence::default()
851 },
852 Some((&signal, control_response.status())),
853 );
854 },
855 };
856 let candidate_callback = candidate_allocation.callback_id().clone();
857 let replay_callback = replay_allocation.callback_id().clone();
858 let candidate_correlation = candidate_allocation
859 .correlation_receipt()
860 .correlation_id()
861 .clone();
862 let replay_correlation = replay_allocation
863 .correlation_receipt()
864 .correlation_id()
865 .clone();
866 let mut facts = SsrfOastReviewFacts::new(&candidate_callback, &replay_callback);
867 facts.control_complete = true;
868 facts.provider_registered = true;
869 facts.allocations_complete = true;
870 facts.correlations_distinct = candidate_correlation != replay_correlation;
871 facts.same_correlation_scope = true;
872 let plan = match mutation_plan(
873 candidate,
874 control_seed,
875 candidate_allocation.target(),
876 replay_allocation.target(),
877 self.policy.provider_origin(),
878 ) {
879 Ok(plan) => plan,
880 Err(_) => {
881 facts.terminal = Some(SsrfOastTerminalState::MalformedProviderResponse);
882 facts.cleanup_verified = cleanup_provider(&mut provider).await;
883 facts.provider_accounting_complete = provider_accounting_is_complete(&provider);
884 let lifecycle = SsrfOastLifecycleEvidence::from_facts(&facts, true);
885 self.store_provider_terminal(
886 provider,
887 Some(facts),
888 SsrfOastTerminalState::MalformedProviderResponse,
889 None,
890 ProviderTerminalContext {
891 source,
892 parameter_identity,
893 cleanup_verified: lifecycle.cleanup_verified,
894 },
895 )?;
896 return phase_evidence(
897 request,
898 control_bytes,
899 lifecycle,
900 Some((&signal, control_response.status())),
901 );
902 },
903 };
904 let preflight = match provider.poll().await {
905 Ok(preflight) => preflight,
906 Err(error) => {
907 let terminal = provider_terminal(error.kind());
908 facts.terminal = Some(terminal);
909 facts.cleanup_verified = cleanup_provider(&mut provider).await;
910 facts.provider_accounting_complete = provider_accounting_is_complete(&provider);
911 let lifecycle = SsrfOastLifecycleEvidence::from_facts(&facts, true);
912 self.store_provider_terminal(
913 provider,
914 Some(facts),
915 terminal,
916 None,
917 ProviderTerminalContext {
918 source,
919 parameter_identity,
920 cleanup_verified: lifecycle.cleanup_verified,
921 },
922 )?;
923 return phase_evidence(
924 request,
925 control_bytes,
926 lifecycle,
927 Some((&signal, control_response.status())),
928 );
929 },
930 };
931 let preflight_clean = preflight
932 .correlation_receipts()
933 .iter()
934 .all(|receipt| receipt.accepted_events() == 0 && receipt.duplicate_events() == 0);
935 facts.preflight_clean = preflight_clean;
936 if !preflight_clean {
937 facts.cleanup_verified = cleanup_provider(&mut provider).await;
938 facts.provider_accounting_complete = provider_accounting_is_complete(&provider);
939 facts.terminal = None;
940 let lifecycle = SsrfOastLifecycleEvidence::from_facts(&facts, true);
941 let mut state = self
942 .state
943 .lock()
944 .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
945 state.cleanup_verified = facts.cleanup_verified;
946 state.facts = Some(facts);
947 state.retain_provider_audit(&provider);
948 state.source = Some(source);
949 state.parameter_identity = Some(parameter_identity);
950 return phase_evidence(
951 request,
952 control_bytes,
953 lifecycle,
954 Some((&signal, control_response.status())),
955 );
956 }
957 let mut state = self
958 .state
959 .lock()
960 .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
961 state.retain_provider_audit(&provider);
962 state.prepared = Some(PreparedSsrfOast {
963 provider,
964 plan,
965 candidate_callback,
966 replay_callback,
967 candidate_correlation,
968 replay_correlation,
969 facts,
970 });
971 state.source = Some(source);
972 state.parameter_identity = Some(parameter_identity);
973 let lifecycle = SsrfOastLifecycleEvidence::from_facts(
974 &state
975 .prepared
976 .as_ref()
977 .expect("prepared SSRF OAST state is present")
978 .facts,
979 false,
980 );
981 let evidence = phase_evidence(
982 request,
983 control_bytes,
984 lifecycle,
985 Some((&signal, control_response.status())),
986 )?;
987 state.evidence_ids.extend(ssrf_evidence_ids(&evidence));
988 Ok(evidence)
989 }
990
991 async fn execute_active(
992 &self,
993 request: &DecisionExecutionRequest,
994 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
995 let mut prepared = self
996 .state
997 .lock()
998 .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?
999 .prepared
1000 .take()
1001 .ok_or_else(|| DecisionExecutorError::new("SSRF OAST passive state is incomplete"))?;
1002 let mut response_bytes = 0_u64;
1003 let candidate_url = prepared
1004 .plan
1005 .execution_url(SsrfOastTargetLeg::Candidate)
1006 .clone();
1007 let candidate_response = collect_target(
1008 &self.authority,
1009 request,
1010 DecisionExecutionStage::Active,
1011 None,
1012 &candidate_url,
1013 )
1014 .await;
1015 let mut last_signal = match candidate_response {
1016 Ok(response) => {
1017 response_bytes = response_bytes
1018 .saturating_add(u64::try_from(response.body().len()).unwrap_or(u64::MAX));
1019 let signal = response.ssrf_oast_defense_signal();
1020 if let Some(terminal) = classify_target_response(&response, &candidate_url, &signal)
1021 {
1022 prepared.facts.terminal = Some(terminal);
1023 }
1024 prepared.facts.candidate_dispatched = true;
1025 Some((signal, response.status()))
1026 },
1027 Err(TargetFailure::TimeoutAfterDispatch) => {
1028 prepared.facts.candidate_dispatched = true;
1029 prepared.facts.terminal = Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch);
1030 None
1031 },
1032 Err(TargetFailure::Limit(limit)) => {
1033 prepared.facts.terminal = Some(SsrfOastTerminalState::BudgetExhausted);
1034 let lifecycle = self.store_active_terminal(prepared, Some(limit)).await?;
1035 return phase_evidence(request, 0, lifecycle, None);
1036 },
1037 Err(TargetFailure::Transport) => {
1038 prepared.facts.terminal = Some(SsrfOastTerminalState::Incomplete);
1039 let lifecycle = self.store_active_terminal(prepared, None).await?;
1040 return phase_evidence(request, 0, lifecycle, None);
1041 },
1042 };
1043 if polling_may_continue(&prepared.facts) {
1044 poll_for_events(
1045 &mut prepared,
1046 self.policy.polls_per_leg(),
1047 self.policy.poll_interval_ms(),
1048 SsrfOastPollPhase::Candidate,
1049 self.authority.cancellation(),
1050 )
1051 .await?;
1052 }
1053 let replay_url = prepared
1054 .plan
1055 .execution_url(SsrfOastTargetLeg::Replay)
1056 .clone();
1057 if polling_may_continue(&prepared.facts) {
1058 match collect_target(
1059 &self.authority,
1060 request,
1061 DecisionExecutionStage::Passive,
1062 Some(DecisionActionOrigin::Planned),
1063 &replay_url,
1064 )
1065 .await
1066 {
1067 Ok(response) => {
1068 response_bytes = response_bytes
1069 .saturating_add(u64::try_from(response.body().len()).unwrap_or(u64::MAX));
1070 let signal = response.ssrf_oast_defense_signal();
1071 if let Some(terminal) =
1072 classify_target_response(&response, &replay_url, &signal)
1073 {
1074 prepared.facts.terminal = Some(terminal);
1075 }
1076 prepared.facts.replay_dispatched = true;
1077 last_signal = Some((signal, response.status()));
1078 },
1079 Err(TargetFailure::TimeoutAfterDispatch) => {
1080 prepared.facts.replay_dispatched = true;
1081 prepared.facts.terminal =
1082 Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch);
1083 },
1084 Err(TargetFailure::Limit(limit)) => {
1085 prepared.facts.terminal = Some(SsrfOastTerminalState::BudgetExhausted);
1086 let lifecycle = self.store_active_terminal(prepared, Some(limit)).await?;
1087 return phase_evidence(request, response_bytes, lifecycle, None);
1088 },
1089 Err(TargetFailure::Transport) => {
1090 prepared.facts.terminal = Some(SsrfOastTerminalState::Incomplete);
1091 },
1092 }
1093 }
1094 if polling_may_continue(&prepared.facts) {
1095 let used = prepared
1096 .provider
1097 .receipts()
1098 .iter()
1099 .filter(|receipt| receipt.operation() == NativeOastProviderOperation::Poll)
1100 .count();
1101 let remaining =
1102 usize::from(MAX_POST_DISPATCH_POLLS).saturating_sub(used.saturating_sub(1));
1103 let polls = usize::from(self.policy.polls_per_leg()).min(remaining) as u16;
1104 poll_for_events(
1105 &mut prepared,
1106 polls,
1107 self.policy.poll_interval_ms(),
1108 SsrfOastPollPhase::Replay,
1109 self.authority.cancellation(),
1110 )
1111 .await?;
1112 }
1113 prepared.facts.cleanup_verified = cleanup_provider(&mut prepared.provider).await;
1114 prepared.facts.provider_accounting_complete =
1115 provider_accounting_is_complete(&prepared.provider);
1116 prepared.facts.target_accounting_complete =
1117 current_target_accounting_is_complete(&self.authority);
1118 let terminal = prepared.facts.terminal.is_some()
1119 && !matches!(
1120 prepared.facts.terminal,
1121 Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch)
1122 );
1123 let lifecycle = SsrfOastLifecycleEvidence::from_facts(&prepared.facts, terminal);
1124 let evidence = phase_evidence(
1125 request,
1126 response_bytes,
1127 lifecycle,
1128 last_signal
1129 .as_ref()
1130 .map(|(signal, status)| (signal, *status)),
1131 )?;
1132 let mut state = self
1133 .state
1134 .lock()
1135 .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
1136 state.retain_provider_audit(&prepared.provider);
1137 state.terminal = prepared.facts.terminal;
1138 state.cleanup_verified = prepared.facts.cleanup_verified;
1139 state.evidence_ids.extend(ssrf_evidence_ids(&evidence));
1140 state.facts = Some(prepared.facts);
1141 Ok(evidence)
1142 }
1143
1144 async fn store_active_terminal(
1145 &self,
1146 mut prepared: PreparedSsrfOast,
1147 limit: Option<RuntimeLimitExceeded>,
1148 ) -> Result<SsrfOastLifecycleEvidence, DecisionExecutorError> {
1149 prepared.facts.cleanup_verified = cleanup_provider(&mut prepared.provider).await;
1150 prepared.facts.provider_accounting_complete =
1151 provider_accounting_is_complete(&prepared.provider);
1152 prepared.facts.target_accounting_complete =
1153 current_target_accounting_is_complete(&self.authority);
1154 let lifecycle = SsrfOastLifecycleEvidence::from_facts(&prepared.facts, true);
1155 let mut state = self
1156 .state
1157 .lock()
1158 .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
1159 state.retain_provider_audit(&prepared.provider);
1160 state.terminal = prepared.facts.terminal;
1161 state.runtime_limit = limit;
1162 state.cleanup_verified = prepared.facts.cleanup_verified;
1163 state.facts = Some(prepared.facts);
1164 Ok(lifecycle)
1165 }
1166
1167 fn store_provider_terminal(
1168 &self,
1169 provider: NativeOastProviderAdapter,
1170 facts: Option<SsrfOastReviewFacts>,
1171 terminal: SsrfOastTerminalState,
1172 limit: Option<RuntimeLimitExceeded>,
1173 context: ProviderTerminalContext,
1174 ) -> Result<(), DecisionExecutorError> {
1175 let mut state = self
1176 .state
1177 .lock()
1178 .map_err(|_| DecisionExecutorError::new("SSRF OAST state is unavailable"))?;
1179 state.retain_provider_audit(&provider);
1180 state.terminal = Some(terminal);
1181 state.runtime_limit = limit;
1182 state.source = Some(context.source);
1183 state.parameter_identity = Some(context.parameter_identity);
1184 state.cleanup_verified = context.cleanup_verified;
1185 state.facts = facts;
1186 Ok(())
1187 }
1188}
1189
1190#[async_trait]
1191impl DecisionActionExecutor for SsrfOastDecisionExecutor {
1192 fn id(&self) -> &str {
1193 SSRF_OAST_EXECUTOR_ID
1194 }
1195
1196 async fn execute(
1197 &self,
1198 request: &DecisionExecutionRequest,
1199 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1200 if request.case().action_id() != SSRF_OAST_REVIEW_ACTION_ID
1201 || request.case().subject() != &self.subject
1202 || request.case().payload_strategy().is_some()
1203 || request.case().applies_hypothesis_transition()
1204 || request.delay_ms().is_some()
1205 || (request.stage() == DecisionExecutionStage::Passive && request.origin().is_none())
1206 || (request.stage() == DecisionExecutionStage::Active && request.origin().is_some())
1207 {
1208 return Err(DecisionExecutorError::new(
1209 "SSRF OAST executor route contract failed",
1210 ));
1211 }
1212 match request.stage() {
1213 DecisionExecutionStage::Passive => self.execute_passive(request).await,
1214 DecisionExecutionStage::Active => self.execute_active(request).await,
1215 }
1216 }
1217}
1218
1219enum TargetFailure {
1220 Limit(RuntimeLimitExceeded),
1221 TimeoutAfterDispatch,
1222 Transport,
1223}
1224
1225async fn collect_target(
1226 authority: &SharedWebRuntimeAuthority,
1227 request: &DecisionExecutionRequest,
1228 stage: DecisionExecutionStage,
1229 origin: Option<DecisionActionOrigin>,
1230 url: &url::Url,
1231) -> Result<crate::http_evidence::CollectedHttpResponse, TargetFailure> {
1232 let probe =
1233 HttpProbe::new(url.clone(), HttpProbeMethod::Get).map_err(|_| TargetFailure::Transport)?;
1234 authority
1235 .requests()
1236 .collect_for_runtime(
1237 SSRF_OAST_REVIEW_ACTION_ID,
1238 stage,
1239 origin,
1240 request.limits(),
1241 &probe,
1242 )
1243 .await
1244 .map_err(|error| {
1245 if let HttpRequestBrokerError::RuntimeLimit(limit) = error {
1246 return TargetFailure::Limit(limit);
1247 }
1248 if error.failure_kind() == DecisionExecutionFailureKind::RequestTimeout {
1249 TargetFailure::TimeoutAfterDispatch
1250 } else {
1251 TargetFailure::Transport
1252 }
1253 })
1254}
1255
1256fn polling_may_continue(facts: &SsrfOastReviewFacts) -> bool {
1257 facts.terminal.is_none()
1258 || matches!(
1259 facts.terminal,
1260 Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch)
1261 )
1262}
1263
1264async fn cleanup_provider(provider: &mut NativeOastProviderAdapter) -> bool {
1265 provider
1266 .cleanup()
1267 .await
1268 .as_ref()
1269 .is_ok_and(|receipt| receipt.cleanup_verified())
1270}
1271
1272fn provider_accounting_is_complete(provider: &NativeOastProviderAdapter) -> bool {
1273 let receipts = provider.receipts();
1274 receipts.len() >= 5
1275 && receipts.len() <= MAX_SSRF_OAST_PROVIDER_REQUESTS
1276 && receipts
1277 .iter()
1278 .enumerate()
1279 .all(|(index, receipt)| usize::from(receipt.request_count()) == index + 1)
1280 && receipts
1281 .first()
1282 .is_some_and(|receipt| receipt.operation() == NativeOastProviderOperation::Register)
1283 && receipts.get(1).is_some_and(|receipt| {
1284 receipt.operation() == NativeOastProviderOperation::AllocateCallback
1285 })
1286 && receipts.get(2).is_some_and(|receipt| {
1287 receipt.operation() == NativeOastProviderOperation::AllocateCallback
1288 })
1289 && receipts
1290 .get(3)
1291 .is_some_and(|receipt| receipt.operation() == NativeOastProviderOperation::Poll)
1292 && receipts[4..receipts.len() - 1]
1293 .iter()
1294 .all(|receipt| receipt.operation() == NativeOastProviderOperation::Poll)
1295 && receipts
1296 .last()
1297 .is_some_and(|receipt| receipt.operation() == NativeOastProviderOperation::Cleanup)
1298}
1299
1300fn current_target_accounting_is_complete(authority: &SharedWebRuntimeAuthority) -> bool {
1301 let audit = authority.request_accounting().dispatch_audit();
1302 let receipts = audit
1303 .receipts()
1304 .iter()
1305 .filter(|receipt| receipt.action_id() == SSRF_OAST_REVIEW_ACTION_ID)
1306 .collect::<Vec<_>>();
1307 audit.omitted_receipt_count() == 0
1308 && target_receipt_prefix_is_valid(&receipts)
1309 && receipts.len() == SSRF_OAST_TARGET_REQUESTS
1310 && receipts
1311 .iter()
1312 .all(|receipt| target_dispatch_outcome_is_accounted(receipt.outcome()))
1313}
1314
1315const fn target_dispatch_outcome_is_accounted(outcome: TransportDispatchOutcome) -> bool {
1316 matches!(
1317 outcome,
1318 TransportDispatchOutcome::Completed | TransportDispatchOutcome::RequestTimeout
1319 )
1320}
1321
1322fn classify_target_response(
1323 response: &crate::http_evidence::CollectedHttpResponse,
1324 expected: &url::Url,
1325 signal: &super::AssessmentDefenseSignal,
1326) -> Option<SsrfOastTerminalState> {
1327 if response.final_url() != expected || (300..400).contains(&response.status()) {
1328 return Some(SsrfOastTerminalState::Incomplete);
1329 }
1330 if response.body_truncated() {
1331 return Some(SsrfOastTerminalState::Incomplete);
1332 }
1333 if !response.body_complete() {
1334 return Some(SsrfOastTerminalState::Incomplete);
1335 }
1336 if signal.state().is_rate_limited() {
1337 return Some(SsrfOastTerminalState::RateLimited);
1338 }
1339 if signal.state().is_challenged() {
1340 return Some(SsrfOastTerminalState::DefensiveInterference);
1341 }
1342 None
1343}
1344
1345#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1346enum SsrfOastPollPhase {
1347 Candidate,
1348 Replay,
1349}
1350
1351impl SsrfOastPollPhase {
1352 const fn expected_event_observed(self, candidate_seen: bool, replay_seen: bool) -> bool {
1353 match self {
1354 Self::Candidate => candidate_seen,
1355 Self::Replay => candidate_seen && replay_seen,
1356 }
1357 }
1358}
1359
1360async fn poll_for_events(
1361 prepared: &mut PreparedSsrfOast,
1362 polls: u16,
1363 interval_ms: u64,
1364 phase: SsrfOastPollPhase,
1365 cancellation: &tokio_util::sync::CancellationToken,
1366) -> Result<(), DecisionExecutorError> {
1367 for _ in 0..polls {
1368 tokio::select! {
1369 _ = cancellation.cancelled() => {
1370 prepared.facts.terminal = Some(SsrfOastTerminalState::Cancelled);
1371 return Ok(());
1372 }
1373 _ = tokio::time::sleep(Duration::from_millis(interval_ms)) => {}
1374 }
1375 let page = match prepared.provider.poll().await {
1376 Ok(page) => page,
1377 Err(error) => {
1378 prepared.facts.terminal = Some(provider_terminal(error.kind()));
1379 return Ok(());
1380 },
1381 };
1382 reduce_poll(prepared, &page, phase);
1383 if !polling_may_continue(&prepared.facts) {
1384 break;
1385 }
1386 if phase.expected_event_observed(
1387 prepared.facts.candidate_event.is_some(),
1388 prepared.facts.replay_event.is_some(),
1389 ) {
1390 break;
1391 }
1392 }
1393 Ok(())
1394}
1395
1396fn reduce_poll(
1397 prepared: &mut PreparedSsrfOast,
1398 page: &crate::native_oast_provider::NativeOastPollOutcome,
1399 phase: SsrfOastPollPhase,
1400) {
1401 for receipt in page.correlation_receipts() {
1402 let is_candidate = receipt.correlation_id() == &prepared.candidate_correlation;
1403 let is_replay = receipt.correlation_id() == &prepared.replay_correlation;
1404 if !is_candidate && !is_replay {
1405 prepared.facts.terminal = Some(SsrfOastTerminalState::MalformedProviderResponse);
1406 continue;
1407 }
1408 if phase == SsrfOastPollPhase::Candidate && is_replay && receipt.accepted_events() > 0 {
1409 prepared.facts.terminal = Some(SsrfOastTerminalState::MalformedProviderResponse);
1410 }
1411 for event in receipt.event_receipts() {
1412 match event.disposition() {
1413 OastEventDisposition::Accepted => {
1414 let observed = if is_candidate {
1415 SsrfOastObservedEvent::from_reduced(
1416 &prepared.candidate_callback,
1417 event.event_key(),
1418 )
1419 } else {
1420 SsrfOastObservedEvent::from_reduced(
1421 &prepared.replay_callback,
1422 event.event_key(),
1423 )
1424 };
1425 let target = if is_candidate {
1426 &mut prepared.facts.candidate_event
1427 } else {
1428 &mut prepared.facts.replay_event
1429 };
1430 if target.replace(observed).is_some() {
1431 prepared.facts.terminal =
1432 Some(SsrfOastTerminalState::MalformedProviderResponse);
1433 }
1434 },
1435 OastEventDisposition::DuplicateSuppressed => {
1436 if (is_candidate && prepared.facts.candidate_event.is_none())
1437 || (is_replay && prepared.facts.replay_event.is_none())
1438 {
1439 prepared.facts.duplicate_only_substitution = true;
1440 }
1441 },
1442 }
1443 }
1444 }
1445}
1446
1447fn provider_terminal(kind: NativeOastProviderErrorKind) -> SsrfOastTerminalState {
1448 match kind {
1449 NativeOastProviderErrorKind::Cancelled => SsrfOastTerminalState::Cancelled,
1450 NativeOastProviderErrorKind::DeadlineExceeded
1451 | NativeOastProviderErrorKind::ProviderExpired => SsrfOastTerminalState::Expired,
1452 NativeOastProviderErrorKind::RuntimeBudget(_)
1453 | NativeOastProviderErrorKind::ParentBudgetTooSmall
1454 | NativeOastProviderErrorKind::RequestLimit
1455 | NativeOastProviderErrorKind::ResponseByteLimit
1456 | NativeOastProviderErrorKind::RequestByteLimit => SsrfOastTerminalState::BudgetExhausted,
1457 NativeOastProviderErrorKind::PollLimit => SsrfOastTerminalState::PollExhausted,
1458 NativeOastProviderErrorKind::ProviderAccessRejected => {
1459 SsrfOastTerminalState::ProviderAuthenticationFailed
1460 },
1461 NativeOastProviderErrorKind::ProviderResponseInvalid
1462 | NativeOastProviderErrorKind::ProviderSessionMismatch
1463 | NativeOastProviderErrorKind::ProviderCallbackMismatch
1464 | NativeOastProviderErrorKind::ProviderPageIncomplete
1465 | NativeOastProviderErrorKind::CorrelationRejected => {
1466 SsrfOastTerminalState::MalformedProviderResponse
1467 },
1468 _ => SsrfOastTerminalState::Incomplete,
1469 }
1470}
1471
1472fn callback_case(
1473 request: &DecisionExecutionRequest,
1474 role: &str,
1475) -> Result<VerificationCase, DecisionExecutorError> {
1476 VerificationCase::new(
1477 format!("{}:{role}", request.case().id()),
1478 request.case().subject().clone(),
1479 request.case().action_id(),
1480 request.case().hypothesis_id(),
1481 )
1482 .map(VerificationCase::without_hypothesis_transition)
1483 .map_err(|_| DecisionExecutorError::new("SSRF OAST callback case failed"))
1484}
1485
1486fn random_entropy() -> Result<[u8; 32], DecisionExecutorError> {
1487 let mut bytes = [0_u8; 32];
1488 getrandom::getrandom(&mut bytes)
1489 .map_err(|_| DecisionExecutorError::new("SSRF OAST entropy source failed"))?;
1490 Ok(bytes)
1491}
1492
1493fn provider_configuration(
1494 origin: &PublicOrigin,
1495 assessment_id: &str,
1496 epoch: [u8; 32],
1497 administrator: Vec<u8>,
1498 limits: NativeOastProviderLimits,
1499) -> Result<NativeOastProviderConfiguration, crate::native_oast_provider::NativeOastProviderError> {
1500 #[cfg(test)]
1501 if origin.as_str().starts_with("http://") {
1502 return NativeOastProviderConfiguration::for_loopback(
1503 origin.clone(),
1504 assessment_id,
1505 epoch,
1506 administrator,
1507 limits,
1508 );
1509 }
1510 NativeOastProviderConfiguration::new(
1511 origin.as_str(),
1512 assessment_id,
1513 epoch,
1514 administrator,
1515 limits,
1516 )
1517}
1518
1519fn mutation_plan(
1520 candidate: SsrfOastQueryCandidate,
1521 control_seed: [u8; 32],
1522 candidate_target: &termivar_oast::CallbackTarget,
1523 replay_target: &termivar_oast::CallbackTarget,
1524 provider: &PublicOrigin,
1525) -> Result<SsrfOastMutationPlan, crate::ssrf_oast_review::SsrfOastContractError> {
1526 #[cfg(test)]
1527 if provider.as_str().starts_with("http://") {
1528 return SsrfOastMutationPlan::new_for_loopback(
1529 candidate,
1530 control_seed,
1531 candidate_target,
1532 replay_target,
1533 provider,
1534 );
1535 }
1536 SsrfOastMutationPlan::new(
1537 candidate,
1538 control_seed,
1539 candidate_target,
1540 replay_target,
1541 provider,
1542 )
1543}
1544
1545fn assessment_identity(subject: &EntityId) -> String {
1546 let digest = Sha256::digest(subject.as_str().as_bytes());
1547 format!("assessment-{digest:x}")
1548}
1549
1550#[derive(Clone, Copy, Default)]
1551struct SsrfOastLifecycleEvidence {
1552 control_complete: bool,
1553 provider_registered: bool,
1554 allocations_complete: bool,
1555 preflight_clean: bool,
1556 candidate_dispatched: bool,
1557 candidate_callback_correlated: bool,
1558 replay_dispatched: bool,
1559 replay_callback_correlated: bool,
1560 repeated_callbacks_correlated: bool,
1561 cleanup_verified: bool,
1562 target_accounting_complete: bool,
1563 provider_accounting_complete: bool,
1564 phase_terminal: bool,
1565}
1566
1567impl SsrfOastLifecycleEvidence {
1568 const fn terminal() -> Self {
1569 Self {
1570 phase_terminal: true,
1571 ..Self::default_const()
1572 }
1573 }
1574
1575 const fn default_const() -> Self {
1576 Self {
1577 control_complete: false,
1578 provider_registered: false,
1579 allocations_complete: false,
1580 preflight_clean: false,
1581 candidate_dispatched: false,
1582 candidate_callback_correlated: false,
1583 replay_dispatched: false,
1584 replay_callback_correlated: false,
1585 repeated_callbacks_correlated: false,
1586 cleanup_verified: false,
1587 target_accounting_complete: false,
1588 provider_accounting_complete: false,
1589 phase_terminal: false,
1590 }
1591 }
1592
1593 fn from_facts(facts: &SsrfOastReviewFacts, phase_terminal: bool) -> Self {
1594 Self {
1595 control_complete: facts.control_complete,
1596 provider_registered: facts.provider_registered,
1597 allocations_complete: facts.allocations_complete,
1598 preflight_clean: facts.preflight_clean,
1599 candidate_dispatched: facts.candidate_dispatched,
1600 candidate_callback_correlated: facts.candidate_event.is_some(),
1601 replay_dispatched: facts.replay_dispatched,
1602 replay_callback_correlated: facts.replay_event.is_some(),
1603 repeated_callbacks_correlated: evaluate_ssrf_oast_review(facts)
1604 .is_ok_and(SsrfOastReviewOutcome::projects_item),
1605 cleanup_verified: facts.cleanup_verified,
1606 target_accounting_complete: facts.target_accounting_complete,
1607 provider_accounting_complete: facts.provider_accounting_complete,
1608 phase_terminal,
1609 }
1610 }
1611
1612 const fn candidate_ready(self) -> bool {
1613 self.control_complete
1614 && self.provider_registered
1615 && self.allocations_complete
1616 && self.preflight_clean
1617 && !self.phase_terminal
1618 }
1619}
1620
1621fn phase_evidence(
1622 request: &DecisionExecutionRequest,
1623 response_bytes: u64,
1624 lifecycle: SsrfOastLifecycleEvidence,
1625 defense: Option<(&super::AssessmentDefenseSignal, u16)>,
1626) -> Result<Vec<Evidence>, DecisionExecutorError> {
1627 let mut evidence = vec![
1628 make_evidence(
1629 request,
1630 EvidenceKind::Content,
1631 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1632 EvidenceValue::Unsigned(response_bytes),
1633 "response-body-size",
1634 )?,
1635 make_evidence(
1636 request,
1637 EvidenceKind::Custom("ssrf-oast-phase".into()),
1638 crate::web_actions::ssrf_oast_review_candidate_ready_predicate(),
1639 EvidenceValue::Boolean(lifecycle.candidate_ready()),
1640 "candidate-ready",
1641 )?,
1642 make_evidence(
1643 request,
1644 EvidenceKind::Custom("ssrf-oast-phase".into()),
1645 crate::web_actions::ssrf_oast_review_phase_terminal_predicate(),
1646 EvidenceValue::Boolean(lifecycle.phase_terminal),
1647 "phase-terminal",
1648 )?,
1649 ];
1650 for (name, value) in [
1651 (CONTROL_COMPLETE, lifecycle.control_complete),
1652 (PROVIDER_REGISTERED, lifecycle.provider_registered),
1653 (ALLOCATIONS_COMPLETE, lifecycle.allocations_complete),
1654 (PREFLIGHT_CLEAN, lifecycle.preflight_clean),
1655 (CANDIDATE_DISPATCHED, lifecycle.candidate_dispatched),
1656 (
1657 CANDIDATE_CALLBACK_CORRELATED,
1658 lifecycle.candidate_callback_correlated,
1659 ),
1660 (REPLAY_DISPATCHED, lifecycle.replay_dispatched),
1661 (
1662 REPLAY_CALLBACK_CORRELATED,
1663 lifecycle.replay_callback_correlated,
1664 ),
1665 (
1666 REPEATED_CALLBACKS_CORRELATED,
1667 lifecycle.repeated_callbacks_correlated,
1668 ),
1669 (CLEANUP_VERIFIED, lifecycle.cleanup_verified),
1670 (
1671 TARGET_ACCOUNTING_COMPLETE,
1672 lifecycle.target_accounting_complete,
1673 ),
1674 (
1675 PROVIDER_ACCOUNTING_COMPLETE,
1676 lifecycle.provider_accounting_complete,
1677 ),
1678 ] {
1679 evidence.push(make_evidence(
1680 request,
1681 EvidenceKind::Custom("ssrf-oast-lifecycle".into()),
1682 KnowledgePredicate::new(SSRF_OAST_EVIDENCE_NAMESPACE, name)
1683 .map_err(|_| DecisionExecutorError::new("SSRF OAST predicate failed"))?,
1684 EvidenceValue::Boolean(value),
1685 name,
1686 )?);
1687 }
1688 if request.stage() == DecisionExecutionStage::Active && !lifecycle.phase_terminal {
1689 evidence.push(make_evidence(
1690 request,
1691 EvidenceKind::Custom("native-web-review-response".into()),
1692 crate::web_actions::native_web_review_response_marker_predicate(),
1693 EvidenceValue::Boolean(true),
1694 "complete",
1695 )?);
1696 }
1697 if let Some((signal, status)) = defense {
1698 let base = make_evidence(
1699 request,
1700 EvidenceKind::Http,
1701 HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge(),
1702 EvidenceValue::Unsigned(u64::from(status)),
1703 "response-status",
1704 )?;
1705 let parents = vec![evidence[0].id().clone(), base.id().clone()];
1706 evidence.push(base);
1707 evidence.extend(
1708 project_assessment_defense_signal(
1709 signal,
1710 AssessmentDefenseProjectionContext {
1711 subject: request.case().subject(),
1712 case_id: request.case().id(),
1713 executor_id: SSRF_OAST_EXECUTOR_ID,
1714 reliability: ConfidenceScore::MAX,
1715 parents,
1716 },
1717 )
1718 .map_err(|_| DecisionExecutorError::new("SSRF OAST defense projection failed"))?,
1719 );
1720 }
1721 Ok(evidence)
1722}
1723
1724fn make_evidence(
1725 request: &DecisionExecutionRequest,
1726 kind: EvidenceKind,
1727 predicate: KnowledgePredicate,
1728 value: EvidenceValue,
1729 method: &'static str,
1730) -> Result<Evidence, DecisionExecutorError> {
1731 Ok(Evidence::new(
1732 request.case().subject().clone(),
1733 kind,
1734 predicate,
1735 value,
1736 EvidenceSource::new(SSRF_OAST_EXECUTOR_ID, method)
1737 .and_then(|source| source.with_correlation_id(request.case().id()))
1738 .map_err(|_| DecisionExecutorError::new("SSRF OAST evidence source failed"))?,
1739 ConfidenceScore::MAX,
1740 ))
1741}
1742
1743fn ssrf_evidence_ids(evidence: &[Evidence]) -> Vec<EvidenceId> {
1744 evidence
1745 .iter()
1746 .filter(|item| item.predicate().namespace() == SSRF_OAST_EVIDENCE_NAMESPACE)
1747 .map(|item| item.id().clone())
1748 .collect()
1749}
1750
1751fn audit(
1752 policy: &SsrfOastReviewPolicy,
1753 state: &SsrfOastExecutionState,
1754 outcome: SsrfOastRuntimeOutcome,
1755 target_requests: u8,
1756 projected: bool,
1757) -> WebAssessmentSsrfOastAudit {
1758 let facts = state.facts.as_ref();
1759 WebAssessmentSsrfOastAudit {
1760 outcome,
1761 policy_id: policy.policy_id().to_wire(),
1762 candidate_source: state.source.map(SsrfOastCandidateSource::as_str),
1763 target_request_count: target_requests,
1764 provider_request_count: state.provider_request_count,
1765 provider_failure: state.provider_failure,
1766 cleanup_failure: state.cleanup_failure,
1767 active_verification_count: u8::from(target_requests >= 2),
1768 preflight_clean: facts.is_some_and(|facts| facts.preflight_clean),
1769 candidate_callback_observed: facts.is_some_and(|facts| facts.candidate_event.is_some()),
1770 replay_callback_observed: facts.is_some_and(|facts| facts.replay_event.is_some()),
1771 cleanup_verified: facts.map_or(state.cleanup_verified, |facts| facts.cleanup_verified),
1772 item_projected: projected,
1773 }
1774}
1775
1776pub(super) fn project_ssrf_oast_item(
1777 context: &mut AssessmentProjectionContext,
1778 knowledge: &KnowledgeBase,
1779 review: &CommittedSsrfOastReview,
1780) -> Result<(), AssessmentItemProjectionError> {
1781 if review.outcome != SsrfOastRuntimeOutcome::RepeatedCallbacksObserved {
1782 return Ok(());
1783 }
1784 if !positive_evidence_contract(knowledge, &review.evidence_ids) {
1785 return Err(AssessmentItemProjectionError::MissingEvidence);
1786 }
1787 let parameter = review
1788 .parameter_identity
1789 .clone()
1790 .ok_or(AssessmentItemProjectionError::InvalidStableSubjectIdentity)?;
1791 let target = AssessmentItemTarget::ssrf_oast_query(parameter.clone())?;
1792 if !context.has_subject(&review.subject) {
1793 context.register_subject(
1794 review.subject.clone(),
1795 StableAssessmentSubjectId::new(parameter)?,
1796 Vec::new(),
1797 )?;
1798 }
1799 for evidence_id in &review.evidence_ids {
1800 context.register_evidence(knowledge, evidence_id)?;
1801 }
1802 let midpoint = review.evidence_ids.len() / 2;
1803 context.project_differential(
1804 &SSRF_OAST_CAPABILITY,
1805 knowledge,
1806 &review.subject,
1807 &target,
1808 &review.evidence_ids[..midpoint],
1809 &review.evidence_ids[midpoint..],
1810 )?;
1811 Ok(())
1812}
1813
1814fn positive_evidence_contract(knowledge: &KnowledgeBase, evidence_ids: &[EvidenceId]) -> bool {
1815 evidence_ids.len().is_multiple_of(2)
1816 && [
1817 CONTROL_COMPLETE,
1818 PROVIDER_REGISTERED,
1819 ALLOCATIONS_COMPLETE,
1820 PREFLIGHT_CLEAN,
1821 CANDIDATE_DISPATCHED,
1822 CANDIDATE_CALLBACK_CORRELATED,
1823 REPLAY_DISPATCHED,
1824 REPLAY_CALLBACK_CORRELATED,
1825 REPEATED_CALLBACKS_CORRELATED,
1826 CLEANUP_VERIFIED,
1827 TARGET_ACCOUNTING_COMPLETE,
1828 PROVIDER_ACCOUNTING_COMPLETE,
1829 ]
1830 .into_iter()
1831 .all(|name| {
1832 evidence_ids.iter().any(|id| {
1833 knowledge.evidence(id).is_some_and(|evidence| {
1834 evidence.predicate().namespace() == SSRF_OAST_EVIDENCE_NAMESPACE
1835 && evidence.predicate().name() == name
1836 && evidence.value() == &EvidenceValue::Boolean(true)
1837 && evidence.source().component() == SSRF_OAST_EXECUTOR_ID
1838 })
1839 })
1840 })
1841}
1842
1843#[cfg(test)]
1844mod tests {
1845 use super::*;
1846 use std::sync::{
1847 atomic::{AtomicUsize, Ordering},
1848 Arc,
1849 };
1850
1851 use termivar_oast::{
1852 serve_provider_on_listener, serve_provider_on_listener_with_poll_barrier, AdminToken,
1853 LoopbackBind, ProviderConfig, ProviderLimits, ProviderState, ProviderTestPollBarrier,
1854 CALLBACK_SCHEMA, CLEANUP_SCHEMA, NATIVE_OAST_PROTOCOL_REVISION, SESSION_SCHEMA,
1855 };
1856 use tokio::{
1857 io::{AsyncReadExt, AsyncWriteExt},
1858 net::TcpListener,
1859 sync::Mutex as AsyncMutex,
1860 task::JoinHandle,
1861 };
1862
1863 use crate::{
1864 ssrf_oast_review::{
1865 SsrfOastAdminToken, SsrfOastReviewPolicy, MIN_SSRF_OAST_ADMIN_TOKEN_BYTES,
1866 },
1867 web_runtime::{AssessmentDisposition, WebAssessmentLimits, WebAssessmentRuntime},
1868 };
1869
1870 const ADMIN_SECRET: &[u8] = b"SSRF-OAST-RUNTIME-ADMIN-MUST-NOT-LEAK-91C8";
1871
1872 #[test]
1873 fn assessment_identity_keeps_the_existing_prefix_and_digest_bytes() {
1874 let subject = EntityId::new("endpoint:https://example.test/").unwrap();
1875 assert_eq!(
1876 assessment_identity(&subject),
1877 "assessment-4d5c4f4e055344698360e30846d555a83c7bc086cdd21af36a5bf73d7505dda9"
1878 );
1879 }
1880
1881 struct LoopbackFixture {
1882 target: url::Url,
1883 target_requests: Arc<AsyncMutex<Vec<String>>>,
1884 callback_attempts: Arc<AtomicUsize>,
1885 target_task: JoinHandle<()>,
1886 provider_task: JoinHandle<()>,
1887 provider_origin: PublicOrigin,
1888 }
1889
1890 impl Drop for LoopbackFixture {
1891 fn drop(&mut self) {
1892 self.target_task.abort();
1893 self.provider_task.abort();
1894 }
1895 }
1896
1897 async fn loopback_fixture() -> LoopbackFixture {
1898 loopback_fixture_with_replay_barrier(None).await
1899 }
1900
1901 async fn loopback_fixture_with_replay_barrier(
1902 replay_barrier: Option<ProviderTestPollBarrier>,
1903 ) -> LoopbackFixture {
1904 let provider_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1905 let provider_address = provider_listener.local_addr().unwrap();
1906 let provider_origin = PublicOrigin::from_test_loopback(provider_address).unwrap();
1907 let provider = ProviderState::new(
1908 ProviderConfig::new(
1909 LoopbackBind::new(provider_address).unwrap(),
1910 provider_origin.clone(),
1911 ProviderLimits::new(
1912 1,
1913 2,
1914 termivar_oast::HARD_MAX_EVENTS_PER_SESSION,
1915 8,
1916 termivar_oast::HARD_MAX_POLL_EVENTS_PER_RESPONSE,
1917 20_000,
1918 16,
1919 )
1920 .unwrap(),
1921 ),
1922 AdminToken::new(ADMIN_SECRET.to_vec()).unwrap(),
1923 )
1924 .unwrap();
1925 let provider_barrier = replay_barrier.clone();
1926 let provider_task = tokio::spawn(async move {
1927 let _ = if let Some(barrier) = provider_barrier {
1928 serve_provider_on_listener_with_poll_barrier(provider_listener, provider, barrier)
1929 .await
1930 } else {
1931 serve_provider_on_listener(provider_listener, provider).await
1932 };
1933 });
1934
1935 let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1936 let target_address = target_listener.local_addr().unwrap();
1937 let target_requests = Arc::new(AsyncMutex::new(Vec::new()));
1938 let recorded = target_requests.clone();
1939 let callback_attempts = Arc::new(AtomicUsize::new(0));
1940 let callback_ordinal = callback_attempts.clone();
1941 let target_task = tokio::spawn(async move {
1942 loop {
1943 let Ok((mut stream, _)) = target_listener.accept().await else {
1944 break;
1945 };
1946 let mut request = Vec::new();
1947 loop {
1948 let mut chunk = [0_u8; 1_024];
1949 let Ok(read) = stream.read(&mut chunk).await else {
1950 break;
1951 };
1952 if read == 0 {
1953 break;
1954 }
1955 request.extend_from_slice(&chunk[..read]);
1956 if request.windows(4).any(|window| window == b"\r\n\r\n") {
1957 break;
1958 }
1959 }
1960 let line = String::from_utf8_lossy(&request)
1961 .lines()
1962 .next()
1963 .unwrap_or_default()
1964 .to_owned();
1965 recorded.lock().await.push(line.clone());
1966 let request_target = line.split_whitespace().nth(1).unwrap_or("/");
1967 let parsed = url::Url::parse(&format!("http://fixture.invalid{request_target}"));
1968 if let Ok(parsed) = parsed {
1969 if let Some((_, callback)) =
1970 parsed.query_pairs().find(|(name, _)| name == "url")
1971 {
1972 if let Ok(callback) = url::Url::parse(callback.as_ref()) {
1973 let is_inert = callback
1974 .host_str()
1975 .is_some_and(|host| host.ends_with(".invalid"));
1976 if !is_inert {
1977 let ordinal = callback_ordinal.fetch_add(1, Ordering::SeqCst);
1978 if let (1, Some(barrier)) = (ordinal, replay_barrier.as_ref()) {
1979 barrier.arm();
1980 let barrier = barrier.clone();
1981 tokio::spawn(async move {
1982 if !barrier.wait_for_poll_snapshot().await {
1983 return;
1984 }
1985 let client = reqwest::Client::builder()
1986 .redirect(reqwest::redirect::Policy::none())
1987 .build()
1988 .unwrap();
1989 let _ = client.get(callback).send().await;
1990 });
1991 } else {
1992 let client = reqwest::Client::builder()
1993 .redirect(reqwest::redirect::Policy::none())
1994 .build()
1995 .unwrap();
1996 let _ = client.get(callback).send().await;
1997 }
1998 }
1999 }
2000 }
2001 }
2002 let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}";
2003 let _ = stream.write_all(response).await;
2004 let _ = stream.shutdown().await;
2005 }
2006 });
2007 LoopbackFixture {
2008 target: url::Url::parse(&format!(
2009 "http://{target_address}/proxy?url=https%3A%2F%2Fseed.example.invalid%2F"
2010 ))
2011 .unwrap(),
2012 target_requests,
2013 callback_attempts,
2014 target_task,
2015 provider_task,
2016 provider_origin,
2017 }
2018 }
2019
2020 async fn provider_rejecting_second_allocation() -> (PublicOrigin, JoinHandle<()>) {
2021 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2022 let address = listener.local_addr().unwrap();
2023 let origin = PublicOrigin::from_test_loopback(address).unwrap();
2024 let session_id = "BwcHBwcHBwcHBwcHBwcHBw";
2025 let callback_id = "CAgICAgICAgICAgICAgICA";
2026 let session_token = "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk";
2027 let callback_target = format!("{}c/{session_id}/{callback_id}", origin.as_str());
2028 let registration = serde_json::to_vec(&serde_json::json!({
2029 "schema": SESSION_SCHEMA,
2030 "session_id": session_id,
2031 "session_token": session_token,
2032 "expires_after_ms": 5_000,
2033 "protocol_revision": NATIVE_OAST_PROTOCOL_REVISION,
2034 }))
2035 .unwrap();
2036 let allocation = serde_json::to_vec(&serde_json::json!({
2037 "schema": CALLBACK_SCHEMA,
2038 "callback_id": callback_id,
2039 "callback_target": callback_target,
2040 }))
2041 .unwrap();
2042 let rejection = br#"{"error":"request rejected"}"#.to_vec();
2043 let cleanup = serde_json::to_vec(&serde_json::json!({
2044 "schema": CLEANUP_SCHEMA,
2045 "removed": true,
2046 }))
2047 .unwrap();
2048 let task = tokio::spawn(async move {
2049 for (status, body) in [
2050 ("201 Created", registration),
2051 ("201 Created", allocation),
2052 ("500 Internal Server Error", rejection),
2053 ("200 OK", cleanup),
2054 ] {
2055 let (mut stream, _) = listener.accept().await.unwrap();
2056 let mut request = vec![0_u8; 16 * 1_024];
2057 let _ = stream.read(&mut request).await.unwrap();
2058 let head = format!(
2059 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2060 body.len()
2061 );
2062 stream.write_all(head.as_bytes()).await.unwrap();
2063 stream.write_all(&body).await.unwrap();
2064 stream.shutdown().await.unwrap();
2065 }
2066 });
2067 (origin, task)
2068 }
2069
2070 #[test]
2071 fn provider_request_arithmetic_never_exceeds_twelve() {
2072 assert_eq!(1 + 2 + 1 + usize::from(MAX_POST_DISPATCH_POLLS) + 1, 12);
2073 assert_eq!(MAX_SSRF_OAST_REVIEW_REQUESTS, 3);
2074 assert_eq!(MAX_SSRF_OAST_REVIEW_ACTIVE_VERIFICATIONS, 1);
2075 }
2076
2077 #[test]
2078 fn provider_errors_reduce_to_closed_terminal_classes() {
2079 use crate::RuntimeBudgetDimension;
2080
2081 for (kind, expected) in [
2082 (
2083 NativeOastProviderErrorKind::Cancelled,
2084 SsrfOastTerminalState::Cancelled,
2085 ),
2086 (
2087 NativeOastProviderErrorKind::DeadlineExceeded,
2088 SsrfOastTerminalState::Expired,
2089 ),
2090 (
2091 NativeOastProviderErrorKind::PollLimit,
2092 SsrfOastTerminalState::PollExhausted,
2093 ),
2094 (
2095 NativeOastProviderErrorKind::ProviderAccessRejected,
2096 SsrfOastTerminalState::ProviderAuthenticationFailed,
2097 ),
2098 (
2099 NativeOastProviderErrorKind::ProviderResponseInvalid,
2100 SsrfOastTerminalState::MalformedProviderResponse,
2101 ),
2102 (
2103 NativeOastProviderErrorKind::RuntimeBudget(RuntimeBudgetDimension::TotalRequests),
2104 SsrfOastTerminalState::BudgetExhausted,
2105 ),
2106 (
2107 NativeOastProviderErrorKind::InvalidLifecycle,
2108 SsrfOastTerminalState::Incomplete,
2109 ),
2110 ] {
2111 assert_eq!(provider_terminal(kind), expected);
2112 }
2113 }
2114
2115 #[test]
2116 fn local_credential_rejection_is_not_remote_authentication_evidence() {
2117 assert_eq!(
2118 provider_terminal(NativeOastProviderErrorKind::ProviderRejected),
2119 SsrfOastTerminalState::Incomplete
2120 );
2121 }
2122
2123 #[test]
2124 fn provider_unavailability_is_not_remote_authentication_evidence() {
2125 for kind in [
2126 NativeOastProviderErrorKind::ProviderThrottled,
2127 NativeOastProviderErrorKind::ProviderNotFound,
2128 NativeOastProviderErrorKind::ProviderGone,
2129 NativeOastProviderErrorKind::ProviderRedirectRefused,
2130 NativeOastProviderErrorKind::ProviderServerFailure,
2131 NativeOastProviderErrorKind::ProviderTransportFailure,
2132 NativeOastProviderErrorKind::ProviderRequestConstruction,
2133 NativeOastProviderErrorKind::ProviderClientInitialization,
2134 NativeOastProviderErrorKind::ProviderUnexpectedStatus,
2135 ] {
2136 assert_eq!(provider_terminal(kind), SsrfOastTerminalState::Incomplete);
2137 }
2138 assert_eq!(
2141 provider_terminal(NativeOastProviderErrorKind::ProviderAccessRejected),
2142 SsrfOastTerminalState::ProviderAuthenticationFailed
2143 );
2144 }
2145
2146 #[test]
2147 fn provider_failure_sidecars_have_closed_raw_free_wire_values() {
2148 use NativeOastProviderErrorKind as Kind;
2149 use SsrfOastProviderFailure as Failure;
2150
2151 for (kind, expected, wire) in [
2152 (
2153 Kind::ProviderRejected,
2154 Failure::CredentialInputInvalid,
2155 "credential_input_invalid",
2156 ),
2157 (
2158 Kind::ProviderAccessRejected,
2159 Failure::AccessRejected,
2160 "access_rejected",
2161 ),
2162 (Kind::ProviderThrottled, Failure::Throttled, "throttled"),
2163 (Kind::ProviderNotFound, Failure::NotFound, "not_found"),
2164 (Kind::ProviderGone, Failure::Gone, "gone"),
2165 (
2166 Kind::ProviderRedirectRefused,
2167 Failure::RedirectRefused,
2168 "redirect_refused",
2169 ),
2170 (
2171 Kind::ProviderServerFailure,
2172 Failure::ServerFailure,
2173 "server_failure",
2174 ),
2175 (
2176 Kind::ProviderTransportFailure,
2177 Failure::TransportFailure,
2178 "transport_failure",
2179 ),
2180 (
2181 Kind::ProviderRequestConstruction,
2182 Failure::RequestConstruction,
2183 "request_construction",
2184 ),
2185 (
2186 Kind::ProviderClientInitialization,
2187 Failure::ClientInitialization,
2188 "client_initialization",
2189 ),
2190 (
2191 Kind::ProviderUnexpectedStatus,
2192 Failure::UnexpectedStatus,
2193 "unexpected_status",
2194 ),
2195 (
2196 Kind::ProviderResponseInvalid,
2197 Failure::MalformedResponse,
2198 "malformed_response",
2199 ),
2200 (
2201 Kind::ProviderSessionMismatch,
2202 Failure::MalformedResponse,
2203 "malformed_response",
2204 ),
2205 (
2206 Kind::ProviderCallbackMismatch,
2207 Failure::MalformedResponse,
2208 "malformed_response",
2209 ),
2210 (
2211 Kind::ProviderPageIncomplete,
2212 Failure::MalformedResponse,
2213 "malformed_response",
2214 ),
2215 (
2216 Kind::CorrelationRejected,
2217 Failure::MalformedResponse,
2218 "malformed_response",
2219 ),
2220 (Kind::Cancelled, Failure::Cancelled, "cancelled"),
2221 (Kind::DeadlineExceeded, Failure::Timeout, "timeout"),
2222 (Kind::ProviderExpired, Failure::Expired, "expired"),
2223 (
2224 Kind::RuntimeBudget(crate::RuntimeBudgetDimension::TotalRequests),
2225 Failure::BudgetRejected,
2226 "budget_rejected",
2227 ),
2228 (
2229 Kind::ParentBudgetTooSmall,
2230 Failure::BudgetRejected,
2231 "budget_rejected",
2232 ),
2233 (
2234 Kind::RequestLimit,
2235 Failure::BudgetRejected,
2236 "budget_rejected",
2237 ),
2238 (
2239 Kind::RequestByteLimit,
2240 Failure::BudgetRejected,
2241 "budget_rejected",
2242 ),
2243 (
2244 Kind::ResponseByteLimit,
2245 Failure::BudgetRejected,
2246 "budget_rejected",
2247 ),
2248 (Kind::PollLimit, Failure::PollLimit, "poll_limit"),
2249 (
2250 Kind::CleanupUnverified,
2251 Failure::CleanupUnverified,
2252 "cleanup_unverified",
2253 ),
2254 (
2255 Kind::InternalInvariant,
2256 Failure::InternalInvariant,
2257 "internal_invariant",
2258 ),
2259 (
2260 Kind::InvalidLimits,
2261 Failure::LocalRejected,
2262 "local_rejected",
2263 ),
2264 (
2265 Kind::InvalidProviderOrigin,
2266 Failure::LocalRejected,
2267 "local_rejected",
2268 ),
2269 (
2270 Kind::ProviderTargetOriginOverlap,
2271 Failure::LocalRejected,
2272 "local_rejected",
2273 ),
2274 (
2275 Kind::AuthorityAlreadyMinted,
2276 Failure::LocalRejected,
2277 "local_rejected",
2278 ),
2279 (
2280 Kind::OperationNotPermitted,
2281 Failure::LocalRejected,
2282 "local_rejected",
2283 ),
2284 (
2285 Kind::InvalidLifecycle,
2286 Failure::LocalRejected,
2287 "local_rejected",
2288 ),
2289 (
2290 Kind::RegistrationLimit,
2291 Failure::LocalRejected,
2292 "local_rejected",
2293 ),
2294 (
2295 Kind::CallbackLimit,
2296 Failure::LocalRejected,
2297 "local_rejected",
2298 ),
2299 ] {
2300 let actual = SsrfOastProviderFailure::from(kind);
2301 assert_eq!(actual, expected);
2302 assert_eq!(serde_json::to_value(actual).unwrap(), wire);
2303 }
2304 }
2305
2306 #[test]
2307 fn client_failure_classes_reach_the_serializable_audit_without_response_data() {
2308 use crate::native_oast_provider::provider_client_failure_kind;
2309 use termivar_oast::{
2310 NativeOastBoundaryRejection, NativeOastClientErrorKind as Client,
2311 NativeOastHttpFailure as Http,
2312 };
2313 use SsrfOastProviderFailure as Failure;
2314 use SsrfOastTerminalState as Terminal;
2315
2316 let target = url::Url::parse("http://127.0.0.1:41001/").unwrap();
2317 let provider =
2318 PublicOrigin::from_test_loopback("127.0.0.1:41002".parse().unwrap()).unwrap();
2319 let policy = SsrfOastReviewPolicy::for_loopback(target, provider, 1, 250, 5_000).unwrap();
2320 for (input, http, failure, terminal, wire) in [
2323 (
2324 Client::UnexpectedStatus,
2325 Some(Http::AccessRejected),
2326 Failure::AccessRejected,
2327 Terminal::ProviderAuthenticationFailed,
2328 "access_rejected",
2329 ),
2330 (
2331 Client::UnexpectedStatus,
2332 Some(Http::Throttled),
2333 Failure::Throttled,
2334 Terminal::Incomplete,
2335 "throttled",
2336 ),
2337 (
2338 Client::UnexpectedStatus,
2339 Some(Http::NotFound),
2340 Failure::NotFound,
2341 Terminal::Incomplete,
2342 "not_found",
2343 ),
2344 (
2345 Client::UnexpectedStatus,
2346 Some(Http::Gone),
2347 Failure::Gone,
2348 Terminal::Incomplete,
2349 "gone",
2350 ),
2351 (
2352 Client::UnexpectedStatus,
2353 Some(Http::RedirectRefused),
2354 Failure::RedirectRefused,
2355 Terminal::Incomplete,
2356 "redirect_refused",
2357 ),
2358 (
2359 Client::UnexpectedStatus,
2360 Some(Http::ServerFailure),
2361 Failure::ServerFailure,
2362 Terminal::Incomplete,
2363 "server_failure",
2364 ),
2365 (
2366 Client::UnexpectedStatus,
2367 Some(Http::Unexpected),
2368 Failure::UnexpectedStatus,
2369 Terminal::Incomplete,
2370 "unexpected_status",
2371 ),
2372 (
2373 Client::UnexpectedStatus,
2374 None,
2375 Failure::UnexpectedStatus,
2376 Terminal::Incomplete,
2377 "unexpected_status",
2378 ),
2379 (
2380 Client::TransportFailure,
2381 None,
2382 Failure::TransportFailure,
2383 Terminal::Incomplete,
2384 "transport_failure",
2385 ),
2386 (
2387 Client::RequestConstruction,
2388 None,
2389 Failure::RequestConstruction,
2390 Terminal::Incomplete,
2391 "request_construction",
2392 ),
2393 (
2394 Client::ClientInitialization,
2395 None,
2396 Failure::ClientInitialization,
2397 Terminal::Incomplete,
2398 "client_initialization",
2399 ),
2400 (
2401 Client::Cancelled,
2402 None,
2403 Failure::Cancelled,
2404 Terminal::Cancelled,
2405 "cancelled",
2406 ),
2407 (
2408 Client::DeadlineExceeded,
2409 None,
2410 Failure::Timeout,
2411 Terminal::Expired,
2412 "timeout",
2413 ),
2414 (
2415 Client::MalformedResponse,
2416 None,
2417 Failure::MalformedResponse,
2418 Terminal::MalformedProviderResponse,
2419 "malformed_response",
2420 ),
2421 (
2422 Client::ProtocolMismatch,
2423 None,
2424 Failure::MalformedResponse,
2425 Terminal::MalformedProviderResponse,
2426 "malformed_response",
2427 ),
2428 (
2429 Client::UnsupportedMedia,
2430 None,
2431 Failure::MalformedResponse,
2432 Terminal::MalformedProviderResponse,
2433 "malformed_response",
2434 ),
2435 (
2436 Client::ResponseOriginMismatch,
2437 None,
2438 Failure::MalformedResponse,
2439 Terminal::MalformedProviderResponse,
2440 "malformed_response",
2441 ),
2442 (
2443 Client::AccountingInvariant,
2444 None,
2445 Failure::MalformedResponse,
2446 Terminal::MalformedProviderResponse,
2447 "malformed_response",
2448 ),
2449 (
2450 Client::CallbackTargetMismatch,
2451 None,
2452 Failure::MalformedResponse,
2453 Terminal::MalformedProviderResponse,
2454 "malformed_response",
2455 ),
2456 (
2457 Client::ResponseTooLarge,
2458 None,
2459 Failure::BudgetRejected,
2460 Terminal::BudgetExhausted,
2461 "budget_rejected",
2462 ),
2463 (
2464 Client::BoundaryRejected(NativeOastBoundaryRejection::OperationNotPermitted),
2465 None,
2466 Failure::LocalRejected,
2467 Terminal::Incomplete,
2468 "local_rejected",
2469 ),
2470 ] {
2471 let provider_kind = provider_client_failure_kind(input, http);
2472 assert_eq!(provider_terminal(provider_kind), terminal);
2473 let mut state = SsrfOastExecutionState::default();
2474 state.retain_provider_failures(Some(provider_kind), None);
2475 let audit = audit(
2476 &policy,
2477 &state,
2478 SsrfOastRuntimeOutcome::Incomplete,
2479 0,
2480 false,
2481 );
2482 assert_eq!(audit.provider_failure(), Some(failure));
2483 assert_eq!(audit.cleanup_failure(), None);
2484 assert_eq!(audit.provider_request_count(), 0);
2485 assert!(!audit.item_projected());
2486 let serialized = serde_json::to_value(audit).unwrap();
2487 assert_eq!(serialized["provider_failure"], wire);
2488 assert!(serialized.get("cleanup_failure").is_none());
2489 }
2490 }
2491
2492 #[test]
2493 fn provider_diagnostics_preserve_first_error_and_separate_cleanup_without_facts() {
2494 let target = url::Url::parse("http://127.0.0.1:41001/").unwrap();
2495 let provider =
2496 PublicOrigin::from_test_loopback("127.0.0.1:41002".parse().unwrap()).unwrap();
2497 let policy = SsrfOastReviewPolicy::for_loopback(target, provider, 1, 250, 5_000).unwrap();
2498 let mut state = SsrfOastExecutionState::default();
2499 state.retain_provider_failures(
2500 Some(NativeOastProviderErrorKind::ProviderServerFailure),
2501 None,
2502 );
2503 state.retain_provider_failures(
2504 Some(NativeOastProviderErrorKind::ProviderTransportFailure),
2505 Some(NativeOastProviderErrorKind::ProviderAccessRejected),
2506 );
2507 state.retain_provider_failures(None, None);
2508 let primary = audit(
2509 &policy,
2510 &state,
2511 SsrfOastRuntimeOutcome::Incomplete,
2512 0,
2513 false,
2514 );
2515 assert_eq!(
2516 primary.provider_failure(),
2517 Some(SsrfOastProviderFailure::ServerFailure)
2518 );
2519 assert_eq!(
2520 primary.cleanup_failure(),
2521 Some(SsrfOastProviderFailure::AccessRejected)
2522 );
2523 assert_eq!(primary.outcome(), SsrfOastRuntimeOutcome::Incomplete);
2524 assert!(!primary.item_projected());
2525 let serialized = serde_json::to_value(&primary).unwrap();
2526 assert_eq!(serialized["provider_failure"], "server_failure");
2527 assert_eq!(serialized["cleanup_failure"], "access_rejected");
2528
2529 let mut state = SsrfOastExecutionState::default();
2530 state.retain_provider_failures(None, Some(NativeOastProviderErrorKind::Cancelled));
2531 let cleanup = audit(
2532 &policy,
2533 &state,
2534 SsrfOastRuntimeOutcome::Incomplete,
2535 0,
2536 false,
2537 );
2538 assert_eq!(cleanup.provider_failure(), None);
2539 assert_eq!(
2540 cleanup.cleanup_failure(),
2541 Some(SsrfOastProviderFailure::Cancelled)
2542 );
2543 assert_eq!(cleanup.provider_request_count(), 0);
2544 assert!(!cleanup.cleanup_verified());
2545 let serialized = serde_json::to_value(&cleanup).unwrap();
2546 assert!(serialized.get("provider_failure").is_none());
2547 assert_eq!(serialized["cleanup_failure"], "cancelled");
2548 }
2549
2550 #[test]
2551 fn every_domain_outcome_has_one_closed_runtime_projection() {
2552 use SsrfOastReviewOutcome as Domain;
2553 use SsrfOastRuntimeOutcome as Runtime;
2554
2555 for (domain, runtime) in [
2556 (Domain::NotEligible, Runtime::NotEligible),
2557 (Domain::ControlIncomplete, Runtime::ControlIncomplete),
2558 (
2559 Domain::RegistrationIncomplete,
2560 Runtime::RegistrationIncomplete,
2561 ),
2562 (Domain::AllocationIncomplete, Runtime::AllocationIncomplete),
2563 (
2564 Domain::PreflightContaminated,
2565 Runtime::PreflightContaminated,
2566 ),
2567 (Domain::TargetNotDispatched, Runtime::TargetNotDispatched),
2568 (Domain::NoCallback, Runtime::NoCallback),
2569 (Domain::CandidateOnly, Runtime::CandidateOnly),
2570 (Domain::ReplayOnly, Runtime::ReplayOnly),
2571 (Domain::WrongCallback, Runtime::WrongCallback),
2572 (
2573 Domain::EventIdentityConflict,
2574 Runtime::EventIdentityConflict,
2575 ),
2576 (Domain::CorrelationMismatch, Runtime::CorrelationMismatch),
2577 (Domain::DuplicateOnly, Runtime::DuplicateOnly),
2578 (Domain::CleanupIncomplete, Runtime::CleanupIncomplete),
2579 (
2580 Domain::DefensiveInterference,
2581 Runtime::DefensiveInterference,
2582 ),
2583 (Domain::RateLimited, Runtime::RateLimited),
2584 (
2585 Domain::ProviderAuthenticationFailed,
2586 Runtime::ProviderAuthenticationFailed,
2587 ),
2588 (
2589 Domain::MalformedProviderResponse,
2590 Runtime::MalformedProviderResponse,
2591 ),
2592 (Domain::PollExhausted, Runtime::PollExhausted),
2593 (Domain::Expired, Runtime::Expired),
2594 (Domain::Cancelled, Runtime::Cancelled),
2595 (Domain::BudgetExhausted, Runtime::BudgetExhausted),
2596 (Domain::Truncated, Runtime::Truncated),
2597 (Domain::Incomplete, Runtime::Incomplete),
2598 (
2599 Domain::RepeatedCallbacksObserved,
2600 Runtime::RepeatedCallbacksObserved,
2601 ),
2602 ] {
2603 assert_eq!(SsrfOastRuntimeOutcome::from(domain), runtime);
2604 }
2605 }
2606
2607 #[test]
2608 fn polling_phase_requires_its_expected_callback_set() {
2609 assert!(!SsrfOastPollPhase::Candidate.expected_event_observed(false, false));
2610 assert!(!SsrfOastPollPhase::Candidate.expected_event_observed(false, true));
2611 assert!(SsrfOastPollPhase::Candidate.expected_event_observed(true, false));
2612 assert!(SsrfOastPollPhase::Candidate.expected_event_observed(true, true));
2613
2614 assert!(!SsrfOastPollPhase::Replay.expected_event_observed(false, false));
2615 assert!(!SsrfOastPollPhase::Replay.expected_event_observed(true, false));
2616 assert!(!SsrfOastPollPhase::Replay.expected_event_observed(false, true));
2617 assert!(SsrfOastPollPhase::Replay.expected_event_observed(true, true));
2618 }
2619
2620 #[test]
2621 fn lifecycle_gate_requires_clean_preflight_and_stops_on_non_timeout_terminal() {
2622 let candidate: CallbackId = "AQEBAQEBAQEBAQEBAQEBAQ".parse().unwrap();
2623 let replay: CallbackId = "AgICAgICAgICAgICAgICAg".parse().unwrap();
2624 let mut facts = SsrfOastReviewFacts::new(&candidate, &replay);
2625 facts.control_complete = true;
2626 facts.provider_registered = true;
2627 facts.allocations_complete = true;
2628 facts.preflight_clean = true;
2629 let lifecycle = SsrfOastLifecycleEvidence::from_facts(&facts, false);
2630 assert!(lifecycle.candidate_ready());
2631 assert!(polling_may_continue(&facts));
2632
2633 facts.terminal = Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch);
2634 assert!(polling_may_continue(&facts));
2635 facts.terminal = Some(SsrfOastTerminalState::Cancelled);
2636 assert!(!polling_may_continue(&facts));
2637
2638 facts.terminal = None;
2639 facts.preflight_clean = false;
2640 assert!(!SsrfOastLifecycleEvidence::from_facts(&facts, false).candidate_ready());
2641 assert!(!SsrfOastLifecycleEvidence::terminal().candidate_ready());
2642 }
2643
2644 #[test]
2645 fn post_dispatch_timeout_preserves_callback_eligibility_but_pre_dispatch_failure_does_not() {
2646 use crate::oast::OastEventKey;
2647
2648 assert!(target_dispatch_outcome_is_accounted(
2649 TransportDispatchOutcome::RequestTimeout
2650 ));
2651 assert!(!target_dispatch_outcome_is_accounted(
2652 TransportDispatchOutcome::TransportFailure
2653 ));
2654
2655 let candidate: CallbackId = "AQEBAQEBAQEBAQEBAQEBAQ".parse().unwrap();
2656 let replay: CallbackId = "AgICAgICAgICAgICAgICAg".parse().unwrap();
2657 let mut facts = SsrfOastReviewFacts::new(&candidate, &replay);
2658 facts.control_complete = true;
2659 facts.provider_registered = true;
2660 facts.allocations_complete = true;
2661 facts.preflight_clean = true;
2662 facts.candidate_dispatched = true;
2663 facts.replay_dispatched = true;
2664 facts.candidate_event = Some(SsrfOastObservedEvent::from_reduced(
2665 &candidate,
2666 &OastEventKey::new([3; 32]).unwrap(),
2667 ));
2668 facts.replay_event = Some(SsrfOastObservedEvent::from_reduced(
2669 &replay,
2670 &OastEventKey::new([4; 32]).unwrap(),
2671 ));
2672 facts.correlations_distinct = true;
2673 facts.same_correlation_scope = true;
2674 facts.cleanup_verified = true;
2675 facts.target_accounting_complete = [
2676 TransportDispatchOutcome::Completed,
2677 TransportDispatchOutcome::RequestTimeout,
2678 TransportDispatchOutcome::Completed,
2679 ]
2680 .into_iter()
2681 .all(target_dispatch_outcome_is_accounted);
2682 facts.provider_accounting_complete = true;
2683 facts.terminal = Some(SsrfOastTerminalState::TargetTimeoutAfterDispatch);
2684 assert_eq!(
2685 evaluate_ssrf_oast_review(&facts).unwrap(),
2686 SsrfOastReviewOutcome::RepeatedCallbacksObserved
2687 );
2688
2689 facts.terminal = None;
2690 facts.replay_dispatched = false;
2691 facts.target_accounting_complete =
2692 target_dispatch_outcome_is_accounted(TransportDispatchOutcome::TransportFailure);
2693 assert_eq!(
2694 evaluate_ssrf_oast_review(&facts).unwrap(),
2695 SsrfOastReviewOutcome::TargetNotDispatched
2696 );
2697 }
2698
2699 #[test]
2700 fn audit_is_raw_free_and_uses_exact_bounded_counts() {
2701 let target =
2702 url::Url::parse("http://127.0.0.1:41001/proxy?url=https://seed.invalid/").unwrap();
2703 let provider =
2704 PublicOrigin::from_test_loopback("127.0.0.1:41002".parse().unwrap()).unwrap();
2705 let policy = SsrfOastReviewPolicy::for_loopback(target, provider, 1, 250, 5_000).unwrap();
2706 let candidate: CallbackId = "AQEBAQEBAQEBAQEBAQEBAQ".parse().unwrap();
2707 let replay: CallbackId = "AgICAgICAgICAgICAgICAg".parse().unwrap();
2708 let mut facts = SsrfOastReviewFacts::new(&candidate, &replay);
2709 facts.preflight_clean = true;
2710 facts.cleanup_verified = true;
2711 let state = SsrfOastExecutionState {
2712 facts: Some(facts),
2713 provider_request_count: 7,
2714 source: Some(SsrfOastCandidateSource::ObservedUrlQuery),
2715 cleanup_verified: true,
2716 ..SsrfOastExecutionState::default()
2717 };
2718 let audit = audit(
2719 &policy,
2720 &state,
2721 SsrfOastRuntimeOutcome::NoCallback,
2722 3,
2723 false,
2724 );
2725 assert_eq!(audit.outcome(), SsrfOastRuntimeOutcome::NoCallback);
2726 assert_eq!(audit.candidate_source(), Some("observed_url_query"));
2727 assert_eq!(audit.target_request_count(), 3);
2728 assert_eq!(audit.provider_request_count(), 7);
2729 assert_eq!(audit.provider_failure(), None);
2730 assert_eq!(audit.cleanup_failure(), None);
2731 let serialized = serde_json::to_value(&audit).unwrap();
2732 assert!(serialized.get("provider_failure").is_none());
2733 assert!(serialized.get("cleanup_failure").is_none());
2734 assert_eq!(audit.active_verification_count(), 1);
2735 assert!(audit.preflight_clean());
2736 assert!(audit.cleanup_verified());
2737 assert!(!audit.candidate_callback_observed());
2738 assert!(!audit.replay_callback_observed());
2739 assert!(!audit.item_projected());
2740 let rendered = format!("{audit:?}");
2741 assert!(!rendered.contains("seed.invalid"));
2742 assert!(!rendered.contains("127.0.0.1"));
2743 }
2744
2745 #[tokio::test]
2746 async fn loopback_target_reaches_two_distinct_callbacks_and_projects_one_item() {
2747 assert!(ADMIN_SECRET.len() >= MIN_SSRF_OAST_ADMIN_TOKEN_BYTES);
2748 let fixture = loopback_fixture().await;
2749 let policy = SsrfOastReviewPolicy::for_loopback(
2750 fixture.target.clone(),
2751 fixture.provider_origin.clone(),
2752 1,
2753 250,
2754 5_000,
2755 )
2756 .unwrap();
2757 let administrator = SsrfOastAdminToken::new(ADMIN_SECRET.to_vec()).unwrap();
2758 let mut runtime = WebAssessmentRuntime::builder(fixture.target.clone())
2759 .with_ssrf_oast_review(policy, administrator)
2760 .build()
2761 .unwrap();
2762 let report = runtime.analyze().await.unwrap();
2763
2764 let audit = report.ssrf_oast_review_audit().unwrap();
2765 assert_eq!(
2766 audit.outcome(),
2767 SsrfOastRuntimeOutcome::RepeatedCallbacksObserved
2768 );
2769 assert_eq!(audit.target_request_count(), 3);
2770 assert_eq!(audit.active_verification_count(), 1);
2771 assert!(audit.preflight_clean());
2772 assert!(audit.candidate_callback_observed());
2773 assert!(audit.replay_callback_observed());
2774 assert!(audit.cleanup_verified());
2775 assert!(audit.item_projected());
2776 assert!(audit.provider_request_count() <= 12);
2777 let item = report
2778 .assessment_items()
2779 .iter()
2780 .find(|item| item.capability_id() == SSRF_OAST_REVIEW_CAPABILITY_ID)
2781 .unwrap();
2782 assert_eq!(item.disposition(), AssessmentDisposition::NeedsReview);
2783 assert_eq!(report.usage().active_verifications(), 1);
2784
2785 let requests = fixture.target_requests.lock().await;
2786 assert_eq!(requests.len(), 4);
2787 assert!(requests.iter().all(|request| request.starts_with("GET ")));
2788 assert_eq!(
2789 requests
2790 .iter()
2791 .filter(|request| request.contains('?'))
2792 .count(),
2793 3
2794 );
2795 assert_eq!(fixture.callback_attempts.load(Ordering::SeqCst), 2);
2796 let debug = format!("{report:?}");
2797 assert!(!debug.contains(std::str::from_utf8(ADMIN_SECRET).unwrap()));
2798 assert!(!debug.contains("seed.example.invalid"));
2799 }
2800
2801 #[tokio::test]
2802 async fn polling_waits_for_the_expected_replay_event_after_an_empty_first_poll() {
2803 let fixture =
2804 loopback_fixture_with_replay_barrier(Some(ProviderTestPollBarrier::new())).await;
2805 let policy = SsrfOastReviewPolicy::for_loopback(
2806 fixture.target.clone(),
2807 fixture.provider_origin.clone(),
2808 3,
2809 250,
2810 5_000,
2811 )
2812 .unwrap();
2813 let administrator = SsrfOastAdminToken::new(ADMIN_SECRET.to_vec()).unwrap();
2814 let mut runtime = WebAssessmentRuntime::builder(fixture.target.clone())
2815 .with_ssrf_oast_review(policy, administrator)
2816 .build()
2817 .unwrap();
2818
2819 let report = runtime.analyze().await.unwrap();
2820 let audit = report.ssrf_oast_review_audit().unwrap();
2821 assert_eq!(
2822 audit.outcome(),
2823 SsrfOastRuntimeOutcome::RepeatedCallbacksObserved
2824 );
2825 assert!(audit.candidate_callback_observed());
2826 assert!(audit.replay_callback_observed());
2827 assert!(audit.cleanup_verified());
2828 assert!(audit.item_projected());
2829 assert_eq!(audit.provider_request_count(), 8);
2830 assert_eq!(audit.target_request_count(), 3);
2831 let mut matching_items = report
2832 .assessment_items()
2833 .iter()
2834 .filter(|item| item.capability_id() == SSRF_OAST_REVIEW_CAPABILITY_ID);
2835 let matching_item = matching_items.next().unwrap();
2836 assert!(matching_items.next().is_none());
2837 assert_eq!(
2838 matching_item.disposition(),
2839 AssessmentDisposition::NeedsReview
2840 );
2841 assert_eq!(fixture.target_requests.lock().await.len(), 4);
2842 assert_eq!(fixture.callback_attempts.load(Ordering::SeqCst), 2);
2843 }
2844
2845 #[tokio::test]
2846 async fn insufficient_parent_budget_stops_after_control_before_provider_dispatch() {
2847 let fixture = loopback_fixture().await;
2848 let policy = SsrfOastReviewPolicy::for_loopback(
2849 fixture.target.clone(),
2850 fixture.provider_origin.clone(),
2851 1,
2852 250,
2853 5_000,
2854 )
2855 .unwrap();
2856 let administrator = SsrfOastAdminToken::new(ADMIN_SECRET.to_vec()).unwrap();
2857 let limits = WebAssessmentLimits::default()
2858 .with_max_total_requests(3)
2859 .unwrap();
2860 let mut runtime = WebAssessmentRuntime::builder(fixture.target.clone())
2861 .limits(limits)
2862 .with_ssrf_oast_review(policy, administrator)
2863 .build()
2864 .unwrap();
2865
2866 let report = runtime.analyze().await.unwrap();
2867 let audit = report.ssrf_oast_review_audit().unwrap();
2868 assert_eq!(audit.outcome(), SsrfOastRuntimeOutcome::Incomplete);
2869 assert_eq!(audit.target_request_count(), 1);
2870 assert_eq!(audit.provider_request_count(), 0);
2871 assert_eq!(audit.active_verification_count(), 0);
2872 assert!(!audit.cleanup_verified());
2873 assert!(!audit.item_projected());
2874 assert_eq!(fixture.target_requests.lock().await.len(), 2);
2875 }
2876
2877 #[tokio::test]
2878 async fn rejected_provider_registration_is_metered_and_never_reaches_active_dispatch() {
2879 let fixture = loopback_fixture().await;
2880 let policy = SsrfOastReviewPolicy::for_loopback(
2881 fixture.target.clone(),
2882 fixture.provider_origin.clone(),
2883 1,
2884 250,
2885 5_000,
2886 )
2887 .unwrap();
2888 let wrong_administrator =
2889 SsrfOastAdminToken::new(b"WRONG-OAST-ADMIN-CREDENTIAL-DOES-NOT-MATCH-55D1".to_vec())
2890 .unwrap();
2891 let mut runtime = WebAssessmentRuntime::builder(fixture.target.clone())
2892 .with_ssrf_oast_review(policy, wrong_administrator)
2893 .build()
2894 .unwrap();
2895
2896 let report = runtime.analyze().await.unwrap();
2897 let audit = report.ssrf_oast_review_audit().unwrap();
2898 assert_eq!(audit.outcome(), SsrfOastRuntimeOutcome::Incomplete);
2899 assert_eq!(audit.target_request_count(), 1);
2900 assert_eq!(audit.provider_request_count(), 1);
2901 assert_eq!(audit.active_verification_count(), 0);
2902 assert!(!audit.cleanup_verified());
2903 assert!(!audit.item_projected());
2904 assert_eq!(fixture.target_requests.lock().await.len(), 2);
2905 }
2906
2907 #[tokio::test]
2908 async fn second_allocation_rejection_cleans_up_and_never_reaches_active_dispatch() {
2909 let fixture = loopback_fixture().await;
2910 let (provider_origin, provider_task) = provider_rejecting_second_allocation().await;
2911 let policy = SsrfOastReviewPolicy::for_loopback(
2912 fixture.target.clone(),
2913 provider_origin,
2914 1,
2915 250,
2916 5_000,
2917 )
2918 .unwrap();
2919 let administrator = SsrfOastAdminToken::new(ADMIN_SECRET.to_vec()).unwrap();
2920 let mut runtime = WebAssessmentRuntime::builder(fixture.target.clone())
2921 .with_ssrf_oast_review(policy, administrator)
2922 .build()
2923 .unwrap();
2924
2925 let report = runtime.analyze().await.unwrap();
2926 let audit = report.ssrf_oast_review_audit().unwrap();
2927 assert_eq!(audit.outcome(), SsrfOastRuntimeOutcome::Incomplete);
2928 assert_eq!(audit.target_request_count(), 1);
2929 assert_eq!(audit.provider_request_count(), 4);
2930 assert_eq!(audit.active_verification_count(), 0);
2931 assert!(audit.cleanup_verified());
2932 assert!(!audit.item_projected());
2933 assert_eq!(fixture.target_requests.lock().await.len(), 2);
2934 provider_task.await.unwrap();
2935 }
2936}