1use std::{fmt, mem, str::FromStr};
12
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use termivar_oast::{CallbackTarget, NativeOastRoute, PublicOrigin};
16use thiserror::Error;
17use url::{Host, Url};
18use zeroize::{Zeroize, Zeroizing};
19
20pub const XML_EXTERNAL_ENTITY_REVIEW_POLICY_SCHEMA: &str =
22 "security.xml-external-entity-review-policy/v1";
23pub const XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA: &str =
25 "security.xml-external-entity-review-audit/v1";
26pub const XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM: &str =
28 "security.xml-external-entity-resolution-review/v1";
29pub const XML_EXTERNAL_ENTITY_REVIEW_ACTION_ID: &str =
31 "web.review.xml.external-entity-resolution@1";
32pub const XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID: &str =
34 "xml.external-entity.repeated-outbound-interaction@1";
35pub const XML_EXTERNAL_ENTITY_REVIEW_METHOD: &str = "POST";
37pub const XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE: &str = "application/xml; charset=utf-8";
39pub const XML_EXTERNAL_ENTITY_REVIEW_CONTROL_MODEL: &str =
41 "external_general_entity_declared_not_referenced";
42
43pub const MAX_XML_EXTERNAL_ENTITY_REVIEW_POLICY_BYTES: usize = 64 * 1024;
45pub const MAX_XML_EXTERNAL_ENTITY_URL_BYTES: usize = 2 * 1024;
47pub const MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES: usize = 4 * 1024;
49pub(crate) const MAX_XML_EXTERNAL_ENTITY_TARGET_RESPONSE_BYTES: u64 = 16 * 1024;
51pub const XML_EXTERNAL_ENTITY_TARGET_REQUESTS: usize = 3;
53pub const XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS: usize = 1;
55pub const XML_EXTERNAL_ENTITY_CALLBACKS: usize = 3;
57pub const MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS: usize = 12;
59pub const MIN_XML_EXTERNAL_ENTITY_POLLS_PER_LEG: u16 = 1;
61pub const MAX_XML_EXTERNAL_ENTITY_POLLS_PER_LEG: u16 = 2;
63pub const MIN_XML_EXTERNAL_ENTITY_POLL_INTERVAL_MS: u64 = 250;
65pub const MAX_XML_EXTERNAL_ENTITY_POLL_INTERVAL_MS: u64 = 2_000;
67pub const MIN_XML_EXTERNAL_ENTITY_LIFETIME_MS: u64 = 5_000;
69pub const MAX_XML_EXTERNAL_ENTITY_LIFETIME_MS: u64 = 30_000;
71pub const MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES: usize = 32;
73pub const MAX_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES: usize = 4_096;
75
76const POLICY_ID_DOMAIN: &[u8] = b"security.xml-external-entity-review-policy.identity.v1\0";
77const XML_ENTITY_NAME: &str = "termivar_external";
78
79#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct XmlExternalEntityPolicyId([u8; 32]);
82
83impl XmlExternalEntityPolicyId {
84 pub fn to_wire(self) -> String {
86 format!("xml-external-entity-policy-sha256:{}", hex(self.0))
87 }
88
89 pub const fn as_bytes(self) -> [u8; 32] {
91 self.0
92 }
93}
94
95impl fmt::Debug for XmlExternalEntityPolicyId {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 formatter.write_str(&self.to_wire())
98 }
99}
100
101impl fmt::Display for XmlExternalEntityPolicyId {
102 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103 formatter.write_str(&self.to_wire())
104 }
105}
106
107#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
109#[serde(rename_all = "snake_case")]
110pub enum XmlExternalEntityExecutionMode {
111 Production,
113 OwnedNumericLoopbackTest,
115}
116
117impl XmlExternalEntityExecutionMode {
118 pub const fn as_str(self) -> &'static str {
120 match self {
121 Self::Production => "production",
122 Self::OwnedNumericLoopbackTest => "owned_numeric_loopback_test",
123 }
124 }
125}
126
127#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
129pub enum XmlExternalEntityHttpMethod {
130 #[serde(rename = "POST")]
132 Post,
133}
134
135impl XmlExternalEntityHttpMethod {
136 pub const fn as_str(self) -> &'static str {
138 XML_EXTERNAL_ENTITY_REVIEW_METHOD
139 }
140}
141
142#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
144pub enum XmlExternalEntityMediaType {
145 #[serde(rename = "application/xml; charset=utf-8")]
147 ApplicationXmlUtf8,
148}
149
150impl XmlExternalEntityMediaType {
151 pub const fn as_str(self) -> &'static str {
153 XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE
154 }
155}
156
157pub struct XmlExternalEntityReviewPolicy {
162 application: Url,
163 endpoint: Url,
164 provider_origin: PublicOrigin,
165 execution_mode: XmlExternalEntityExecutionMode,
166 polls_per_leg: u16,
167 poll_interval_ms: u64,
168 lifetime_ms: u64,
169 policy_id: XmlExternalEntityPolicyId,
170}
171
172impl XmlExternalEntityReviewPolicy {
173 pub fn parse_toml(
176 assessment_target: &Url,
177 source: &[u8],
178 ) -> Result<Self, XmlExternalEntityReviewPolicyError> {
179 if source.len() > MAX_XML_EXTERNAL_ENTITY_REVIEW_POLICY_BYTES {
180 return Err(XmlExternalEntityReviewPolicyError::PolicyTooLarge);
181 }
182 let source = std::str::from_utf8(source)
183 .map_err(|_| XmlExternalEntityReviewPolicyError::MalformedPolicy)?;
184 let wire: WirePolicy = toml::from_str(source)
185 .map_err(|_| XmlExternalEntityReviewPolicyError::MalformedPolicy)?;
186
187 if wire.schema != XML_EXTERNAL_ENTITY_REVIEW_POLICY_SCHEMA {
188 return Err(XmlExternalEntityReviewPolicyError::UnsupportedSchema);
189 }
190 if wire.method != XML_EXTERNAL_ENTITY_REVIEW_METHOD {
191 return Err(XmlExternalEntityReviewPolicyError::InvalidMethod);
192 }
193 if wire.media_type != XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE {
194 return Err(XmlExternalEntityReviewPolicyError::InvalidMediaType);
195 }
196 if !wire.acknowledge_xml_post
197 || !wire.acknowledge_external_interaction
198 || !wire.acknowledge_disposable_test_endpoint
199 {
200 return Err(XmlExternalEntityReviewPolicyError::AcknowledgementRequired);
201 }
202 validate_limits(wire.polls_per_leg, wire.poll_interval_ms, wire.lifetime_ms)?;
203
204 validate_production_url(assessment_target)
205 .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidAssessmentTarget)?;
206 let endpoint = parse_exact_policy_endpoint(&wire.endpoint)
207 .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidEndpoint)?;
208 validate_production_url(&endpoint)
209 .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidEndpoint)?;
210 if !same_origin(assessment_target, &endpoint) {
211 return Err(XmlExternalEntityReviewPolicyError::TargetOriginMismatch);
212 }
213 if !application_contains(assessment_target, &endpoint) {
214 return Err(XmlExternalEntityReviewPolicyError::EndpointOutsideApplication);
215 }
216
217 let provider_origin = PublicOrigin::from_str(&wire.provider_origin)
218 .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidProviderOrigin)?;
219 let provider_url = Url::parse(provider_origin.as_str())
220 .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidProviderOrigin)?;
221 if same_origin(&endpoint, &provider_url) {
222 return Err(XmlExternalEntityReviewPolicyError::ProviderOriginMatchesTarget);
223 }
224
225 let execution_mode = XmlExternalEntityExecutionMode::Production;
226 let policy_id = policy_identity(
227 assessment_target,
228 &endpoint,
229 &provider_url,
230 execution_mode,
231 wire.polls_per_leg,
232 wire.poll_interval_ms,
233 wire.lifetime_ms,
234 );
235 Ok(Self {
236 application: assessment_target.clone(),
237 endpoint,
238 provider_origin,
239 execution_mode,
240 polls_per_leg: wire.polls_per_leg,
241 poll_interval_ms: wire.poll_interval_ms,
242 lifetime_ms: wire.lifetime_ms,
243 policy_id,
244 })
245 }
246
247 #[cfg(test)]
250 pub(crate) fn for_owned_loopback(
251 assessment_target: Url,
252 endpoint: Url,
253 provider_origin: PublicOrigin,
254 polls_per_leg: u16,
255 poll_interval_ms: u64,
256 lifetime_ms: u64,
257 ) -> Result<Self, XmlExternalEntityReviewPolicyError> {
258 validate_limits(polls_per_leg, poll_interval_ms, lifetime_ms)?;
259 if !is_owned_http_loopback(&assessment_target) {
260 return Err(XmlExternalEntityReviewPolicyError::InvalidAssessmentTarget);
261 }
262 if !is_owned_http_loopback(&endpoint) {
263 return Err(XmlExternalEntityReviewPolicyError::InvalidEndpoint);
264 }
265 if !same_origin(&assessment_target, &endpoint) {
266 return Err(XmlExternalEntityReviewPolicyError::TargetOriginMismatch);
267 }
268 if !application_contains(&assessment_target, &endpoint) {
269 return Err(XmlExternalEntityReviewPolicyError::EndpointOutsideApplication);
270 }
271 let provider_url = Url::parse(provider_origin.as_str())
272 .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidProviderOrigin)?;
273 if !is_owned_http_loopback(&provider_url) {
274 return Err(XmlExternalEntityReviewPolicyError::InvalidProviderOrigin);
275 }
276 if same_origin(&endpoint, &provider_url) {
277 return Err(XmlExternalEntityReviewPolicyError::ProviderOriginMatchesTarget);
278 }
279
280 let execution_mode = XmlExternalEntityExecutionMode::OwnedNumericLoopbackTest;
281 let policy_id = policy_identity(
282 &assessment_target,
283 &endpoint,
284 &provider_url,
285 execution_mode,
286 polls_per_leg,
287 poll_interval_ms,
288 lifetime_ms,
289 );
290 Ok(Self {
291 application: assessment_target,
292 endpoint,
293 provider_origin,
294 execution_mode,
295 polls_per_leg,
296 poll_interval_ms,
297 lifetime_ms,
298 policy_id,
299 })
300 }
301
302 pub const fn policy_id(&self) -> XmlExternalEntityPolicyId {
304 self.policy_id
305 }
306
307 pub const fn policy_revision(&self) -> &'static str {
309 XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM
310 }
311
312 pub const fn execution_mode(&self) -> XmlExternalEntityExecutionMode {
314 self.execution_mode
315 }
316
317 pub const fn method(&self) -> XmlExternalEntityHttpMethod {
319 XmlExternalEntityHttpMethod::Post
320 }
321
322 pub const fn media_type(&self) -> XmlExternalEntityMediaType {
324 XmlExternalEntityMediaType::ApplicationXmlUtf8
325 }
326
327 pub const fn polls_per_leg(&self) -> u16 {
329 self.polls_per_leg
330 }
331
332 pub const fn poll_interval_ms(&self) -> u64 {
334 self.poll_interval_ms
335 }
336
337 pub const fn lifetime_ms(&self) -> u64 {
339 self.lifetime_ms
340 }
341
342 pub(crate) const fn endpoint(&self) -> &Url {
344 &self.endpoint
345 }
346
347 pub(crate) fn is_bound_to_application(&self, application: &Url) -> bool {
350 self.application == *application
351 }
352
353 pub(crate) const fn application(&self) -> &Url {
355 &self.application
356 }
357
358 pub(crate) const fn provider_origin(&self) -> &PublicOrigin {
360 &self.provider_origin
361 }
362}
363
364impl fmt::Debug for XmlExternalEntityReviewPolicy {
365 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
366 formatter
367 .debug_struct("XmlExternalEntityReviewPolicy")
368 .field("application", &"<redacted>")
369 .field("endpoint", &"<redacted>")
370 .field("provider_origin", &"<redacted>")
371 .field("execution_mode", &self.execution_mode)
372 .field("polls_per_leg", &self.polls_per_leg)
373 .field("poll_interval_ms", &self.poll_interval_ms)
374 .field("lifetime_ms", &self.lifetime_ms)
375 .field("policy_id", &self.policy_id)
376 .finish()
377 }
378}
379
380#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
382pub enum XmlExternalEntityReviewPolicyError {
383 #[error("XML external-entity review policy exceeds its compiled byte limit")]
385 PolicyTooLarge,
386 #[error("XML external-entity review policy is malformed")]
388 MalformedPolicy,
389 #[error("XML external-entity review policy schema is unsupported")]
391 UnsupportedSchema,
392 #[error("XML external-entity review method is invalid")]
394 InvalidMethod,
395 #[error("XML external-entity review media type is invalid")]
397 InvalidMediaType,
398 #[error("XML external-entity review requires all explicit acknowledgements")]
400 AcknowledgementRequired,
401 #[error("XML external-entity review scheduling limits are invalid")]
403 InvalidLimits,
404 #[error("XML external-entity review assessment target is invalid")]
406 InvalidAssessmentTarget,
407 #[error("XML external-entity review endpoint is invalid")]
409 InvalidEndpoint,
410 #[error("XML external-entity review endpoint does not match exact-origin authority")]
412 TargetOriginMismatch,
413 #[error("XML external-entity review endpoint is outside the selected application")]
415 EndpointOutsideApplication,
416 #[error("XML external-entity review provider origin is invalid")]
418 InvalidProviderOrigin,
419 #[error("XML external-entity review provider origin must differ from target origin")]
421 ProviderOriginMatchesTarget,
422}
423
424#[derive(Deserialize)]
425#[serde(deny_unknown_fields)]
426struct WirePolicy {
427 schema: String,
428 endpoint: String,
429 provider_origin: String,
430 method: String,
431 media_type: String,
432 acknowledge_xml_post: bool,
433 acknowledge_external_interaction: bool,
434 acknowledge_disposable_test_endpoint: bool,
435 polls_per_leg: u16,
436 poll_interval_ms: u64,
437 lifetime_ms: u64,
438}
439
440pub struct XmlExternalEntityAdminToken {
442 bytes: Vec<u8>,
443}
444
445impl XmlExternalEntityAdminToken {
446 pub fn new(mut bytes: Vec<u8>) -> Result<Self, XmlExternalEntityAdminTokenError> {
448 let valid = (MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES
449 ..=MAX_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES)
450 .contains(&bytes.len())
451 && bytes.iter().all(|byte| (0x21..=0x7e).contains(byte));
452 if !valid {
453 bytes.zeroize();
454 return Err(XmlExternalEntityAdminTokenError::Invalid);
455 }
456 Ok(Self { bytes })
457 }
458
459 pub(crate) fn into_bytes(mut self) -> Vec<u8> {
461 mem::take(&mut self.bytes)
462 }
463}
464
465impl fmt::Debug for XmlExternalEntityAdminToken {
466 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
467 formatter.write_str("XmlExternalEntityAdminToken(<redacted>)")
468 }
469}
470
471impl Drop for XmlExternalEntityAdminToken {
472 fn drop(&mut self) {
473 self.bytes.zeroize();
474 }
475}
476
477#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
479pub enum XmlExternalEntityAdminTokenError {
480 #[error("XML external-entity review administrator token is invalid")]
482 Invalid,
483}
484
485#[derive(Clone, Copy, Debug, Eq, PartialEq)]
487pub(crate) enum XmlExternalEntityDocumentRole {
488 Control,
490 Candidate,
492 Replay,
494}
495
496impl XmlExternalEntityDocumentRole {
497 const fn as_str(self) -> &'static str {
498 match self {
499 Self::Control => "control",
500 Self::Candidate => "candidate",
501 Self::Replay => "replay",
502 }
503 }
504
505 const fn references_entity(self) -> bool {
506 !matches!(self, Self::Control)
507 }
508}
509
510pub(crate) struct XmlExternalEntityDocumentPlan {
515 control: XmlExternalEntityDocument,
516 candidate: XmlExternalEntityDocument,
517 replay: XmlExternalEntityDocument,
518}
519
520impl XmlExternalEntityDocumentPlan {
521 pub(crate) fn new(
523 policy: &XmlExternalEntityReviewPolicy,
524 control_target: &CallbackTarget,
525 candidate_target: &CallbackTarget,
526 replay_target: &CallbackTarget,
527 ) -> Result<Self, XmlExternalEntityDocumentError> {
528 Self::from_callback_strings(
529 policy,
530 control_target.as_str(),
531 candidate_target.as_str(),
532 replay_target.as_str(),
533 )
534 }
535
536 fn from_callback_strings(
537 policy: &XmlExternalEntityReviewPolicy,
538 control_target: &str,
539 candidate_target: &str,
540 replay_target: &str,
541 ) -> Result<Self, XmlExternalEntityDocumentError> {
542 let control_target = validate_callback_target(policy, control_target)?;
543 let candidate_target = validate_callback_target(policy, candidate_target)?;
544 let replay_target = validate_callback_target(policy, replay_target)?;
545 if control_target == candidate_target
546 || control_target == replay_target
547 || candidate_target == replay_target
548 {
549 return Err(XmlExternalEntityDocumentError::CallbackIdentityConflict);
550 }
551
552 Ok(Self {
553 control: build_document(
554 policy,
555 XmlExternalEntityDocumentRole::Control,
556 control_target.as_str(),
557 )?,
558 candidate: build_document(
559 policy,
560 XmlExternalEntityDocumentRole::Candidate,
561 candidate_target.as_str(),
562 )?,
563 replay: build_document(
564 policy,
565 XmlExternalEntityDocumentRole::Replay,
566 replay_target.as_str(),
567 )?,
568 })
569 }
570
571 #[cfg(test)]
574 pub(crate) fn from_callback_strings_for_test(
575 policy: &XmlExternalEntityReviewPolicy,
576 control_target: &str,
577 candidate_target: &str,
578 replay_target: &str,
579 ) -> Result<Self, XmlExternalEntityDocumentError> {
580 Self::from_callback_strings(policy, control_target, candidate_target, replay_target)
581 }
582
583 pub(crate) const fn document(
585 &self,
586 role: XmlExternalEntityDocumentRole,
587 ) -> &XmlExternalEntityDocument {
588 match role {
589 XmlExternalEntityDocumentRole::Control => &self.control,
590 XmlExternalEntityDocumentRole::Candidate => &self.candidate,
591 XmlExternalEntityDocumentRole::Replay => &self.replay,
592 }
593 }
594}
595
596pub(crate) struct XmlExternalEntityDocument {
601 policy_id: XmlExternalEntityPolicyId,
602 role: XmlExternalEntityDocumentRole,
603 bytes: Zeroizing<Vec<u8>>,
604}
605
606impl XmlExternalEntityDocument {
607 pub(crate) const fn policy_id(&self) -> XmlExternalEntityPolicyId {
609 self.policy_id
610 }
611
612 pub(crate) const fn role(&self) -> XmlExternalEntityDocumentRole {
614 self.role
615 }
616
617 pub(crate) fn as_bytes(&self) -> &[u8] {
619 self.bytes.as_slice()
620 }
621}
622
623#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
625pub(crate) enum XmlExternalEntityDocumentError {
626 #[error("XML external-entity callback target is invalid")]
628 InvalidCallbackTarget,
629 #[error("XML external-entity callback targets must be distinct")]
631 CallbackIdentityConflict,
632 #[error("XML external-entity document exceeds its compiled byte limit")]
634 DocumentTooLarge,
635}
636
637#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
639#[serde(rename_all = "snake_case")]
640pub enum XmlExternalEntityReviewOutcome {
641 NotEligible,
643 ControlIncomplete,
645 PreflightContaminated,
647 ControlCallbackObserved,
649 NoCallback,
651 CandidateOnly,
653 ReplayOnly,
655 CorrelationMismatch,
657 CleanupIncomplete,
659 Cancelled,
661 BudgetExhausted,
663 Incomplete,
665 RepeatedExternalEntityResolutionObserved,
667}
668
669impl XmlExternalEntityReviewOutcome {
670 pub const fn as_str(self) -> &'static str {
672 match self {
673 Self::NotEligible => "not_eligible",
674 Self::ControlIncomplete => "control_incomplete",
675 Self::PreflightContaminated => "preflight_contaminated",
676 Self::ControlCallbackObserved => "control_callback_observed",
677 Self::NoCallback => "no_callback",
678 Self::CandidateOnly => "candidate_only",
679 Self::ReplayOnly => "replay_only",
680 Self::CorrelationMismatch => "correlation_mismatch",
681 Self::CleanupIncomplete => "cleanup_incomplete",
682 Self::Cancelled => "cancelled",
683 Self::BudgetExhausted => "budget_exhausted",
684 Self::Incomplete => "incomplete",
685 Self::RepeatedExternalEntityResolutionObserved => {
686 "repeated_external_entity_resolution_observed"
687 },
688 }
689 }
690}
691
692#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
694#[serde(rename_all = "snake_case")]
695pub enum XmlExternalEntityMaximumDisposition {
696 NeedsReview,
698}
699
700impl XmlExternalEntityMaximumDisposition {
701 pub const fn as_str(self) -> &'static str {
703 "needs_review"
704 }
705}
706
707#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
709#[serde(rename_all = "snake_case")]
710pub enum XmlExternalEntityOperationStatus {
711 NotPerformed,
713}
714
715impl XmlExternalEntityOperationStatus {
716 pub const fn as_str(self) -> &'static str {
718 "not_performed"
719 }
720}
721
722#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
724pub struct XmlExternalEntityClaimLimits {
725 maximum_disposition: XmlExternalEntityMaximumDisposition,
726 semantic_effect: XmlExternalEntityOperationStatus,
727 impact_validation: XmlExternalEntityOperationStatus,
728}
729
730impl XmlExternalEntityClaimLimits {
731 const fn v1() -> Self {
732 Self {
733 maximum_disposition: XmlExternalEntityMaximumDisposition::NeedsReview,
734 semantic_effect: XmlExternalEntityOperationStatus::NotPerformed,
735 impact_validation: XmlExternalEntityOperationStatus::NotPerformed,
736 }
737 }
738
739 pub const fn maximum_disposition(self) -> XmlExternalEntityMaximumDisposition {
741 self.maximum_disposition
742 }
743
744 pub const fn semantic_effect(self) -> XmlExternalEntityOperationStatus {
746 self.semantic_effect
747 }
748
749 pub const fn impact_validation(self) -> XmlExternalEntityOperationStatus {
751 self.impact_validation
752 }
753}
754
755pub(crate) struct XmlExternalEntityAuditFacts {
757 pub(crate) outcome: XmlExternalEntityReviewOutcome,
758 pub(crate) target_request_count: u8,
759 pub(crate) provider_request_count: u8,
760 pub(crate) active_verification_count: u8,
761 pub(crate) target_request_body_bytes: u64,
762 pub(crate) target_response_bytes: u64,
763 pub(crate) target_complete: bool,
764 pub(crate) preflight_clean: bool,
765 pub(crate) control_callback_observed: bool,
766 pub(crate) candidate_callback_observed: bool,
767 pub(crate) replay_callback_observed: bool,
768 pub(crate) callback_targets_distinct: bool,
769 pub(crate) event_identities_distinct: bool,
770 pub(crate) cleanup_verified: bool,
771 pub(crate) target_accounting_complete: bool,
772 pub(crate) provider_accounting_complete: bool,
773 pub(crate) item_projected: bool,
774}
775
776#[derive(Clone, Eq, PartialEq, Serialize)]
778pub struct XmlExternalEntityReviewAudit {
779 schema: &'static str,
780 algorithm: &'static str,
781 action_id: &'static str,
782 capability_id: &'static str,
783 outcome: XmlExternalEntityReviewOutcome,
784 policy_id: String,
785 execution_mode: XmlExternalEntityExecutionMode,
786 target_request_count: u8,
787 provider_request_count: u8,
788 active_verification_count: u8,
789 target_request_body_bytes: u64,
790 target_response_bytes: u64,
791 target_complete: bool,
792 preflight_clean: bool,
793 control_callback_observed: bool,
794 candidate_callback_observed: bool,
795 replay_callback_observed: bool,
796 callback_targets_distinct: bool,
797 event_identities_distinct: bool,
798 cleanup_verified: bool,
799 target_accounting_complete: bool,
800 provider_accounting_complete: bool,
801 item_projected: bool,
802 claim_limits: XmlExternalEntityClaimLimits,
803}
804
805impl XmlExternalEntityReviewAudit {
806 pub(crate) fn from_runtime(
809 policy: &XmlExternalEntityReviewPolicy,
810 facts: XmlExternalEntityAuditFacts,
811 ) -> Result<Self, XmlExternalEntityAuditError> {
812 validate_audit_facts(&facts)?;
813 Ok(Self {
814 schema: XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA,
815 algorithm: XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM,
816 action_id: XML_EXTERNAL_ENTITY_REVIEW_ACTION_ID,
817 capability_id: XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID,
818 outcome: facts.outcome,
819 policy_id: policy.policy_id().to_wire(),
820 execution_mode: policy.execution_mode(),
821 target_request_count: facts.target_request_count,
822 provider_request_count: facts.provider_request_count,
823 active_verification_count: facts.active_verification_count,
824 target_request_body_bytes: facts.target_request_body_bytes,
825 target_response_bytes: facts.target_response_bytes,
826 target_complete: facts.target_complete,
827 preflight_clean: facts.preflight_clean,
828 control_callback_observed: facts.control_callback_observed,
829 candidate_callback_observed: facts.candidate_callback_observed,
830 replay_callback_observed: facts.replay_callback_observed,
831 callback_targets_distinct: facts.callback_targets_distinct,
832 event_identities_distinct: facts.event_identities_distinct,
833 cleanup_verified: facts.cleanup_verified,
834 target_accounting_complete: facts.target_accounting_complete,
835 provider_accounting_complete: facts.provider_accounting_complete,
836 item_projected: facts.item_projected,
837 claim_limits: XmlExternalEntityClaimLimits::v1(),
838 })
839 }
840
841 pub const fn schema(&self) -> &'static str {
843 self.schema
844 }
845
846 pub const fn algorithm(&self) -> &'static str {
848 self.algorithm
849 }
850
851 pub const fn policy_revision(&self) -> &'static str {
853 self.algorithm
854 }
855
856 pub const fn action_id(&self) -> &'static str {
858 self.action_id
859 }
860
861 pub const fn capability_id(&self) -> &'static str {
863 self.capability_id
864 }
865
866 pub const fn method(&self) -> &'static str {
868 XML_EXTERNAL_ENTITY_REVIEW_METHOD
869 }
870
871 pub const fn media_type(&self) -> &'static str {
873 XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE
874 }
875
876 pub const fn control_model(&self) -> &'static str {
878 XML_EXTERNAL_ENTITY_REVIEW_CONTROL_MODEL
879 }
880
881 pub const fn outcome(&self) -> XmlExternalEntityReviewOutcome {
883 self.outcome
884 }
885
886 pub fn policy_id(&self) -> &str {
888 &self.policy_id
889 }
890
891 pub const fn execution_mode(&self) -> XmlExternalEntityExecutionMode {
893 self.execution_mode
894 }
895
896 pub const fn target_request_count(&self) -> u8 {
898 self.target_request_count
899 }
900
901 pub const fn provider_request_count(&self) -> u8 {
903 self.provider_request_count
904 }
905
906 pub const fn active_verification_count(&self) -> u8 {
908 self.active_verification_count
909 }
910
911 pub const fn target_request_body_bytes(&self) -> u64 {
913 self.target_request_body_bytes
914 }
915
916 pub const fn target_response_bytes(&self) -> u64 {
918 self.target_response_bytes
919 }
920
921 pub const fn target_complete(&self) -> bool {
923 self.target_complete
924 }
925
926 pub const fn preflight_clean(&self) -> bool {
928 self.preflight_clean
929 }
930
931 pub const fn control_callback_observed(&self) -> bool {
933 self.control_callback_observed
934 }
935
936 pub const fn candidate_callback_observed(&self) -> bool {
938 self.candidate_callback_observed
939 }
940
941 pub const fn replay_callback_observed(&self) -> bool {
943 self.replay_callback_observed
944 }
945
946 pub const fn callback_targets_distinct(&self) -> bool {
948 self.callback_targets_distinct
949 }
950
951 pub const fn event_identities_distinct(&self) -> bool {
953 self.event_identities_distinct
954 }
955
956 pub const fn distinct_event_count(&self) -> u8 {
958 if self.event_identities_distinct {
959 2
960 } else if self.candidate_callback_observed || self.replay_callback_observed {
961 1
962 } else {
963 0
964 }
965 }
966
967 pub const fn cleanup_verified(&self) -> bool {
969 self.cleanup_verified
970 }
971
972 pub const fn target_accounting_complete(&self) -> bool {
974 self.target_accounting_complete
975 }
976
977 pub const fn provider_accounting_complete(&self) -> bool {
979 self.provider_accounting_complete
980 }
981
982 pub const fn provider_complete(&self) -> bool {
984 self.cleanup_verified && self.provider_accounting_complete
985 }
986
987 pub const fn item_projected(&self) -> bool {
989 self.item_projected
990 }
991
992 pub const fn claim_limits(&self) -> XmlExternalEntityClaimLimits {
994 self.claim_limits
995 }
996
997 pub const fn semantic_effect(&self) -> XmlExternalEntityOperationStatus {
999 self.claim_limits.semantic_effect()
1000 }
1001
1002 pub const fn impact_validation(&self) -> XmlExternalEntityOperationStatus {
1004 self.claim_limits.impact_validation()
1005 }
1006}
1007
1008impl fmt::Debug for XmlExternalEntityReviewAudit {
1009 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1010 formatter
1011 .debug_struct("XmlExternalEntityReviewAudit")
1012 .field("schema", &self.schema)
1013 .field("algorithm", &self.algorithm)
1014 .field("action_id", &self.action_id)
1015 .field("capability_id", &self.capability_id)
1016 .field("outcome", &self.outcome)
1017 .field("policy_id", &self.policy_id)
1018 .field("execution_mode", &self.execution_mode)
1019 .field("target_request_count", &self.target_request_count)
1020 .field("provider_request_count", &self.provider_request_count)
1021 .field("active_verification_count", &self.active_verification_count)
1022 .field("target_request_body_bytes", &self.target_request_body_bytes)
1023 .field("target_response_bytes", &self.target_response_bytes)
1024 .field("target_complete", &self.target_complete)
1025 .field("preflight_clean", &self.preflight_clean)
1026 .field("control_callback_observed", &self.control_callback_observed)
1027 .field(
1028 "candidate_callback_observed",
1029 &self.candidate_callback_observed,
1030 )
1031 .field("replay_callback_observed", &self.replay_callback_observed)
1032 .field("callback_targets_distinct", &self.callback_targets_distinct)
1033 .field("event_identities_distinct", &self.event_identities_distinct)
1034 .field("cleanup_verified", &self.cleanup_verified)
1035 .field(
1036 "target_accounting_complete",
1037 &self.target_accounting_complete,
1038 )
1039 .field(
1040 "provider_accounting_complete",
1041 &self.provider_accounting_complete,
1042 )
1043 .field("item_projected", &self.item_projected)
1044 .field("claim_limits", &self.claim_limits)
1045 .finish()
1046 }
1047}
1048
1049#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
1051pub enum XmlExternalEntityAuditError {
1052 #[error("XML external-entity review audit counts are invalid")]
1054 InvalidCounts,
1055 #[error("XML external-entity review audit correlation facts are invalid")]
1057 InvalidCorrelation,
1058 #[error("XML external-entity review audit outcome is invalid")]
1060 InvalidOutcome,
1061 #[error("XML external-entity review audit projection is invalid")]
1063 InvalidProjection,
1064}
1065
1066fn validate_limits(
1067 polls_per_leg: u16,
1068 poll_interval_ms: u64,
1069 lifetime_ms: u64,
1070) -> Result<(), XmlExternalEntityReviewPolicyError> {
1071 let minimum_poll_window_ms = u64::from(polls_per_leg)
1072 .checked_mul(3)
1073 .and_then(|polls| polls.checked_mul(poll_interval_ms));
1074 if !(MIN_XML_EXTERNAL_ENTITY_POLLS_PER_LEG..=MAX_XML_EXTERNAL_ENTITY_POLLS_PER_LEG)
1075 .contains(&polls_per_leg)
1076 || !(MIN_XML_EXTERNAL_ENTITY_POLL_INTERVAL_MS..=MAX_XML_EXTERNAL_ENTITY_POLL_INTERVAL_MS)
1077 .contains(&poll_interval_ms)
1078 || !(MIN_XML_EXTERNAL_ENTITY_LIFETIME_MS..=MAX_XML_EXTERNAL_ENTITY_LIFETIME_MS)
1079 .contains(&lifetime_ms)
1080 || minimum_poll_window_ms.is_none_or(|minimum| lifetime_ms <= minimum)
1081 {
1082 return Err(XmlExternalEntityReviewPolicyError::InvalidLimits);
1083 }
1084 Ok(())
1085}
1086
1087fn validate_production_url(url: &Url) -> Result<(), ()> {
1088 if !valid_url_shape(url)
1089 || url.scheme() != "https"
1090 || !matches!(url.host(), Some(Host::Domain(host)) if valid_dns_host(host))
1091 {
1092 return Err(());
1093 }
1094 Ok(())
1095}
1096
1097fn parse_exact_policy_endpoint(value: &str) -> Result<Url, ()> {
1098 if value.is_empty()
1104 || value.len() > MAX_XML_EXTERNAL_ENTITY_URL_BYTES
1105 || value.trim() != value
1106 || !value.is_ascii()
1107 || value.bytes().any(|byte| byte.is_ascii_control())
1108 || value.contains('%')
1109 || value.contains('\\')
1110 {
1111 return Err(());
1112 }
1113 let parsed = Url::parse(value).map_err(|_| ())?;
1114 if parsed.as_str() != value {
1115 return Err(());
1116 }
1117 Ok(parsed)
1118}
1119
1120fn valid_url_shape(url: &Url) -> bool {
1121 url.as_str().len() <= MAX_XML_EXTERNAL_ENTITY_URL_BYTES
1122 && !url.cannot_be_a_base()
1123 && url.has_host()
1124 && url.port_or_known_default().is_some_and(|port| port != 0)
1125 && url.username().is_empty()
1126 && url.password().is_none()
1127 && url.query().is_none()
1128 && url.fragment().is_none()
1129 && safe_unambiguous_path(url.path())
1130}
1131
1132fn safe_unambiguous_path(path: &str) -> bool {
1133 !path.is_empty()
1134 && path.starts_with('/')
1135 && path
1136 .bytes()
1137 .all(|byte| (0x21..=0x7e).contains(&byte) && !matches!(byte, b'%' | b'\\'))
1138}
1139
1140fn valid_dns_host(host: &str) -> bool {
1141 !host.eq_ignore_ascii_case("localhost")
1142 && !host.ends_with(".localhost")
1143 && !host.is_empty()
1144 && host.len() <= 253
1145 && host.split('.').all(|label| {
1146 !label.is_empty()
1147 && label.len() <= 63
1148 && !label.starts_with('-')
1149 && !label.ends_with('-')
1150 && label
1151 .bytes()
1152 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1153 })
1154}
1155
1156fn application_contains(application: &Url, endpoint: &Url) -> bool {
1157 if !same_origin(application, endpoint) {
1158 return false;
1159 }
1160 let application_segments = nonempty_path_segments(application);
1161 let endpoint_segments = nonempty_path_segments(endpoint);
1162 application_segments.len() <= endpoint_segments.len()
1163 && application_segments
1164 .iter()
1165 .zip(endpoint_segments.iter())
1166 .all(|(application, endpoint)| application == endpoint)
1167}
1168
1169fn nonempty_path_segments(url: &Url) -> Vec<&str> {
1170 url.path_segments()
1171 .map(|segments| segments.filter(|segment| !segment.is_empty()).collect())
1172 .unwrap_or_default()
1173}
1174
1175fn same_origin(left: &Url, right: &Url) -> bool {
1176 left.scheme() == right.scheme()
1177 && left.host() == right.host()
1178 && left.port_or_known_default() == right.port_or_known_default()
1179}
1180
1181#[cfg(test)]
1182fn is_owned_http_loopback(url: &Url) -> bool {
1183 valid_url_shape(url)
1184 && url.scheme() == "http"
1185 && url.port().is_some_and(|port| port != 0)
1186 && match url.host() {
1187 Some(Host::Ipv4(address)) => address.is_loopback(),
1188 Some(Host::Ipv6(address)) => address.is_loopback(),
1189 _ => false,
1190 }
1191}
1192
1193fn policy_identity(
1194 application: &Url,
1195 endpoint: &Url,
1196 provider: &Url,
1197 execution_mode: XmlExternalEntityExecutionMode,
1198 polls_per_leg: u16,
1199 poll_interval_ms: u64,
1200 lifetime_ms: u64,
1201) -> XmlExternalEntityPolicyId {
1202 let polls = polls_per_leg.to_be_bytes();
1203 let interval = poll_interval_ms.to_be_bytes();
1204 let lifetime = lifetime_ms.to_be_bytes();
1205 XmlExternalEntityPolicyId(digest_parts(
1206 POLICY_ID_DOMAIN,
1207 &[
1208 XML_EXTERNAL_ENTITY_REVIEW_POLICY_SCHEMA.as_bytes(),
1209 XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM.as_bytes(),
1210 XML_EXTERNAL_ENTITY_REVIEW_METHOD.as_bytes(),
1211 XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE.as_bytes(),
1212 application.as_str().as_bytes(),
1213 endpoint.as_str().as_bytes(),
1214 provider.as_str().as_bytes(),
1215 execution_mode.as_str().as_bytes(),
1216 &polls,
1217 &interval,
1218 &lifetime,
1219 ],
1220 ))
1221}
1222
1223fn validate_callback_target(
1224 policy: &XmlExternalEntityReviewPolicy,
1225 source: &str,
1226) -> Result<Url, XmlExternalEntityDocumentError> {
1227 if source.is_empty() || source.len() > MAX_XML_EXTERNAL_ENTITY_URL_BYTES {
1228 return Err(XmlExternalEntityDocumentError::InvalidCallbackTarget);
1229 }
1230 let parsed =
1231 Url::parse(source).map_err(|_| XmlExternalEntityDocumentError::InvalidCallbackTarget)?;
1232 let provider = Url::parse(policy.provider_origin().as_str())
1233 .map_err(|_| XmlExternalEntityDocumentError::InvalidCallbackTarget)?;
1234 let environment_valid = match policy.execution_mode() {
1235 XmlExternalEntityExecutionMode::Production => {
1236 parsed.scheme() == "https" && validate_production_url(&parsed).is_ok()
1237 },
1238 XmlExternalEntityExecutionMode::OwnedNumericLoopbackTest => {
1239 #[cfg(test)]
1240 {
1241 is_owned_http_loopback(&parsed)
1242 }
1243 #[cfg(not(test))]
1244 {
1245 false
1246 }
1247 },
1248 };
1249 if !environment_valid
1250 || !same_origin(&parsed, &provider)
1251 || !matches!(
1252 NativeOastRoute::from_str(parsed.path()),
1253 Ok(NativeOastRoute::Callback { .. })
1254 )
1255 {
1256 return Err(XmlExternalEntityDocumentError::InvalidCallbackTarget);
1257 }
1258 Ok(parsed)
1259}
1260
1261fn build_document(
1262 policy: &XmlExternalEntityReviewPolicy,
1263 role: XmlExternalEntityDocumentRole,
1264 callback_target: &str,
1265) -> Result<XmlExternalEntityDocument, XmlExternalEntityDocumentError> {
1266 let callback_target = escape_xml_attribute(callback_target);
1267 let content = if role.references_entity() {
1268 format!("&{XML_ENTITY_NAME};")
1269 } else {
1270 "control".to_owned()
1271 };
1272 let body = format!(
1273 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
1274<!DOCTYPE termivar-review [<!ENTITY {XML_ENTITY_NAME} SYSTEM \"{callback_target}\">]>\n\
1275<termivar-review role=\"{}\">{content}</termivar-review>\n",
1276 role.as_str()
1277 )
1278 .into_bytes();
1279 if body.len() > MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES {
1280 return Err(XmlExternalEntityDocumentError::DocumentTooLarge);
1281 }
1282 Ok(XmlExternalEntityDocument {
1283 policy_id: policy.policy_id(),
1284 role,
1285 bytes: Zeroizing::new(body),
1286 })
1287}
1288
1289fn escape_xml_attribute(source: &str) -> String {
1290 let mut escaped = String::with_capacity(source.len());
1291 for character in source.chars() {
1292 match character {
1293 '&' => escaped.push_str("&"),
1294 '<' => escaped.push_str("<"),
1295 '>' => escaped.push_str(">"),
1296 '"' => escaped.push_str("""),
1297 '\'' => escaped.push_str("'"),
1298 character => escaped.push(character),
1299 }
1300 }
1301 escaped
1302}
1303
1304fn validate_audit_facts(
1305 facts: &XmlExternalEntityAuditFacts,
1306) -> Result<(), XmlExternalEntityAuditError> {
1307 let maximum_target_body_bytes = u64::try_from(XML_EXTERNAL_ENTITY_TARGET_REQUESTS)
1308 .ok()
1309 .and_then(|requests| {
1310 u64::try_from(MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES)
1311 .ok()
1312 .and_then(|bytes| requests.checked_mul(bytes))
1313 });
1314 let maximum_complete_target_response_bytes = u64::try_from(XML_EXTERNAL_ENTITY_TARGET_REQUESTS)
1315 .ok()
1316 .and_then(|requests| requests.checked_mul(MAX_XML_EXTERNAL_ENTITY_TARGET_RESPONSE_BYTES));
1317 if usize::from(facts.target_request_count) > XML_EXTERNAL_ENTITY_TARGET_REQUESTS
1318 || usize::from(facts.provider_request_count) > MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS
1319 || usize::from(facts.active_verification_count) > XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS
1320 || maximum_target_body_bytes.is_none_or(|maximum| facts.target_request_body_bytes > maximum)
1321 || (facts.target_complete
1322 && maximum_complete_target_response_bytes
1323 .is_none_or(|maximum| facts.target_response_bytes > maximum))
1324 {
1325 return Err(XmlExternalEntityAuditError::InvalidCounts);
1326 }
1327 let expected_active_verification_count = u8::from(facts.target_request_count >= 2);
1328 let target_bytes_consistent =
1329 (facts.target_request_count == 0) == (facts.target_request_body_bytes == 0);
1330 let target_completion_consistent = !facts.target_complete
1331 || usize::from(facts.target_request_count) == XML_EXTERNAL_ENTITY_TARGET_REQUESTS;
1332 let target_accounting_consistent = !facts.target_accounting_complete || facts.target_complete;
1333 let callback_facts_consistent = (!facts.control_callback_observed
1334 && !facts.candidate_callback_observed
1335 && !facts.replay_callback_observed)
1336 || facts.callback_targets_distinct;
1337 let event_facts_consistent = !facts.event_identities_distinct
1338 || (facts.callback_targets_distinct
1339 && facts.candidate_callback_observed
1340 && facts.replay_callback_observed);
1341 let provider_empty_consistent = facts.provider_request_count != 0
1342 || (facts.active_verification_count == 0
1343 && !facts.preflight_clean
1344 && !facts.control_callback_observed
1345 && !facts.candidate_callback_observed
1346 && !facts.replay_callback_observed
1347 && !facts.callback_targets_distinct
1348 && !facts.event_identities_distinct
1349 && !facts.cleanup_verified
1350 && !facts.provider_accounting_complete);
1351 let provider_completion_consistent = !facts.provider_accounting_complete
1352 || (usize::from(facts.provider_request_count) >= 9
1353 && facts.preflight_clean
1354 && facts.callback_targets_distinct);
1355 let target_started_consistent = facts.target_request_count == 0
1356 || (facts.provider_request_count > 0
1357 && facts.preflight_clean
1358 && facts.callback_targets_distinct);
1359 if facts.active_verification_count != expected_active_verification_count
1360 || !target_bytes_consistent
1361 || !target_completion_consistent
1362 || !target_accounting_consistent
1363 || !callback_facts_consistent
1364 || !event_facts_consistent
1365 || !provider_empty_consistent
1366 || !provider_completion_consistent
1367 || !target_started_consistent
1368 {
1369 return Err(XmlExternalEntityAuditError::InvalidCorrelation);
1370 }
1371
1372 let complete_lifecycle = usize::from(facts.target_request_count)
1373 == XML_EXTERNAL_ENTITY_TARGET_REQUESTS
1374 && facts.provider_request_count > 0
1375 && usize::from(facts.active_verification_count) == XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS
1376 && facts.target_request_body_bytes > 0
1377 && facts.target_complete
1378 && facts.preflight_clean
1379 && !facts.control_callback_observed
1380 && facts.callback_targets_distinct
1381 && facts.cleanup_verified
1382 && facts.target_accounting_complete
1383 && facts.provider_accounting_complete;
1384 let complete_positive = complete_lifecycle
1385 && facts.candidate_callback_observed
1386 && facts.replay_callback_observed
1387 && facts.event_identities_distinct;
1388 let positive_outcome =
1389 facts.outcome == XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved;
1390 if positive_outcome != complete_positive {
1391 return Err(XmlExternalEntityAuditError::InvalidOutcome);
1392 }
1393
1394 let no_callbacks = !facts.control_callback_observed
1395 && !facts.candidate_callback_observed
1396 && !facts.replay_callback_observed;
1397 let empty_lifecycle = facts.target_request_count == 0
1398 && facts.provider_request_count == 0
1399 && no_callbacks
1400 && !facts.target_complete
1401 && !facts.preflight_clean
1402 && !facts.callback_targets_distinct
1403 && !facts.event_identities_distinct
1404 && !facts.cleanup_verified
1405 && !facts.target_accounting_complete
1406 && !facts.provider_accounting_complete;
1407 let stopped_lifecycle = !complete_lifecycle
1408 && !facts.control_callback_observed
1409 && (!facts.target_accounting_complete || !facts.provider_accounting_complete);
1410 let outcome_facts_match = match facts.outcome {
1411 XmlExternalEntityReviewOutcome::NotEligible => empty_lifecycle,
1412 XmlExternalEntityReviewOutcome::ControlIncomplete => {
1413 facts.target_request_count == 0
1414 && !facts.target_complete
1415 && !facts.preflight_clean
1416 && no_callbacks
1417 && !facts.target_accounting_complete
1418 && !facts.provider_accounting_complete
1419 },
1420 XmlExternalEntityReviewOutcome::PreflightContaminated => {
1421 facts.target_request_count == 0
1422 && facts.provider_request_count > 0
1423 && !facts.preflight_clean
1424 && !no_callbacks
1425 && facts.callback_targets_distinct
1426 && !facts.target_complete
1427 && !facts.target_accounting_complete
1428 && !facts.provider_accounting_complete
1429 },
1430 XmlExternalEntityReviewOutcome::ControlCallbackObserved => {
1431 facts.preflight_clean
1432 && facts.control_callback_observed
1433 && facts.target_request_count > 0
1434 && !facts.target_complete
1435 && !facts.target_accounting_complete
1436 && !facts.provider_accounting_complete
1437 },
1438 XmlExternalEntityReviewOutcome::NoCallback => complete_lifecycle && no_callbacks,
1439 XmlExternalEntityReviewOutcome::CandidateOnly => {
1440 complete_lifecycle
1441 && facts.candidate_callback_observed
1442 && !facts.replay_callback_observed
1443 && !facts.event_identities_distinct
1444 },
1445 XmlExternalEntityReviewOutcome::ReplayOnly => {
1446 complete_lifecycle
1447 && !facts.candidate_callback_observed
1448 && facts.replay_callback_observed
1449 && !facts.event_identities_distinct
1450 },
1451 XmlExternalEntityReviewOutcome::CorrelationMismatch => {
1452 facts.preflight_clean
1453 && !facts.control_callback_observed
1454 && (facts.candidate_callback_observed || facts.replay_callback_observed)
1455 && !facts.event_identities_distinct
1456 },
1457 XmlExternalEntityReviewOutcome::CleanupIncomplete => {
1458 facts.preflight_clean && !facts.control_callback_observed && !facts.cleanup_verified
1459 },
1460 XmlExternalEntityReviewOutcome::Cancelled
1461 | XmlExternalEntityReviewOutcome::BudgetExhausted
1462 | XmlExternalEntityReviewOutcome::Incomplete => stopped_lifecycle,
1463 XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved => {
1464 complete_positive
1465 },
1466 };
1467 if !outcome_facts_match {
1468 return Err(XmlExternalEntityAuditError::InvalidOutcome);
1469 }
1470 if facts.item_projected != positive_outcome {
1471 return Err(XmlExternalEntityAuditError::InvalidProjection);
1472 }
1473 Ok(())
1474}
1475
1476fn digest_parts(domain: &[u8], parts: &[&[u8]]) -> [u8; 32] {
1477 let mut hasher = Sha256::new();
1478 hasher.update(domain);
1479 for part in parts {
1480 hasher.update((part.len() as u64).to_be_bytes());
1481 hasher.update(part);
1482 }
1483 hasher.finalize().into()
1484}
1485
1486fn hex(bytes: [u8; 32]) -> String {
1487 const DIGITS: &[u8; 16] = b"0123456789abcdef";
1488 let mut output = String::with_capacity(64);
1489 for byte in bytes {
1490 output.push(DIGITS[(byte >> 4) as usize] as char);
1491 output.push(DIGITS[(byte & 0x0f) as usize] as char);
1492 }
1493 output
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1499
1500 use super::*;
1501
1502 const SESSION: &str = "AQEBAQEBAQEBAQEBAQEBAQ";
1503 const CONTROL_CALLBACK: &str = "AgICAgICAgICAgICAgICAg";
1504 const CANDIDATE_CALLBACK: &str = "AwMDAwMDAwMDAwMDAwMDAw";
1505 const REPLAY_CALLBACK: &str = "BAQEBAQEBAQEBAQEBAQEBA";
1506
1507 fn policy_source(endpoint: &str, provider: &str) -> Vec<u8> {
1508 format!(
1509 "schema = \"{XML_EXTERNAL_ENTITY_REVIEW_POLICY_SCHEMA}\"\n\
1510endpoint = \"{endpoint}\"\n\
1511provider_origin = \"{provider}\"\n\
1512method = \"{XML_EXTERNAL_ENTITY_REVIEW_METHOD}\"\n\
1513media_type = \"{XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE}\"\n\
1514acknowledge_xml_post = true\n\
1515acknowledge_external_interaction = true\n\
1516acknowledge_disposable_test_endpoint = true\n\
1517polls_per_leg = 2\n\
1518poll_interval_ms = 500\n\
1519lifetime_ms = 10000\n"
1520 )
1521 .into_bytes()
1522 }
1523
1524 fn production_policy() -> XmlExternalEntityReviewPolicy {
1525 XmlExternalEntityReviewPolicy::parse_toml(
1526 &Url::parse("https://app.example.test/application/").unwrap(),
1527 &policy_source(
1528 "https://app.example.test/application/fixtures/xml",
1529 "https://oast.example.test/",
1530 ),
1531 )
1532 .unwrap()
1533 }
1534
1535 fn loopback_policy() -> XmlExternalEntityReviewPolicy {
1536 let provider_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 39091);
1537 XmlExternalEntityReviewPolicy::for_owned_loopback(
1538 Url::parse("http://127.0.0.1:39090/application/").unwrap(),
1539 Url::parse("http://127.0.0.1:39090/application/fixtures/xml").unwrap(),
1540 PublicOrigin::from_test_loopback(provider_address).unwrap(),
1541 2,
1542 500,
1543 10_000,
1544 )
1545 .unwrap()
1546 }
1547
1548 fn callback(callback: &str) -> String {
1549 format!("http://127.0.0.1:39091/c/{SESSION}/{callback}")
1550 }
1551
1552 #[test]
1553 fn wire_vocabulary_and_derived_callback_counts_are_total() {
1554 let policy = production_policy();
1555 assert_eq!(
1556 format!("{}", policy.policy_id()),
1557 policy.policy_id().to_wire()
1558 );
1559 assert_eq!(
1560 policy.policy_revision(),
1561 XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM
1562 );
1563 assert_eq!(policy.method().as_str(), XML_EXTERNAL_ENTITY_REVIEW_METHOD);
1564 assert_eq!(
1565 policy.media_type().as_str(),
1566 XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE
1567 );
1568 assert_eq!(
1569 XmlExternalEntityExecutionMode::Production.as_str(),
1570 "production"
1571 );
1572 assert_eq!(
1573 XmlExternalEntityExecutionMode::OwnedNumericLoopbackTest.as_str(),
1574 "owned_numeric_loopback_test"
1575 );
1576
1577 let outcomes = [
1578 (XmlExternalEntityReviewOutcome::NotEligible, "not_eligible"),
1579 (
1580 XmlExternalEntityReviewOutcome::ControlIncomplete,
1581 "control_incomplete",
1582 ),
1583 (
1584 XmlExternalEntityReviewOutcome::PreflightContaminated,
1585 "preflight_contaminated",
1586 ),
1587 (
1588 XmlExternalEntityReviewOutcome::ControlCallbackObserved,
1589 "control_callback_observed",
1590 ),
1591 (XmlExternalEntityReviewOutcome::NoCallback, "no_callback"),
1592 (
1593 XmlExternalEntityReviewOutcome::CandidateOnly,
1594 "candidate_only",
1595 ),
1596 (XmlExternalEntityReviewOutcome::ReplayOnly, "replay_only"),
1597 (
1598 XmlExternalEntityReviewOutcome::CorrelationMismatch,
1599 "correlation_mismatch",
1600 ),
1601 (
1602 XmlExternalEntityReviewOutcome::CleanupIncomplete,
1603 "cleanup_incomplete",
1604 ),
1605 (XmlExternalEntityReviewOutcome::Cancelled, "cancelled"),
1606 (
1607 XmlExternalEntityReviewOutcome::BudgetExhausted,
1608 "budget_exhausted",
1609 ),
1610 (XmlExternalEntityReviewOutcome::Incomplete, "incomplete"),
1611 (
1612 XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved,
1613 "repeated_external_entity_resolution_observed",
1614 ),
1615 ];
1616 for (outcome, expected) in outcomes {
1617 assert_eq!(outcome.as_str(), expected);
1618 }
1619 assert_eq!(
1620 XmlExternalEntityMaximumDisposition::NeedsReview.as_str(),
1621 "needs_review"
1622 );
1623 assert_eq!(
1624 XmlExternalEntityOperationStatus::NotPerformed.as_str(),
1625 "not_performed"
1626 );
1627
1628 let positive =
1629 XmlExternalEntityReviewAudit::from_runtime(&policy, positive_facts()).unwrap();
1630 assert_eq!(
1631 positive.policy_revision(),
1632 XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM
1633 );
1634 assert_eq!(
1635 positive.execution_mode(),
1636 XmlExternalEntityExecutionMode::Production
1637 );
1638 assert_eq!(positive.distinct_event_count(), 2);
1639 assert!(positive.provider_complete());
1640
1641 let mut candidate_only = positive_facts();
1642 candidate_only.outcome = XmlExternalEntityReviewOutcome::CandidateOnly;
1643 candidate_only.replay_callback_observed = false;
1644 candidate_only.event_identities_distinct = false;
1645 candidate_only.item_projected = false;
1646 let candidate_only =
1647 XmlExternalEntityReviewAudit::from_runtime(&policy, candidate_only).unwrap();
1648 assert_eq!(candidate_only.distinct_event_count(), 1);
1649
1650 let empty = XmlExternalEntityReviewAudit::from_runtime(
1651 &policy,
1652 XmlExternalEntityAuditFacts {
1653 outcome: XmlExternalEntityReviewOutcome::NotEligible,
1654 target_request_count: 0,
1655 provider_request_count: 0,
1656 active_verification_count: 0,
1657 target_request_body_bytes: 0,
1658 target_response_bytes: 0,
1659 target_complete: false,
1660 preflight_clean: false,
1661 control_callback_observed: false,
1662 candidate_callback_observed: false,
1663 replay_callback_observed: false,
1664 callback_targets_distinct: false,
1665 event_identities_distinct: false,
1666 cleanup_verified: false,
1667 target_accounting_complete: false,
1668 provider_accounting_complete: false,
1669 item_projected: false,
1670 },
1671 )
1672 .unwrap();
1673 assert_eq!(empty.distinct_event_count(), 0);
1674 assert!(!empty.provider_complete());
1675 }
1676
1677 fn positive_facts() -> XmlExternalEntityAuditFacts {
1678 XmlExternalEntityAuditFacts {
1679 outcome: XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved,
1680 target_request_count: XML_EXTERNAL_ENTITY_TARGET_REQUESTS as u8,
1681 provider_request_count: MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS as u8,
1682 active_verification_count: XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS as u8,
1683 target_request_body_bytes: 768,
1684 target_response_bytes: 96,
1685 target_complete: true,
1686 preflight_clean: true,
1687 control_callback_observed: false,
1688 candidate_callback_observed: true,
1689 replay_callback_observed: true,
1690 callback_targets_distinct: true,
1691 event_identities_distinct: true,
1692 cleanup_verified: true,
1693 target_accounting_complete: true,
1694 provider_accounting_complete: true,
1695 item_projected: true,
1696 }
1697 }
1698
1699 #[test]
1700 fn strict_policy_binds_exact_post_media_origin_and_application() {
1701 let policy = production_policy();
1702 let selected_application = Url::parse("https://app.example.test/application/").unwrap();
1703 let broader_application = Url::parse("https://app.example.test/").unwrap();
1704 assert!(policy.is_bound_to_application(&selected_application));
1705 assert!(!policy.is_bound_to_application(&broader_application));
1706 let broader_policy = XmlExternalEntityReviewPolicy::parse_toml(
1707 &broader_application,
1708 &policy_source(
1709 "https://app.example.test/application/fixtures/xml",
1710 "https://oast.example.test/",
1711 ),
1712 )
1713 .unwrap();
1714 assert_ne!(policy.policy_id(), broader_policy.policy_id());
1715 assert_eq!(policy.method(), XmlExternalEntityHttpMethod::Post);
1716 assert_eq!(
1717 policy.media_type(),
1718 XmlExternalEntityMediaType::ApplicationXmlUtf8
1719 );
1720 assert_eq!(
1721 policy.execution_mode(),
1722 XmlExternalEntityExecutionMode::Production
1723 );
1724 assert_eq!(policy.polls_per_leg(), 2);
1725 assert_eq!(policy.poll_interval_ms(), 500);
1726 assert_eq!(policy.lifetime_ms(), 10_000);
1727 assert!(policy
1728 .policy_id()
1729 .to_wire()
1730 .starts_with("xml-external-entity-policy-sha256:"));
1731 let rendered = format!("{policy:?}");
1732 assert!(!rendered.contains("app.example.test"));
1733 assert!(!rendered.contains("oast.example.test"));
1734 assert!(!rendered.contains("/fixtures/xml"));
1735 }
1736
1737 #[test]
1738 fn policy_rejects_authority_broadening_mutations() {
1739 let assessment = Url::parse("https://app.example.test/application/").unwrap();
1740 let base = String::from_utf8(policy_source(
1741 "https://app.example.test/application/fixtures/xml",
1742 "https://oast.example.test/",
1743 ))
1744 .unwrap();
1745 let mutations = [
1746 base.replace(
1747 XML_EXTERNAL_ENTITY_REVIEW_POLICY_SCHEMA,
1748 "security.xml-external-entity-review-policy/v2",
1749 ),
1750 base.replace("method = \"POST\"", "method = \"PUT\""),
1751 base.replace(
1752 "media_type = \"application/xml; charset=utf-8\"",
1753 "media_type = \"text/xml\"",
1754 ),
1755 base.replace(
1756 "acknowledge_xml_post = true",
1757 "acknowledge_xml_post = false",
1758 ),
1759 base.replace(
1760 "acknowledge_external_interaction = true",
1761 "acknowledge_external_interaction = false",
1762 ),
1763 base.replace(
1764 "acknowledge_disposable_test_endpoint = true",
1765 "acknowledge_disposable_test_endpoint = false",
1766 ),
1767 base.replace(
1768 "https://app.example.test/application/fixtures/xml",
1769 "https://other.example.test/application/fixtures/xml",
1770 ),
1771 base.replace(
1772 "https://app.example.test/application/fixtures/xml",
1773 "https://app.example.test/sibling/fixtures/xml",
1774 ),
1775 base.replace(
1776 "https://app.example.test/application/fixtures/xml",
1777 "https://app.example.test/application/fixtures/xml?mode=test",
1778 ),
1779 base.replace(
1780 "https://app.example.test/application/fixtures/xml",
1781 "https://user@app.example.test/application/fixtures/xml",
1782 ),
1783 base.replace(
1784 "https://app.example.test/application/fixtures/xml",
1785 "http://app.example.test/application/fixtures/xml",
1786 ),
1787 base.replace(
1788 "https://app.example.test/application/fixtures/xml",
1789 "https://127.0.0.1/application/fixtures/xml",
1790 ),
1791 format!("{base}unknown = true\n"),
1792 ];
1793 for mutation in mutations {
1794 assert!(
1795 XmlExternalEntityReviewPolicy::parse_toml(&assessment, mutation.as_bytes())
1796 .is_err(),
1797 "authority-broadening mutation unexpectedly passed"
1798 );
1799 }
1800
1801 let same_provider = base.replace("https://oast.example.test/", "https://app.example.test/");
1802 assert_eq!(
1803 XmlExternalEntityReviewPolicy::parse_toml(&assessment, same_provider.as_bytes())
1804 .unwrap_err(),
1805 XmlExternalEntityReviewPolicyError::ProviderOriginMatchesTarget
1806 );
1807 }
1808
1809 #[test]
1810 fn policy_endpoint_rejects_raw_normalization_and_unsafe_separators() {
1811 let assessment = Url::parse("https://app.example.test/application/").unwrap();
1812 let canonical = "https://app.example.test/application/fixtures/xml";
1813 assert!(XmlExternalEntityReviewPolicy::parse_toml(
1814 &assessment,
1815 &policy_source(canonical, "https://oast.example.test/")
1816 )
1817 .is_ok());
1818
1819 for ambiguous in [
1820 "https://app.example.test/application/fixtures/./xml",
1821 "https://app.example.test/application/fixtures/safe/../xml",
1822 "https://app.example.test/application/fixtures/safe/%2e%2e/xml",
1823 "https://app.example.test/application/fixtures/%2f/xml",
1824 "HTTPS://APP.EXAMPLE.TEST:443/application/fixtures/xml",
1825 ] {
1826 assert_eq!(
1827 XmlExternalEntityReviewPolicy::parse_toml(
1828 &assessment,
1829 &policy_source(ambiguous, "https://oast.example.test/")
1830 )
1831 .unwrap_err(),
1832 XmlExternalEntityReviewPolicyError::InvalidEndpoint,
1833 "normalized endpoint spelling was admitted: {ambiguous}"
1834 );
1835 }
1836
1837 let backslash = String::from_utf8(policy_source(canonical, "https://oast.example.test/"))
1838 .unwrap()
1839 .replace(
1840 &format!("endpoint = \"{canonical}\""),
1841 r"endpoint = 'https://app.example.test/application/fixtures\xml'",
1842 );
1843 assert_eq!(
1844 XmlExternalEntityReviewPolicy::parse_toml(&assessment, backslash.as_bytes())
1845 .unwrap_err(),
1846 XmlExternalEntityReviewPolicyError::InvalidEndpoint
1847 );
1848 }
1849
1850 #[test]
1851 fn policy_limits_and_source_ceiling_fail_closed() {
1852 let assessment = Url::parse("https://app.example.test/application/").unwrap();
1853 let base = String::from_utf8(policy_source(
1854 "https://app.example.test/application/fixtures/xml",
1855 "https://oast.example.test/",
1856 ))
1857 .unwrap();
1858 for mutation in [
1859 base.replace("polls_per_leg = 2", "polls_per_leg = 0"),
1860 base.replace("polls_per_leg = 2", "polls_per_leg = 3"),
1861 base.replace("poll_interval_ms = 500", "poll_interval_ms = 249"),
1862 base.replace("poll_interval_ms = 500", "poll_interval_ms = 2001"),
1863 base.replace("lifetime_ms = 10000", "lifetime_ms = 4999"),
1864 base.replace("lifetime_ms = 10000", "lifetime_ms = 30001"),
1865 base.replace("poll_interval_ms = 500", "poll_interval_ms = 2000"),
1866 ] {
1867 assert_eq!(
1868 XmlExternalEntityReviewPolicy::parse_toml(&assessment, mutation.as_bytes())
1869 .unwrap_err(),
1870 XmlExternalEntityReviewPolicyError::InvalidLimits
1871 );
1872 }
1873 assert_eq!(
1874 XmlExternalEntityReviewPolicy::parse_toml(
1875 &assessment,
1876 &vec![b'a'; MAX_XML_EXTERNAL_ENTITY_REVIEW_POLICY_BYTES + 1]
1877 )
1878 .unwrap_err(),
1879 XmlExternalEntityReviewPolicyError::PolicyTooLarge
1880 );
1881 }
1882
1883 #[test]
1884 fn numeric_loopback_requires_the_owned_test_constructor() {
1885 let policy = loopback_policy();
1886 assert_eq!(
1887 policy.execution_mode(),
1888 XmlExternalEntityExecutionMode::OwnedNumericLoopbackTest
1889 );
1890 assert_eq!(
1891 XmlExternalEntityReviewPolicy::parse_toml(
1892 &Url::parse("http://127.0.0.1:39090/application/").unwrap(),
1893 &policy_source(
1894 "http://127.0.0.1:39090/application/fixtures/xml",
1895 "http://127.0.0.1:39091/"
1896 )
1897 )
1898 .unwrap_err(),
1899 XmlExternalEntityReviewPolicyError::InvalidAssessmentTarget
1900 );
1901 }
1902
1903 #[test]
1904 fn administrator_token_is_bounded_move_only_and_redacted() {
1905 let token =
1906 XmlExternalEntityAdminToken::new(vec![b'a'; MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES])
1907 .unwrap();
1908 assert_eq!(
1909 format!("{token:?}"),
1910 "XmlExternalEntityAdminToken(<redacted>)"
1911 );
1912 assert_eq!(
1913 token.into_bytes(),
1914 vec![b'a'; MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES]
1915 );
1916 for invalid in [
1917 vec![],
1918 vec![b'a'; MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES - 1],
1919 vec![b'a'; MAX_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES + 1],
1920 vec![b' '; MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES],
1921 ] {
1922 assert_eq!(
1923 XmlExternalEntityAdminToken::new(invalid).unwrap_err(),
1924 XmlExternalEntityAdminTokenError::Invalid
1925 );
1926 }
1927 }
1928
1929 #[test]
1930 fn canonical_plan_has_one_external_general_entity_and_distinct_roles() {
1931 let policy = loopback_policy();
1932 let plan = XmlExternalEntityDocumentPlan::from_callback_strings(
1933 &policy,
1934 &callback(CONTROL_CALLBACK),
1935 &callback(CANDIDATE_CALLBACK),
1936 &callback(REPLAY_CALLBACK),
1937 )
1938 .unwrap();
1939 let control = std::str::from_utf8(
1940 plan.document(XmlExternalEntityDocumentRole::Control)
1941 .as_bytes(),
1942 )
1943 .unwrap();
1944 let candidate = std::str::from_utf8(
1945 plan.document(XmlExternalEntityDocumentRole::Candidate)
1946 .as_bytes(),
1947 )
1948 .unwrap();
1949 let replay = std::str::from_utf8(
1950 plan.document(XmlExternalEntityDocumentRole::Replay)
1951 .as_bytes(),
1952 )
1953 .unwrap();
1954
1955 for body in [control, candidate, replay] {
1956 assert_eq!(body.matches("<!ENTITY ").count(), 1);
1957 assert!(body.contains("<!ENTITY termivar_external SYSTEM \""));
1958 assert!(!body.contains("<!ENTITY %"));
1959 assert!(!body.contains(" PUBLIC "));
1960 assert!(!body.contains("XInclude"));
1961 assert!(body.len() <= MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES);
1962 }
1963 assert!(control.contains("role=\"control\""));
1964 assert!(!control.contains("&termivar_external;"));
1965 assert!(candidate.contains("role=\"candidate\""));
1966 assert_eq!(candidate.matches("&termivar_external;").count(), 1);
1967 assert!(replay.contains("role=\"replay\""));
1968 assert_eq!(replay.matches("&termivar_external;").count(), 1);
1969 assert_ne!(control, candidate);
1970 assert_ne!(candidate, replay);
1971 }
1972
1973 #[test]
1974 fn callback_targets_are_exact_native_routes_and_pairwise_distinct() {
1975 let policy = loopback_policy();
1976 assert_eq!(
1977 XmlExternalEntityDocumentPlan::from_callback_strings(
1978 &policy,
1979 &callback(CONTROL_CALLBACK),
1980 &callback(CONTROL_CALLBACK),
1981 &callback(REPLAY_CALLBACK),
1982 )
1983 .err()
1984 .unwrap(),
1985 XmlExternalEntityDocumentError::CallbackIdentityConflict
1986 );
1987 for invalid in [
1988 "http://127.0.0.1:39092/c/AQEBAQEBAQEBAQEBAQEBAQ/AgICAgICAgICAgICAgICAg",
1989 "http://127.0.0.1:39091/not-a-callback",
1990 "http://127.0.0.1:39091/c/AQEBAQEBAQEBAQEBAQEBAQ/AgICAgICAgICAgICAgICAg?x=1",
1991 "https://127.0.0.1:39091/c/AQEBAQEBAQEBAQEBAQEBAQ/AgICAgICAgICAgICAgICAg",
1992 ] {
1993 assert_eq!(
1994 XmlExternalEntityDocumentPlan::from_callback_strings(
1995 &policy,
1996 invalid,
1997 &callback(CANDIDATE_CALLBACK),
1998 &callback(REPLAY_CALLBACK),
1999 )
2000 .err()
2001 .unwrap(),
2002 XmlExternalEntityDocumentError::InvalidCallbackTarget
2003 );
2004 }
2005 }
2006
2007 #[test]
2008 fn xml_attribute_escaping_is_canonical_and_body_bound_is_enforced() {
2009 assert_eq!(
2010 escape_xml_attribute("a&<>\"'z"),
2011 "a&<>"'z"
2012 );
2013 assert_eq!(
2014 build_document(
2015 &loopback_policy(),
2016 XmlExternalEntityDocumentRole::Candidate,
2017 &"a".repeat(MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES)
2018 )
2019 .err()
2020 .unwrap(),
2021 XmlExternalEntityDocumentError::DocumentTooLarge
2022 );
2023 }
2024
2025 #[test]
2026 fn audit_positive_outcome_enforces_claim_and_lifecycle_invariants() {
2027 let policy = production_policy();
2028 let audit = XmlExternalEntityReviewAudit::from_runtime(&policy, positive_facts()).unwrap();
2029 assert_eq!(audit.schema(), XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA);
2030 assert_eq!(audit.algorithm(), XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM);
2031 assert_eq!(audit.action_id(), XML_EXTERNAL_ENTITY_REVIEW_ACTION_ID);
2032 assert_eq!(
2033 audit.capability_id(),
2034 XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID
2035 );
2036 assert_eq!(audit.method(), XML_EXTERNAL_ENTITY_REVIEW_METHOD);
2037 assert_eq!(audit.media_type(), XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE);
2038 assert_eq!(
2039 audit.control_model(),
2040 XML_EXTERNAL_ENTITY_REVIEW_CONTROL_MODEL
2041 );
2042 assert_eq!(
2043 audit.outcome(),
2044 XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved
2045 );
2046 assert_eq!(audit.target_request_body_bytes(), 768);
2047 assert_eq!(audit.target_response_bytes(), 96);
2048 assert!(audit.target_complete());
2049 assert!(audit.item_projected());
2050 assert_eq!(
2051 audit.claim_limits().maximum_disposition(),
2052 XmlExternalEntityMaximumDisposition::NeedsReview
2053 );
2054 assert_eq!(
2055 audit.semantic_effect(),
2056 XmlExternalEntityOperationStatus::NotPerformed
2057 );
2058 assert_eq!(
2059 audit.impact_validation(),
2060 XmlExternalEntityOperationStatus::NotPerformed
2061 );
2062 let wire = serde_json::to_string(&audit).unwrap();
2063 assert!(wire.contains(XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA));
2064 assert!(wire.contains("\"semantic_effect\":\"not_performed\""));
2065 assert!(wire.contains("\"impact_validation\":\"not_performed\""));
2066 assert!(!wire.contains("app.example.test"));
2067 assert!(!wire.contains("oast.example.test"));
2068 assert!(!format!("{audit:?}").contains("fixtures/xml"));
2069
2070 let mut contaminated = positive_facts();
2071 contaminated.control_callback_observed = true;
2072 assert_eq!(
2073 XmlExternalEntityReviewAudit::from_runtime(&policy, contaminated).unwrap_err(),
2074 XmlExternalEntityAuditError::InvalidOutcome
2075 );
2076
2077 let mut incomplete = positive_facts();
2078 incomplete.target_complete = false;
2079 assert_eq!(
2080 XmlExternalEntityReviewAudit::from_runtime(&policy, incomplete).unwrap_err(),
2081 XmlExternalEntityAuditError::InvalidCorrelation
2082 );
2083
2084 let mut over_budget = positive_facts();
2085 over_budget.provider_request_count = MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS as u8 + 1;
2086 assert_eq!(
2087 XmlExternalEntityReviewAudit::from_runtime(&policy, over_budget).unwrap_err(),
2088 XmlExternalEntityAuditError::InvalidCounts
2089 );
2090
2091 let mut oversized_complete_response = positive_facts();
2092 oversized_complete_response.target_response_bytes =
2093 u64::try_from(XML_EXTERNAL_ENTITY_TARGET_REQUESTS).unwrap()
2094 * MAX_XML_EXTERNAL_ENTITY_TARGET_RESPONSE_BYTES
2095 + 1;
2096 assert_eq!(
2097 XmlExternalEntityReviewAudit::from_runtime(&policy, oversized_complete_response)
2098 .unwrap_err(),
2099 XmlExternalEntityAuditError::InvalidCounts
2100 );
2101 }
2102
2103 #[test]
2104 fn non_positive_audit_cannot_project_an_item() {
2105 let policy = production_policy();
2106 let mut preflight = positive_facts();
2107 preflight.outcome = XmlExternalEntityReviewOutcome::PreflightContaminated;
2108 preflight.target_request_count = 0;
2109 preflight.target_request_body_bytes = 0;
2110 preflight.target_response_bytes = 0;
2111 preflight.target_complete = false;
2112 preflight.target_accounting_complete = false;
2113 preflight.provider_request_count = 6;
2114 preflight.active_verification_count = 0;
2115 preflight.provider_accounting_complete = false;
2116 preflight.preflight_clean = false;
2117 preflight.candidate_callback_observed = true;
2118 preflight.replay_callback_observed = false;
2119 preflight.event_identities_distinct = false;
2120 preflight.item_projected = false;
2121 let audit = XmlExternalEntityReviewAudit::from_runtime(&policy, preflight).unwrap();
2122 assert_eq!(
2123 audit.outcome(),
2124 XmlExternalEntityReviewOutcome::PreflightContaminated
2125 );
2126
2127 let mut facts = positive_facts();
2128 facts.outcome = XmlExternalEntityReviewOutcome::CandidateOnly;
2129 facts.replay_callback_observed = false;
2130 facts.event_identities_distinct = false;
2131 facts.item_projected = false;
2132 let audit = XmlExternalEntityReviewAudit::from_runtime(&policy, facts).unwrap();
2133 assert_eq!(
2134 audit.outcome(),
2135 XmlExternalEntityReviewOutcome::CandidateOnly
2136 );
2137 assert!(!audit.item_projected());
2138
2139 let mut invalid = positive_facts();
2140 invalid.outcome = XmlExternalEntityReviewOutcome::CandidateOnly;
2141 invalid.replay_callback_observed = false;
2142 invalid.event_identities_distinct = false;
2143 assert_eq!(
2144 XmlExternalEntityReviewAudit::from_runtime(&policy, invalid).unwrap_err(),
2145 XmlExternalEntityAuditError::InvalidProjection
2146 );
2147 }
2148
2149 #[test]
2150 fn audit_rejects_cross_field_lifecycle_mutations_for_negative_outcomes() {
2151 let policy = production_policy();
2152
2153 let mut wrong_active_count = positive_facts();
2154 wrong_active_count.outcome = XmlExternalEntityReviewOutcome::CandidateOnly;
2155 wrong_active_count.replay_callback_observed = false;
2156 wrong_active_count.event_identities_distinct = false;
2157 wrong_active_count.item_projected = false;
2158 wrong_active_count.active_verification_count = 0;
2159 assert_eq!(
2160 XmlExternalEntityReviewAudit::from_runtime(&policy, wrong_active_count).unwrap_err(),
2161 XmlExternalEntityAuditError::InvalidCorrelation
2162 );
2163
2164 let mut impossible_target_accounting = positive_facts();
2165 impossible_target_accounting.outcome = XmlExternalEntityReviewOutcome::Incomplete;
2166 impossible_target_accounting.target_complete = false;
2167 impossible_target_accounting.item_projected = false;
2168 assert_eq!(
2169 XmlExternalEntityReviewAudit::from_runtime(&policy, impossible_target_accounting)
2170 .unwrap_err(),
2171 XmlExternalEntityAuditError::InvalidCorrelation
2172 );
2173
2174 let mut unbound_callback = positive_facts();
2175 unbound_callback.outcome = XmlExternalEntityReviewOutcome::CorrelationMismatch;
2176 unbound_callback.callback_targets_distinct = false;
2177 unbound_callback.event_identities_distinct = false;
2178 unbound_callback.item_projected = false;
2179 assert_eq!(
2180 XmlExternalEntityReviewAudit::from_runtime(&policy, unbound_callback).unwrap_err(),
2181 XmlExternalEntityAuditError::InvalidCorrelation
2182 );
2183
2184 let mut fabricated_complete_provider = positive_facts();
2185 fabricated_complete_provider.outcome = XmlExternalEntityReviewOutcome::CandidateOnly;
2186 fabricated_complete_provider.provider_request_count = 8;
2187 fabricated_complete_provider.replay_callback_observed = false;
2188 fabricated_complete_provider.event_identities_distinct = false;
2189 fabricated_complete_provider.item_projected = false;
2190 assert_eq!(
2191 XmlExternalEntityReviewAudit::from_runtime(&policy, fabricated_complete_provider)
2192 .unwrap_err(),
2193 XmlExternalEntityAuditError::InvalidCorrelation
2194 );
2195
2196 let mut stopped_with_complete_accounting = positive_facts();
2197 stopped_with_complete_accounting.outcome = XmlExternalEntityReviewOutcome::Incomplete;
2198 stopped_with_complete_accounting.cleanup_verified = false;
2199 stopped_with_complete_accounting.item_projected = false;
2200 assert_eq!(
2201 XmlExternalEntityReviewAudit::from_runtime(&policy, stopped_with_complete_accounting)
2202 .unwrap_err(),
2203 XmlExternalEntityAuditError::InvalidOutcome
2204 );
2205 }
2206}