Skip to main content

termivar_scanner/
ssrf_oast_review.rs

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