1use std::{collections::BTreeSet, fmt, net::IpAddr};
14
15use serde::de::Error as _;
16use serde::{
17 de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor},
18 Deserialize, Deserializer,
19};
20use serde_json::{Map, Number, Value};
21use sha2::{Digest, Sha256};
22use thiserror::Error;
23use url::{Host, Url};
24
25pub const WEBSOCKET_REVIEW_POLICY_SCHEMA: &str = "security.websocket-review-policy/v1";
27pub const WEBSOCKET_REVIEW_POLICY_ALGORITHM: &str = "termivar.websocket-review-policy/v1";
29pub const MAX_WEBSOCKET_REVIEW_POLICY_BYTES: usize = 128 * 1024;
31pub const MAX_WEBSOCKET_REVIEW_MESSAGES: usize = 8;
33pub const MAX_WEBSOCKET_REVIEW_MESSAGE_TEXT_BYTES: usize = 16 * 1024;
35pub const MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES: u64 = 64 * 1024;
37pub const MAX_WEBSOCKET_REVIEW_CONTROL_FRAMES: u16 = 16;
39pub const MAX_WEBSOCKET_REVIEW_WALL_TIME_MS: u64 = 10_000;
41pub const MAX_WEBSOCKET_REVIEW_MESSAGE_ID_BYTES: usize = 128;
43pub const MAX_WEBSOCKET_REVIEW_SUBPROTOCOL_BYTES: usize = 128;
45
46pub const MAX_WEBSOCKET_REVIEW_MESSAGE_BYTES: u64 = MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES;
48pub const MAX_WEBSOCKET_REVIEW_TOTAL_OUTBOUND_BYTES: u64 = MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES;
50pub const MAX_WEBSOCKET_REVIEW_TOTAL_INBOUND_BYTES: u64 = MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES;
52
53const MAX_WEBSOCKET_REVIEW_ENDPOINT_BYTES: usize = 2048;
54const DUPLICATE_KEY_SENTINEL: &str = "termivar_websocket_review_duplicate_json_key";
55const POLICY_REFERENCE_DOMAIN: &[u8] = b"termivar.websocket-review.policy-reference.v1\0";
56const APPLICATION_REFERENCE_DOMAIN: &[u8] = b"termivar.websocket-review.application-reference.v1\0";
57const ENDPOINT_REFERENCE_DOMAIN: &[u8] = b"termivar.websocket-review.endpoint-reference.v1\0";
58const MESSAGE_REFERENCE_DOMAIN: &[u8] = b"termivar.websocket-review.message-reference.v1\0";
59const EXPECTED_RESPONSE_REFERENCE_DOMAIN: &[u8] =
60 b"termivar.websocket-review.expected-response-reference.v1\0";
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum WebSocketOriginMode {
66 ApplicationOrigin,
68 Omit,
70}
71
72impl WebSocketOriginMode {
73 #[must_use]
75 pub const fn as_str(self) -> &'static str {
76 match self {
77 Self::ApplicationOrigin => "application_origin",
78 Self::Omit => "omit",
79 }
80 }
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub struct WebSocketReviewLimits {
86 max_inbound_message_bytes: u64,
87 max_outbound_message_bytes: u64,
88 max_messages: u16,
89 max_control_frames: u16,
90 max_wall_time_ms: u64,
91}
92
93impl WebSocketReviewLimits {
94 #[must_use]
96 pub const fn max_inbound_message_bytes(self) -> u64 {
97 self.max_inbound_message_bytes
98 }
99
100 #[must_use]
102 pub const fn max_outbound_message_bytes(self) -> u64 {
103 self.max_outbound_message_bytes
104 }
105
106 #[must_use]
108 pub const fn max_messages(self) -> u16 {
109 self.max_messages
110 }
111
112 #[must_use]
114 pub const fn max_control_frames(self) -> u16 {
115 self.max_control_frames
116 }
117
118 #[must_use]
120 pub const fn max_wall_time_ms(self) -> u64 {
121 self.max_wall_time_ms
122 }
123}
124
125#[derive(Clone, PartialEq, Eq)]
126struct WebSocketReviewMessage {
127 text: String,
128 expected_response_sha256: [u8; 32],
129 expected_response_length: u64,
130 message_reference: String,
131 expected_response_reference: String,
132}
133
134impl fmt::Debug for WebSocketReviewMessage {
135 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136 formatter
137 .debug_struct("WebSocketReviewMessage")
138 .field("id", &"<redacted>")
139 .field("text", &"<redacted>")
140 .field("expected_response", &"<redacted>")
141 .field("message_reference", &self.message_reference)
142 .field(
143 "expected_response_reference",
144 &self.expected_response_reference,
145 )
146 .field("outbound_length", &self.text.len())
147 .field("expected_response_length", &self.expected_response_length)
148 .finish()
149 }
150}
151
152#[derive(Clone, Copy)]
157pub struct WebSocketReviewMessageRef<'a> {
158 message: &'a WebSocketReviewMessage,
159}
160
161impl<'a> WebSocketReviewMessageRef<'a> {
162 #[must_use]
164 pub fn message_reference(self) -> &'a str {
165 &self.message.message_reference
166 }
167
168 #[must_use]
170 pub fn expected_response_reference(self) -> &'a str {
171 &self.message.expected_response_reference
172 }
173
174 #[must_use]
176 pub fn outbound_length(self) -> u64 {
177 u64::try_from(self.message.text.len()).unwrap_or(u64::MAX)
178 }
179
180 #[must_use]
182 pub const fn expected_response_length(self) -> u64 {
183 self.message.expected_response_length
184 }
185
186 pub(crate) fn execution_text(self) -> &'a str {
187 &self.message.text
188 }
189
190 pub(crate) const fn expected_response_sha256(self) -> &'a [u8; 32] {
191 &self.message.expected_response_sha256
192 }
193}
194
195impl fmt::Debug for WebSocketReviewMessageRef<'_> {
196 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197 formatter
198 .debug_struct("WebSocketReviewMessageRef")
199 .field("message_reference", &self.message.message_reference)
200 .field(
201 "expected_response_reference",
202 &self.message.expected_response_reference,
203 )
204 .field("outbound_length", &self.message.text.len())
205 .field(
206 "expected_response_length",
207 &self.message.expected_response_length,
208 )
209 .finish()
210 }
211}
212
213#[derive(Clone, PartialEq, Eq)]
215pub struct WebSocketReviewPolicy {
216 endpoint: Url,
217 application_origin: Option<String>,
218 origin_mode: WebSocketOriginMode,
219 subprotocol: Option<String>,
220 messages: Vec<WebSocketReviewMessage>,
221 limits: WebSocketReviewLimits,
222 policy_reference: String,
223 application_reference: String,
224 endpoint_reference: String,
225}
226
227impl WebSocketReviewPolicy {
228 pub fn parse_json(
235 application_target: &Url,
236 source: &[u8],
237 ) -> Result<Self, WebSocketReviewPolicyError> {
238 if source.is_empty() {
239 return Err(WebSocketReviewPolicyError::EmptyPolicy);
240 }
241 if source.len() > MAX_WEBSOCKET_REVIEW_POLICY_BYTES {
242 return Err(WebSocketReviewPolicyError::PolicyTooLarge);
243 }
244
245 let application = validate_application_target(application_target)?;
246 let value = parse_strict_json(source)?;
247 let wire: WirePolicy = serde_json::from_value(value)
248 .map_err(|_| WebSocketReviewPolicyError::MalformedPolicy)?;
249 if wire.schema != WEBSOCKET_REVIEW_POLICY_SCHEMA {
250 return Err(WebSocketReviewPolicyError::UnsupportedSchema);
251 }
252 if !wire.target_authorized {
253 return Err(WebSocketReviewPolicyError::TargetAuthorizationRequired);
254 }
255 if !wire.messages_read_only_acknowledged {
256 return Err(WebSocketReviewPolicyError::ReadOnlyAcknowledgementRequired);
257 }
258 if !wire.message_content_is_non_secret {
259 return Err(WebSocketReviewPolicyError::NonSecretContentAcknowledgementRequired);
260 }
261 if wire.compression {
262 return Err(WebSocketReviewPolicyError::CompressionNotPermitted);
263 }
264 if wire.reconnect {
265 return Err(WebSocketReviewPolicyError::ReconnectNotPermitted);
266 }
267
268 let endpoint = validate_endpoint(&application, &wire.endpoint)?;
269 let origin_mode = match wire.origin_mode.as_str() {
270 "application_origin" => WebSocketOriginMode::ApplicationOrigin,
271 "omit" => WebSocketOriginMode::Omit,
272 _ => return Err(WebSocketReviewPolicyError::UnsupportedOriginMode),
273 };
274 if wire
275 .subprotocol
276 .as_deref()
277 .is_some_and(|value| !valid_subprotocol(value))
278 {
279 return Err(WebSocketReviewPolicyError::InvalidSubprotocol);
280 }
281
282 let limits = validate_limits(&wire.limits, wire.messages.len())?;
283 let mut seen_ids = BTreeSet::new();
284 let mut total_outbound_bytes = 0_u64;
285 let mut total_expected_inbound_bytes = 0_u64;
286 let mut messages = Vec::with_capacity(wire.messages.len());
287 for declaration in wire.messages {
288 if !valid_message_id(&declaration.id) {
289 return Err(WebSocketReviewPolicyError::InvalidMessageId);
290 }
291 if !seen_ids.insert(declaration.id.clone()) {
292 return Err(WebSocketReviewPolicyError::DuplicateMessageId);
293 }
294 if declaration.text.len() > MAX_WEBSOCKET_REVIEW_MESSAGE_TEXT_BYTES {
295 return Err(WebSocketReviewPolicyError::MessageTextTooLarge);
296 }
297 let outbound_length = u64::try_from(declaration.text.len())
298 .map_err(|_| WebSocketReviewPolicyError::MessageTextTooLarge)?;
299 total_outbound_bytes = total_outbound_bytes
300 .checked_add(outbound_length)
301 .filter(|length| *length <= MAX_WEBSOCKET_REVIEW_TOTAL_OUTBOUND_BYTES)
302 .ok_or(WebSocketReviewPolicyError::MessageTextAggregateTooLarge)?;
303 let expected_response_sha256 = parse_sha256(&declaration.expected_response.sha256)?;
304 let expected_response_length = declaration.expected_response.length;
305 total_expected_inbound_bytes = total_expected_inbound_bytes
306 .checked_add(expected_response_length)
307 .filter(|length| *length <= MAX_WEBSOCKET_REVIEW_TOTAL_INBOUND_BYTES)
308 .ok_or(WebSocketReviewPolicyError::ExpectedResponseAggregateTooLarge)?;
309 if outbound_length > limits.max_outbound_message_bytes
310 || expected_response_length > limits.max_inbound_message_bytes
311 {
312 return Err(WebSocketReviewPolicyError::InvalidLimits);
313 }
314 let expected_response_reference = digest_reference(
319 EXPECTED_RESPONSE_REFERENCE_DOMAIN,
320 "websocket-expected-response-revision-sha256",
321 &[
322 declaration.id.as_bytes(),
323 &expected_response_length.to_be_bytes(),
324 ],
325 );
326 let message_reference = digest_reference(
327 MESSAGE_REFERENCE_DOMAIN,
328 "websocket-message-revision-sha256",
329 &[declaration.id.as_bytes()],
330 );
331 messages.push(WebSocketReviewMessage {
332 text: declaration.text,
333 expected_response_sha256,
334 expected_response_length,
335 message_reference,
336 expected_response_reference,
337 });
338 }
339
340 let application_reference = digest_reference(
341 APPLICATION_REFERENCE_DOMAIN,
342 "websocket-application-sha256",
343 &[application.as_str().as_bytes()],
344 );
345 let endpoint_reference = digest_reference(
346 ENDPOINT_REFERENCE_DOMAIN,
347 "websocket-endpoint-sha256",
348 &[endpoint.as_str().as_bytes()],
349 );
350 let policy_reference = policy_reference(
351 &application_reference,
352 &endpoint,
353 origin_mode,
354 wire.subprotocol.as_deref(),
355 &messages,
356 limits,
357 );
358 let application_origin = (origin_mode == WebSocketOriginMode::ApplicationOrigin)
359 .then(|| application.origin().ascii_serialization());
360
361 Ok(Self {
362 endpoint,
363 application_origin,
364 origin_mode,
365 subprotocol: wire.subprotocol,
366 messages,
367 limits,
368 policy_reference,
369 application_reference,
370 endpoint_reference,
371 })
372 }
373
374 #[must_use]
376 pub const fn schema(&self) -> &'static str {
377 WEBSOCKET_REVIEW_POLICY_SCHEMA
378 }
379
380 #[must_use]
382 pub fn policy_reference(&self) -> &str {
383 &self.policy_reference
384 }
385
386 #[must_use]
388 pub fn application_reference(&self) -> &str {
389 &self.application_reference
390 }
391
392 #[must_use]
394 pub fn endpoint_reference(&self) -> &str {
395 &self.endpoint_reference
396 }
397
398 #[must_use]
400 pub fn message_count(&self) -> usize {
401 self.messages.len()
402 }
403
404 pub fn messages(&self) -> impl ExactSizeIterator<Item = WebSocketReviewMessageRef<'_>> {
406 self.messages
407 .iter()
408 .map(|message| WebSocketReviewMessageRef { message })
409 }
410
411 #[must_use]
413 pub const fn origin_mode(&self) -> WebSocketOriginMode {
414 self.origin_mode
415 }
416
417 #[must_use]
420 pub const fn has_subprotocol(&self) -> bool {
421 self.subprotocol.is_some()
422 }
423
424 #[must_use]
426 pub const fn target_authorized(&self) -> bool {
427 true
428 }
429
430 #[must_use]
432 pub const fn messages_read_only_acknowledged(&self) -> bool {
433 true
434 }
435
436 #[must_use]
438 pub const fn message_content_is_non_secret(&self) -> bool {
439 true
440 }
441
442 #[must_use]
444 pub const fn compression_enabled(&self) -> bool {
445 false
446 }
447
448 #[must_use]
450 pub const fn reconnect_enabled(&self) -> bool {
451 false
452 }
453
454 #[must_use]
456 pub const fn limits(&self) -> WebSocketReviewLimits {
457 self.limits
458 }
459
460 #[must_use]
462 pub const fn max_inbound_message_bytes(&self) -> u64 {
463 self.limits.max_inbound_message_bytes
464 }
465
466 #[must_use]
468 pub const fn max_outbound_message_bytes(&self) -> u64 {
469 self.limits.max_outbound_message_bytes
470 }
471
472 #[must_use]
474 pub const fn max_messages(&self) -> u16 {
475 self.limits.max_messages
476 }
477
478 #[must_use]
480 pub const fn max_control_frames(&self) -> u16 {
481 self.limits.max_control_frames
482 }
483
484 #[must_use]
486 pub const fn max_wall_time_ms(&self) -> u64 {
487 self.limits.max_wall_time_ms
488 }
489
490 pub(crate) const fn execution_endpoint(&self) -> &Url {
491 &self.endpoint
492 }
493
494 pub(crate) fn is_bound_to_application(&self, application_target: &Url) -> bool {
499 validate_application_target(application_target).is_ok_and(|application| {
500 digest_reference(
501 APPLICATION_REFERENCE_DOMAIN,
502 "websocket-application-sha256",
503 &[application.as_str().as_bytes()],
504 ) == self.application_reference
505 })
506 }
507
508 pub(crate) fn execution_origin(&self) -> Option<&str> {
509 self.application_origin.as_deref()
510 }
511
512 pub(crate) fn execution_subprotocol(&self) -> Option<&str> {
513 self.subprotocol.as_deref()
514 }
515
516 pub(crate) fn execution_messages(
517 &self,
518 ) -> impl ExactSizeIterator<Item = WebSocketReviewMessageRef<'_>> {
519 self.messages()
520 }
521}
522
523impl fmt::Debug for WebSocketReviewPolicy {
524 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
525 formatter
526 .debug_struct("WebSocketReviewPolicy")
527 .field("application", &"<redacted>")
528 .field("endpoint", &"<redacted>")
529 .field("origin", &"<redacted>")
530 .field("subprotocol", &"<redacted>")
531 .field("messages", &"<redacted>")
532 .field("schema", &WEBSOCKET_REVIEW_POLICY_SCHEMA)
533 .field("origin_mode", &self.origin_mode)
534 .field("subprotocol_present", &self.subprotocol.is_some())
535 .field("message_count", &self.messages.len())
536 .field("limits", &self.limits)
537 .field("policy_reference", &self.policy_reference)
538 .field("application_reference", &self.application_reference)
539 .field("endpoint_reference", &self.endpoint_reference)
540 .finish()
541 }
542}
543
544#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
546#[non_exhaustive]
547pub enum WebSocketReviewPolicyError {
548 #[error("WebSocket review policy is empty")]
550 EmptyPolicy,
551 #[error("WebSocket review policy exceeds its compiled byte limit")]
553 PolicyTooLarge,
554 #[error("WebSocket review policy is malformed")]
556 MalformedPolicy,
557 #[error("WebSocket review policy contains a duplicate JSON key")]
559 DuplicateJsonKey,
560 #[error("WebSocket review policy schema is unsupported")]
562 UnsupportedSchema,
563 #[error("WebSocket review application target is invalid")]
565 InvalidApplicationTarget,
566 #[error("WebSocket review requires the target_authorized operator assertion")]
568 TargetAuthorizationRequired,
569 #[error("WebSocket review requires the read-only message acknowledgement")]
571 ReadOnlyAcknowledgementRequired,
572 #[error("WebSocket review requires the non-secret message-content acknowledgement")]
574 NonSecretContentAcknowledgementRequired,
575 #[error("WebSocket review endpoint is invalid")]
577 InvalidEndpoint,
578 #[error("WebSocket review endpoint is outside application authority")]
580 EndpointOutsideApplication,
581 #[error("plaintext WebSocket review is permitted only on numeric loopback")]
583 InsecureEndpoint,
584 #[error("WebSocket review origin mode is unsupported")]
586 UnsupportedOriginMode,
587 #[error("WebSocket review subprotocol is invalid")]
589 InvalidSubprotocol,
590 #[error("WebSocket review compression must be disabled")]
592 CompressionNotPermitted,
593 #[error("WebSocket review reconnect must be disabled")]
595 ReconnectNotPermitted,
596 #[error("WebSocket review message count is invalid")]
598 InvalidMessageCount,
599 #[error("WebSocket review message identifier is invalid")]
601 InvalidMessageId,
602 #[error("WebSocket review message identifier is duplicated")]
604 DuplicateMessageId,
605 #[error("WebSocket review message text exceeds its compiled byte limit")]
607 MessageTextTooLarge,
608 #[error("WebSocket review outbound message bytes exceed their compiled limit")]
610 MessageTextAggregateTooLarge,
611 #[error("WebSocket review expected response is invalid")]
613 InvalidExpectedResponse,
614 #[error("WebSocket review expected response bytes exceed their compiled limit")]
616 ExpectedResponseAggregateTooLarge,
617 #[error("WebSocket review runtime limits are invalid")]
619 InvalidLimits,
620}
621
622impl WebSocketReviewPolicyError {
623 #[must_use]
625 pub const fn as_str(self) -> &'static str {
626 match self {
627 Self::EmptyPolicy => "empty_policy",
628 Self::PolicyTooLarge => "policy_too_large",
629 Self::MalformedPolicy => "malformed_policy",
630 Self::DuplicateJsonKey => "duplicate_json_key",
631 Self::UnsupportedSchema => "unsupported_schema",
632 Self::InvalidApplicationTarget => "invalid_application_target",
633 Self::TargetAuthorizationRequired => "target_authorization_required",
634 Self::ReadOnlyAcknowledgementRequired => "read_only_acknowledgement_required",
635 Self::NonSecretContentAcknowledgementRequired => {
636 "non_secret_content_acknowledgement_required"
637 },
638 Self::InvalidEndpoint => "invalid_endpoint",
639 Self::EndpointOutsideApplication => "endpoint_outside_application",
640 Self::InsecureEndpoint => "insecure_endpoint",
641 Self::UnsupportedOriginMode => "unsupported_origin_mode",
642 Self::InvalidSubprotocol => "invalid_subprotocol",
643 Self::CompressionNotPermitted => "compression_not_permitted",
644 Self::ReconnectNotPermitted => "reconnect_not_permitted",
645 Self::InvalidMessageCount => "invalid_message_count",
646 Self::InvalidMessageId => "invalid_message_id",
647 Self::DuplicateMessageId => "duplicate_message_id",
648 Self::MessageTextTooLarge => "message_text_too_large",
649 Self::MessageTextAggregateTooLarge => "message_text_aggregate_too_large",
650 Self::InvalidExpectedResponse => "invalid_expected_response",
651 Self::ExpectedResponseAggregateTooLarge => "expected_response_aggregate_too_large",
652 Self::InvalidLimits => "invalid_limits",
653 }
654 }
655}
656
657#[derive(Deserialize)]
658#[serde(deny_unknown_fields)]
659struct WirePolicy {
660 schema: String,
661 target_authorized: bool,
662 messages_read_only_acknowledged: bool,
663 message_content_is_non_secret: bool,
664 endpoint: String,
665 origin_mode: String,
666 subprotocol: Option<String>,
667 compression: bool,
668 reconnect: bool,
669 messages: Vec<WireMessage>,
670 limits: WireLimits,
671}
672
673#[derive(Deserialize)]
674#[serde(deny_unknown_fields)]
675struct WireMessage {
676 id: String,
677 text: String,
678 expected_response: WireExpectedResponse,
679}
680
681#[derive(Deserialize)]
682#[serde(deny_unknown_fields)]
683struct WireExpectedResponse {
684 sha256: String,
685 length: u64,
686}
687
688#[derive(Deserialize)]
689#[serde(deny_unknown_fields)]
690struct WireLimits {
691 max_inbound_message_bytes: u64,
692 max_outbound_message_bytes: u64,
693 max_messages: u16,
694 max_control_frames: u16,
695 max_wall_time_ms: u64,
696}
697
698fn validate_application_target(
699 application_target: &Url,
700) -> Result<Url, WebSocketReviewPolicyError> {
701 if !matches!(application_target.scheme(), "http" | "https")
702 || application_target.host().is_none()
703 || application_target.port_or_known_default().is_none()
704 || !application_target.username().is_empty()
705 || application_target.password().is_some()
706 || application_target.query().is_some()
707 || application_target.fragment().is_some()
708 || !valid_scoped_path(application_target.path())
709 {
710 return Err(WebSocketReviewPolicyError::InvalidApplicationTarget);
711 }
712 let mut application = application_target.clone();
713 application.set_query(None);
714 application.set_fragment(None);
715 Ok(application)
716}
717
718fn validate_endpoint(application: &Url, raw: &str) -> Result<Url, WebSocketReviewPolicyError> {
719 if raw.is_empty()
720 || raw.len() > MAX_WEBSOCKET_REVIEW_ENDPOINT_BYTES
721 || raw.trim() != raw
722 || raw.contains('%')
723 || raw.contains('\\')
724 || raw
725 .chars()
726 .any(|character| character.is_ascii_control() || character.is_whitespace())
727 || raw_url_has_dot_path_segment(raw)
728 {
729 return Err(WebSocketReviewPolicyError::InvalidEndpoint);
730 }
731 let endpoint = Url::parse(raw).map_err(|_| WebSocketReviewPolicyError::InvalidEndpoint)?;
732 if !matches!(endpoint.scheme(), "ws" | "wss")
733 || endpoint.host().is_none()
734 || endpoint.port_or_known_default().is_none()
735 || !endpoint.username().is_empty()
736 || endpoint.password().is_some()
737 || endpoint.query().is_some()
738 || endpoint.fragment().is_some()
739 || !valid_scoped_path(endpoint.path())
740 {
741 return Err(WebSocketReviewPolicyError::InvalidEndpoint);
742 }
743 if endpoint.host() != application.host()
744 || endpoint.port_or_known_default() != application.port_or_known_default()
745 || !path_is_within(application.path(), endpoint.path())
746 {
747 return Err(WebSocketReviewPolicyError::EndpointOutsideApplication);
748 }
749 if !matches!(
750 (application.scheme(), endpoint.scheme()),
751 ("http", "ws") | ("https", "wss")
752 ) {
753 return Err(WebSocketReviewPolicyError::InvalidEndpoint);
754 }
755 if endpoint.scheme() == "ws"
756 && (application.scheme() != "http"
757 || !numeric_loopback(application.host())
758 || !numeric_loopback(endpoint.host()))
759 {
760 return Err(WebSocketReviewPolicyError::InsecureEndpoint);
761 }
762 Ok(endpoint)
763}
764
765fn raw_url_has_dot_path_segment(raw: &str) -> bool {
766 let Some((_, after_scheme)) = raw.split_once("://") else {
767 return false;
768 };
769 let Some(path_start) = after_scheme.find('/') else {
770 return false;
771 };
772 after_scheme[path_start..]
773 .split(['?', '#'])
774 .next()
775 .unwrap_or_default()
776 .split('/')
777 .any(|segment| matches!(segment, "." | ".."))
778}
779
780fn valid_scoped_path(path: &str) -> bool {
781 !path.is_empty()
782 && path.starts_with('/')
783 && !path.contains('%')
784 && !path.contains('\\')
785 && !path.contains("//")
786 && !path.chars().any(char::is_control)
787}
788
789fn path_is_within(application_path: &str, endpoint_path: &str) -> bool {
790 if application_path == "/" {
791 return endpoint_path.starts_with('/');
792 }
793 let application_path = application_path.trim_end_matches('/');
794 endpoint_path == application_path
795 || endpoint_path
796 .strip_prefix(application_path)
797 .is_some_and(|suffix| suffix.starts_with('/'))
798}
799
800fn numeric_loopback(host: Option<Host<&str>>) -> bool {
801 match host {
802 Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(),
803 Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(),
804 _ => false,
805 }
806}
807
808fn valid_subprotocol(value: &str) -> bool {
809 !value.is_empty()
810 && value.len() <= MAX_WEBSOCKET_REVIEW_SUBPROTOCOL_BYTES
811 && value.bytes().all(http_token_byte)
812}
813
814fn valid_message_id(value: &str) -> bool {
815 !value.is_empty()
816 && value.len() <= MAX_WEBSOCKET_REVIEW_MESSAGE_ID_BYTES
817 && value.as_bytes()[0].is_ascii_alphanumeric()
818 && value
819 .bytes()
820 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
821}
822
823fn http_token_byte(byte: u8) -> bool {
824 byte.is_ascii_alphanumeric()
825 || matches!(
826 byte,
827 b'!' | b'#'
828 | b'$'
829 | b'%'
830 | b'&'
831 | b'\''
832 | b'*'
833 | b'+'
834 | b'-'
835 | b'.'
836 | b'^'
837 | b'_'
838 | b'`'
839 | b'|'
840 | b'~'
841 )
842}
843
844fn validate_limits(
845 wire: &WireLimits,
846 message_count: usize,
847) -> Result<WebSocketReviewLimits, WebSocketReviewPolicyError> {
848 if message_count == 0 || message_count > MAX_WEBSOCKET_REVIEW_MESSAGES {
849 return Err(WebSocketReviewPolicyError::InvalidMessageCount);
850 }
851 let message_count = u16::try_from(message_count)
852 .map_err(|_| WebSocketReviewPolicyError::InvalidMessageCount)?;
853 if wire.max_inbound_message_bytes == 0
854 || wire.max_inbound_message_bytes > MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES
855 || wire.max_outbound_message_bytes == 0
856 || wire.max_outbound_message_bytes > MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES
857 || wire.max_messages < message_count
858 || usize::from(wire.max_messages) > MAX_WEBSOCKET_REVIEW_MESSAGES
859 || wire.max_control_frames == 0
860 || wire.max_control_frames > MAX_WEBSOCKET_REVIEW_CONTROL_FRAMES
861 || wire.max_wall_time_ms == 0
862 || wire.max_wall_time_ms > MAX_WEBSOCKET_REVIEW_WALL_TIME_MS
863 {
864 return Err(WebSocketReviewPolicyError::InvalidLimits);
865 }
866 Ok(WebSocketReviewLimits {
867 max_inbound_message_bytes: wire.max_inbound_message_bytes,
868 max_outbound_message_bytes: wire.max_outbound_message_bytes,
869 max_messages: wire.max_messages,
870 max_control_frames: wire.max_control_frames,
871 max_wall_time_ms: wire.max_wall_time_ms,
872 })
873}
874
875fn parse_sha256(value: &str) -> Result<[u8; 32], WebSocketReviewPolicyError> {
876 if value.len() != 64 {
877 return Err(WebSocketReviewPolicyError::InvalidExpectedResponse);
878 }
879 let mut decoded = [0_u8; 32];
880 let encoded = value.as_bytes();
881 for (index, byte) in decoded.iter_mut().enumerate() {
882 let offset = index * 2;
883 let high = lower_hex_nibble(encoded[offset])
884 .ok_or(WebSocketReviewPolicyError::InvalidExpectedResponse)?;
885 let low = lower_hex_nibble(encoded[offset + 1])
886 .ok_or(WebSocketReviewPolicyError::InvalidExpectedResponse)?;
887 *byte = (high << 4) | low;
888 }
889 Ok(decoded)
890}
891
892fn lower_hex_nibble(byte: u8) -> Option<u8> {
893 match byte {
894 b'0'..=b'9' => Some(byte - b'0'),
895 b'a'..=b'f' => Some(byte - b'a' + 10),
896 _ => None,
897 }
898}
899
900fn policy_reference(
901 application_reference: &str,
902 endpoint: &Url,
903 origin_mode: WebSocketOriginMode,
904 subprotocol: Option<&str>,
905 messages: &[WebSocketReviewMessage],
906 limits: WebSocketReviewLimits,
907) -> String {
908 let mut hasher = Sha256::new();
909 hasher.update(POLICY_REFERENCE_DOMAIN);
910 framed(&mut hasher, WEBSOCKET_REVIEW_POLICY_SCHEMA.as_bytes());
911 framed(&mut hasher, WEBSOCKET_REVIEW_POLICY_ALGORITHM.as_bytes());
912 framed(&mut hasher, application_reference.as_bytes());
913 framed(&mut hasher, endpoint.as_str().as_bytes());
914 framed(&mut hasher, origin_mode.as_str().as_bytes());
915 match subprotocol {
916 Some(value) => {
917 framed(&mut hasher, b"subprotocol-present");
918 framed(&mut hasher, value.as_bytes());
919 },
920 None => framed(&mut hasher, b"subprotocol-absent"),
921 }
922 framed(&mut hasher, b"target-authorized-asserted");
923 framed(&mut hasher, b"messages-read-only-acknowledged");
924 framed(&mut hasher, b"message-content-non-secret-acknowledged");
925 framed(&mut hasher, b"compression-disabled");
926 framed(&mut hasher, b"reconnect-disabled");
927 for message in messages {
928 framed(&mut hasher, message.message_reference.as_bytes());
929 }
930 for value in [
931 limits.max_inbound_message_bytes,
932 limits.max_outbound_message_bytes,
933 u64::from(limits.max_messages),
934 u64::from(limits.max_control_frames),
935 limits.max_wall_time_ms,
936 ] {
937 framed(&mut hasher, &value.to_be_bytes());
938 }
939 format!("websocket-policy-sha256:{}", hex(hasher.finalize().into()))
940}
941
942fn digest_reference(domain: &[u8], prefix: &str, values: &[&[u8]]) -> String {
943 let mut hasher = Sha256::new();
944 hasher.update(domain);
945 for value in values {
946 framed(&mut hasher, value);
947 }
948 format!("{prefix}:{}", hex(hasher.finalize().into()))
949}
950
951fn framed(hasher: &mut Sha256, value: &[u8]) {
952 hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
953 hasher.update(value);
954}
955
956fn hex(bytes: [u8; 32]) -> String {
957 const HEX: &[u8; 16] = b"0123456789abcdef";
958 let mut output = String::with_capacity(64);
959 for byte in bytes {
960 output.push(char::from(HEX[usize::from(byte >> 4)]));
961 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
962 }
963 output
964}
965
966#[derive(Default)]
967struct StrictJsonSeed;
968
969impl<'de> DeserializeSeed<'de> for StrictJsonSeed {
970 type Value = Value;
971
972 fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
973 where
974 D: Deserializer<'de>,
975 {
976 deserializer.deserialize_any(StrictJsonVisitor)
977 }
978}
979
980struct StrictJsonVisitor;
981
982impl<'de> Visitor<'de> for StrictJsonVisitor {
983 type Value = Value;
984
985 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
986 formatter.write_str("strict WebSocket review policy JSON")
987 }
988
989 fn visit_unit<E>(self) -> Result<Self::Value, E> {
990 Ok(Value::Null)
991 }
992
993 fn visit_none<E>(self) -> Result<Self::Value, E> {
994 Ok(Value::Null)
995 }
996
997 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
998 Ok(Value::Bool(value))
999 }
1000
1001 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
1002 Ok(Value::Number(Number::from(value)))
1003 }
1004
1005 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
1006 Ok(Value::Number(Number::from(value)))
1007 }
1008
1009 fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
1010 where
1011 E: de::Error,
1012 {
1013 Number::from_f64(value)
1014 .map(Value::Number)
1015 .ok_or_else(|| E::custom("invalid JSON number"))
1016 }
1017
1018 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
1019 Ok(Value::String(value.to_owned()))
1020 }
1021
1022 fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
1023 Ok(Value::String(value))
1024 }
1025
1026 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1027 where
1028 A: SeqAccess<'de>,
1029 {
1030 let mut values = Vec::new();
1031 while let Some(value) = sequence.next_element_seed(StrictJsonSeed)? {
1032 values.push(value);
1033 }
1034 Ok(Value::Array(values))
1035 }
1036
1037 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1038 where
1039 A: MapAccess<'de>,
1040 {
1041 let mut values = Map::new();
1042 while let Some(key) = map.next_key::<String>()? {
1043 if values.contains_key(&key) {
1044 return Err(A::Error::custom(DUPLICATE_KEY_SENTINEL));
1045 }
1046 let value = map.next_value_seed(StrictJsonSeed)?;
1047 values.insert(key, value);
1048 }
1049 Ok(Value::Object(values))
1050 }
1051}
1052
1053fn parse_strict_json(source: &[u8]) -> Result<Value, WebSocketReviewPolicyError> {
1054 let mut deserializer = serde_json::Deserializer::from_slice(source);
1055 let value = StrictJsonSeed
1056 .deserialize(&mut deserializer)
1057 .map_err(classify_json_error)?;
1058 deserializer.end().map_err(classify_json_error)?;
1059 Ok(value)
1060}
1061
1062fn classify_json_error(error: serde_json::Error) -> WebSocketReviewPolicyError {
1063 if error.to_string().contains(DUPLICATE_KEY_SENTINEL) {
1064 WebSocketReviewPolicyError::DuplicateJsonKey
1065 } else {
1066 WebSocketReviewPolicyError::MalformedPolicy
1067 }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073 use serde_json::{json, Value};
1074
1075 const ENDPOINT_CANARY: &str = "endpoint-canary-4f190f";
1076 const ID_CANARY: &str = "message-canary-2e731b";
1077 const TEXT_CANARY: &str = "text-canary-60bbcf";
1078 const SUBPROTOCOL_CANARY: &str = "protocol-canary-771a0f";
1079 const DIGEST_CANARY: &str = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
1080
1081 fn application() -> Url {
1082 Url::parse("https://example.test:8443/application/").unwrap()
1083 }
1084
1085 fn valid_value() -> Value {
1086 json!({
1087 "schema": WEBSOCKET_REVIEW_POLICY_SCHEMA,
1088 "target_authorized": true,
1089 "messages_read_only_acknowledged": true,
1090 "message_content_is_non_secret": true,
1091 "endpoint": format!("wss://example.test:8443/application/{ENDPOINT_CANARY}"),
1092 "origin_mode": "application_origin",
1093 "subprotocol": SUBPROTOCOL_CANARY,
1094 "compression": false,
1095 "reconnect": false,
1096 "messages": [{
1097 "id": ID_CANARY,
1098 "text": TEXT_CANARY,
1099 "expected_response": {
1100 "sha256": DIGEST_CANARY,
1101 "length": 19
1102 }
1103 }],
1104 "limits": {
1105 "max_inbound_message_bytes": 4096,
1106 "max_outbound_message_bytes": 4096,
1107 "max_messages": 1,
1108 "max_control_frames": 4,
1109 "max_wall_time_ms": 1000
1110 }
1111 })
1112 }
1113
1114 fn parse(value: &Value) -> Result<WebSocketReviewPolicy, WebSocketReviewPolicyError> {
1115 WebSocketReviewPolicy::parse_json(&application(), &serde_json::to_vec(value).unwrap())
1116 }
1117
1118 #[test]
1119 fn valid_policy_exposes_only_bounded_metadata_and_redacted_debug() {
1120 let policy = parse(&valid_value()).unwrap();
1121 assert_eq!(policy.schema(), WEBSOCKET_REVIEW_POLICY_SCHEMA);
1122 assert_eq!(policy.origin_mode(), WebSocketOriginMode::ApplicationOrigin);
1123 assert!(policy.has_subprotocol());
1124 assert!(policy.target_authorized());
1125 assert!(policy.messages_read_only_acknowledged());
1126 assert!(policy.message_content_is_non_secret());
1127 assert!(!policy.compression_enabled());
1128 assert!(!policy.reconnect_enabled());
1129 assert_eq!(policy.message_count(), 1);
1130 assert_eq!(policy.max_messages(), 1);
1131 assert_eq!(policy.max_control_frames(), 4);
1132 assert_eq!(policy.max_wall_time_ms(), 1000);
1133 assert_eq!(policy.execution_origin(), Some("https://example.test:8443"));
1134 assert_eq!(policy.execution_subprotocol(), Some(SUBPROTOCOL_CANARY));
1135 let message = policy.messages().next().unwrap();
1136 assert!(message
1137 .message_reference()
1138 .starts_with("websocket-message-revision-sha256:"));
1139 assert!(message
1140 .expected_response_reference()
1141 .starts_with("websocket-expected-response-revision-sha256:"));
1142 assert_eq!(message.outbound_length(), TEXT_CANARY.len() as u64);
1143 assert_eq!(message.expected_response_length(), 19);
1144
1145 let rendered = format!("{policy:?} {message:?}");
1146 for canary in [
1147 ENDPOINT_CANARY,
1148 ID_CANARY,
1149 TEXT_CANARY,
1150 SUBPROTOCOL_CANARY,
1151 DIGEST_CANARY,
1152 "example.test",
1153 ] {
1154 assert!(!rendered.contains(canary), "Debug leaked {canary}");
1155 }
1156 assert!(rendered.contains(policy.policy_reference()));
1157 assert!(rendered.contains(policy.endpoint_reference()));
1158 }
1159
1160 #[test]
1161 fn policy_metadata_getters_and_internal_debug_remain_value_free() {
1162 let policy = parse(&valid_value()).unwrap();
1163 assert!(policy
1164 .application_reference()
1165 .starts_with("websocket-application-sha256:"));
1166
1167 let limits = policy.limits();
1168 assert_eq!(limits.max_inbound_message_bytes(), 4096);
1169 assert_eq!(limits.max_outbound_message_bytes(), 4096);
1170 assert_eq!(limits.max_messages(), 1);
1171 assert_eq!(limits.max_control_frames(), 4);
1172 assert_eq!(limits.max_wall_time_ms(), 1000);
1173 assert!(format!("{limits:?}").contains("max_wall_time_ms: 1000"));
1174
1175 let rendered = format!("{:?}", policy.messages.first().unwrap());
1176 assert!(rendered.contains("id: \"<redacted>\""));
1177 assert!(rendered.contains("text: \"<redacted>\""));
1178 assert!(rendered.contains("expected_response: \"<redacted>\""));
1179 assert!(rendered.contains("outbound_length: 18"));
1180 assert!(rendered.contains("expected_response_length: 19"));
1181 for canary in [ID_CANARY, TEXT_CANARY, DIGEST_CANARY] {
1182 assert!(!rendered.contains(canary), "internal Debug leaked {canary}");
1183 }
1184 }
1185
1186 #[test]
1187 fn policy_error_tokens_cover_every_static_failure_class() {
1188 for (error, token) in [
1189 (WebSocketReviewPolicyError::EmptyPolicy, "empty_policy"),
1190 (
1191 WebSocketReviewPolicyError::PolicyTooLarge,
1192 "policy_too_large",
1193 ),
1194 (
1195 WebSocketReviewPolicyError::MalformedPolicy,
1196 "malformed_policy",
1197 ),
1198 (
1199 WebSocketReviewPolicyError::DuplicateJsonKey,
1200 "duplicate_json_key",
1201 ),
1202 (
1203 WebSocketReviewPolicyError::UnsupportedSchema,
1204 "unsupported_schema",
1205 ),
1206 (
1207 WebSocketReviewPolicyError::InvalidApplicationTarget,
1208 "invalid_application_target",
1209 ),
1210 (
1211 WebSocketReviewPolicyError::TargetAuthorizationRequired,
1212 "target_authorization_required",
1213 ),
1214 (
1215 WebSocketReviewPolicyError::ReadOnlyAcknowledgementRequired,
1216 "read_only_acknowledgement_required",
1217 ),
1218 (
1219 WebSocketReviewPolicyError::NonSecretContentAcknowledgementRequired,
1220 "non_secret_content_acknowledgement_required",
1221 ),
1222 (
1223 WebSocketReviewPolicyError::InvalidEndpoint,
1224 "invalid_endpoint",
1225 ),
1226 (
1227 WebSocketReviewPolicyError::EndpointOutsideApplication,
1228 "endpoint_outside_application",
1229 ),
1230 (
1231 WebSocketReviewPolicyError::InsecureEndpoint,
1232 "insecure_endpoint",
1233 ),
1234 (
1235 WebSocketReviewPolicyError::UnsupportedOriginMode,
1236 "unsupported_origin_mode",
1237 ),
1238 (
1239 WebSocketReviewPolicyError::InvalidSubprotocol,
1240 "invalid_subprotocol",
1241 ),
1242 (
1243 WebSocketReviewPolicyError::CompressionNotPermitted,
1244 "compression_not_permitted",
1245 ),
1246 (
1247 WebSocketReviewPolicyError::ReconnectNotPermitted,
1248 "reconnect_not_permitted",
1249 ),
1250 (
1251 WebSocketReviewPolicyError::InvalidMessageCount,
1252 "invalid_message_count",
1253 ),
1254 (
1255 WebSocketReviewPolicyError::InvalidMessageId,
1256 "invalid_message_id",
1257 ),
1258 (
1259 WebSocketReviewPolicyError::DuplicateMessageId,
1260 "duplicate_message_id",
1261 ),
1262 (
1263 WebSocketReviewPolicyError::MessageTextTooLarge,
1264 "message_text_too_large",
1265 ),
1266 (
1267 WebSocketReviewPolicyError::MessageTextAggregateTooLarge,
1268 "message_text_aggregate_too_large",
1269 ),
1270 (
1271 WebSocketReviewPolicyError::InvalidExpectedResponse,
1272 "invalid_expected_response",
1273 ),
1274 (
1275 WebSocketReviewPolicyError::ExpectedResponseAggregateTooLarge,
1276 "expected_response_aggregate_too_large",
1277 ),
1278 (WebSocketReviewPolicyError::InvalidLimits, "invalid_limits"),
1279 ] {
1280 assert_eq!(error.as_str(), token);
1281 }
1282 }
1283
1284 #[test]
1285 fn strict_json_scalar_visitor_preserves_types_and_rejects_non_finite_numbers() {
1286 struct Expectation;
1287
1288 impl fmt::Display for Expectation {
1289 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1290 StrictJsonVisitor.expecting(formatter)
1291 }
1292 }
1293
1294 type ValueError = de::value::Error;
1295 assert_eq!(
1296 Expectation.to_string(),
1297 "strict WebSocket review policy JSON"
1298 );
1299 assert_eq!(
1300 StrictJsonVisitor.visit_none::<ValueError>().unwrap(),
1301 Value::Null
1302 );
1303 assert_eq!(
1304 StrictJsonVisitor.visit_i64::<ValueError>(-17).unwrap(),
1305 json!(-17)
1306 );
1307 assert_eq!(
1308 StrictJsonVisitor.visit_f64::<ValueError>(1.25).unwrap(),
1309 json!(1.25)
1310 );
1311 assert_eq!(
1312 StrictJsonVisitor
1313 .visit_string::<ValueError>("owned scalar".to_owned())
1314 .unwrap(),
1315 json!("owned scalar")
1316 );
1317 assert!(StrictJsonVisitor
1318 .visit_f64::<ValueError>(f64::NAN)
1319 .unwrap_err()
1320 .to_string()
1321 .contains("invalid JSON number"));
1322 }
1323
1324 #[test]
1325 fn public_references_do_not_form_a_dictionary_oracle_for_private_message_bytes() {
1326 let baseline = parse(&valid_value()).unwrap();
1327 let mut private_change = valid_value();
1328 private_change["messages"][0]["text"] = json!("x".repeat(TEXT_CANARY.len()));
1329 private_change["messages"][0]["expected_response"]["sha256"] = json!("1".repeat(64));
1330 let private_change = parse(&private_change).unwrap();
1331
1332 let baseline_message = baseline.messages().next().unwrap();
1333 let private_message = private_change.messages().next().unwrap();
1334 assert_eq!(
1335 baseline_message.message_reference(),
1336 private_message.message_reference()
1337 );
1338 assert_eq!(
1339 baseline_message.expected_response_reference(),
1340 private_message.expected_response_reference()
1341 );
1342 assert_eq!(
1343 baseline.policy_reference(),
1344 private_change.policy_reference()
1345 );
1346
1347 let mut revised = valid_value();
1348 revised["messages"][0]["id"] = json!("message-revision-2");
1349 let revised = parse(&revised).unwrap();
1350 let revised_message = revised.messages().next().unwrap();
1351 assert_ne!(
1352 baseline_message.message_reference(),
1353 revised_message.message_reference()
1354 );
1355 assert_ne!(
1356 baseline_message.expected_response_reference(),
1357 revised_message.expected_response_reference()
1358 );
1359 assert_ne!(baseline.policy_reference(), revised.policy_reference());
1360 }
1361
1362 #[test]
1363 fn duplicate_keys_are_rejected_at_every_depth_after_json_unescaping() {
1364 let root = br#"{"schema":"security.websocket-review-policy/v1","schema":"security.websocket-review-policy/v1"}"#;
1365 assert_eq!(
1366 WebSocketReviewPolicy::parse_json(&application(), root),
1367 Err(WebSocketReviewPolicyError::DuplicateJsonKey)
1368 );
1369 let nested = serde_json::to_string(&valid_value()).unwrap().replacen(
1370 &format!("\"sha256\":\"{DIGEST_CANARY}\""),
1371 &format!("\"sha256\":\"{DIGEST_CANARY}\",\"sha256\":\"{DIGEST_CANARY}\""),
1372 1,
1373 );
1374 assert_eq!(
1375 WebSocketReviewPolicy::parse_json(&application(), nested.as_bytes()),
1376 Err(WebSocketReviewPolicyError::DuplicateJsonKey)
1377 );
1378 let escaped = serde_json::to_string(&valid_value()).unwrap().replacen(
1379 "\"length\":19",
1380 "\"length\":19,\"len\\u0067th\":19",
1381 1,
1382 );
1383 assert_eq!(
1384 WebSocketReviewPolicy::parse_json(&application(), escaped.as_bytes()),
1385 Err(WebSocketReviewPolicyError::DuplicateJsonKey)
1386 );
1387 }
1388
1389 #[test]
1390 fn endpoint_is_absolute_exact_authority_and_segment_contained() {
1391 for endpoint in [
1392 "wss://other.test:8443/application/ws",
1393 "wss://example.test:9443/application/ws",
1394 "wss://example.test:8443/application-other/ws",
1395 "wss://example.test:8443",
1396 ] {
1397 let mut value = valid_value();
1398 value["endpoint"] = json!(endpoint);
1399 assert_eq!(
1400 parse(&value),
1401 Err(WebSocketReviewPolicyError::EndpointOutsideApplication),
1402 "{endpoint}"
1403 );
1404 }
1405 for endpoint in [
1406 "/application/ws",
1407 "https://example.test:8443/application/ws",
1408 "wss://user@example.test:8443/application/ws",
1409 "wss://example.test:8443/application/ws?secret=value",
1410 "wss://example.test:8443/application/ws#fragment",
1411 "wss://example.test:8443/application/%77s",
1412 "wss://example.test:8443/application/../application/ws",
1413 "wss://example.test:8443/application/./ws",
1414 "wss://example.test:8443/application//ws",
1415 "wss:\\\\example.test:8443\\application\\ws",
1416 "wss://example.test:8443/application/ ws",
1417 ] {
1418 let mut value = valid_value();
1419 value["endpoint"] = json!(endpoint);
1420 assert_eq!(
1421 parse(&value),
1422 Err(WebSocketReviewPolicyError::InvalidEndpoint),
1423 "{endpoint}"
1424 );
1425 }
1426 }
1427
1428 #[test]
1429 fn endpoint_scheme_root_scope_and_ipv6_loopback_are_explicit() {
1430 let mut value = valid_value();
1431 value["endpoint"] = json!("ws://example.test:8443/application/ws");
1432 assert_eq!(
1433 parse(&value),
1434 Err(WebSocketReviewPolicyError::InvalidEndpoint)
1435 );
1436
1437 let http_loopback = Url::parse("http://127.0.0.1:8080/application/").unwrap();
1438 value["endpoint"] = json!("wss://127.0.0.1:8080/application/ws");
1439 assert_eq!(
1440 WebSocketReviewPolicy::parse_json(&http_loopback, &serde_json::to_vec(&value).unwrap()),
1441 Err(WebSocketReviewPolicyError::InvalidEndpoint)
1442 );
1443
1444 let root = Url::parse("https://example.test:8443/").unwrap();
1445 value["endpoint"] = json!("wss://example.test:8443/socket");
1446 WebSocketReviewPolicy::parse_json(&root, &serde_json::to_vec(&value).unwrap()).unwrap();
1447
1448 let ipv6_loopback = Url::parse("http://[::1]:8080/application/").unwrap();
1449 value["endpoint"] = json!("ws://[::1]:8080/application/socket");
1450 WebSocketReviewPolicy::parse_json(&ipv6_loopback, &serde_json::to_vec(&value).unwrap())
1451 .unwrap();
1452 }
1453
1454 #[test]
1455 fn policy_binding_cannot_be_replayed_under_a_sibling_or_broader_application() {
1456 let policy = parse(&valid_value()).unwrap();
1457 assert!(policy.is_bound_to_application(&application()));
1458 assert!(!policy.is_bound_to_application(&Url::parse("https://example.test:8443/").unwrap()));
1459 assert!(!policy.is_bound_to_application(
1460 &Url::parse("https://example.test:8443/application-other/").unwrap()
1461 ));
1462 assert!(!policy
1463 .is_bound_to_application(&Url::parse("https://other.test:8443/application/").unwrap()));
1464 }
1465
1466 #[test]
1467 fn plaintext_requires_http_and_numeric_loopback_on_both_sides() {
1468 let mut value = valid_value();
1469 value["endpoint"] = json!("ws://example.test:8443/application/ws");
1470 let domain_application = Url::parse("http://example.test:8443/application/").unwrap();
1471 assert_eq!(
1472 WebSocketReviewPolicy::parse_json(
1473 &domain_application,
1474 &serde_json::to_vec(&value).unwrap()
1475 ),
1476 Err(WebSocketReviewPolicyError::InsecureEndpoint)
1477 );
1478
1479 let loopback = Url::parse("http://127.0.0.1:8080/application/").unwrap();
1480 value["endpoint"] = json!("ws://127.0.0.1:8080/application/ws");
1481 WebSocketReviewPolicy::parse_json(&loopback, &serde_json::to_vec(&value).unwrap()).unwrap();
1482
1483 let named_loopback = Url::parse("http://localhost:8080/application/").unwrap();
1484 value["endpoint"] = json!("ws://localhost:8080/application/ws");
1485 assert_eq!(
1486 WebSocketReviewPolicy::parse_json(
1487 &named_loopback,
1488 &serde_json::to_vec(&value).unwrap()
1489 ),
1490 Err(WebSocketReviewPolicyError::InsecureEndpoint)
1491 );
1492
1493 let non_loopback = Url::parse("http://192.0.2.1:8080/application/").unwrap();
1494 value["endpoint"] = json!("ws://192.0.2.1:8080/application/ws");
1495 assert_eq!(
1496 WebSocketReviewPolicy::parse_json(&non_loopback, &serde_json::to_vec(&value).unwrap()),
1497 Err(WebSocketReviewPolicyError::InsecureEndpoint)
1498 );
1499 }
1500
1501 #[test]
1502 fn assertions_modes_subprotocol_compression_and_reconnect_fail_closed() {
1503 for (field, replacement, expected) in [
1504 (
1505 "target_authorized",
1506 json!(false),
1507 WebSocketReviewPolicyError::TargetAuthorizationRequired,
1508 ),
1509 (
1510 "messages_read_only_acknowledged",
1511 json!(false),
1512 WebSocketReviewPolicyError::ReadOnlyAcknowledgementRequired,
1513 ),
1514 (
1515 "message_content_is_non_secret",
1516 json!(false),
1517 WebSocketReviewPolicyError::NonSecretContentAcknowledgementRequired,
1518 ),
1519 (
1520 "origin_mode",
1521 json!("reflect_server"),
1522 WebSocketReviewPolicyError::UnsupportedOriginMode,
1523 ),
1524 (
1525 "subprotocol",
1526 json!("one, two"),
1527 WebSocketReviewPolicyError::InvalidSubprotocol,
1528 ),
1529 (
1530 "compression",
1531 json!(true),
1532 WebSocketReviewPolicyError::CompressionNotPermitted,
1533 ),
1534 (
1535 "reconnect",
1536 json!(true),
1537 WebSocketReviewPolicyError::ReconnectNotPermitted,
1538 ),
1539 ] {
1540 let mut value = valid_value();
1541 value[field] = replacement;
1542 assert_eq!(parse(&value), Err(expected), "{field}");
1543 }
1544 let mut omitted = valid_value();
1545 omitted["origin_mode"] = json!("omit");
1546 omitted["subprotocol"] = Value::Null;
1547 let policy = parse(&omitted).unwrap();
1548 assert_eq!(policy.origin_mode(), WebSocketOriginMode::Omit);
1549 assert_eq!(policy.execution_origin(), None);
1550 assert_eq!(policy.execution_subprotocol(), None);
1551 }
1552
1553 #[test]
1554 fn message_count_identity_and_byte_bounds_are_exact() {
1555 let mut empty = valid_value();
1556 empty["messages"] = json!([]);
1557 assert_eq!(
1558 parse(&empty),
1559 Err(WebSocketReviewPolicyError::InvalidMessageCount)
1560 );
1561
1562 let message = valid_value()["messages"][0].clone();
1563 let mut nine = valid_value();
1564 nine["messages"] = Value::Array(vec![message.clone(); 9]);
1565 assert_eq!(
1566 parse(&nine),
1567 Err(WebSocketReviewPolicyError::InvalidMessageCount)
1568 );
1569
1570 let mut duplicate = valid_value();
1571 duplicate["messages"] = Value::Array(vec![message.clone(), message.clone()]);
1572 duplicate["limits"]["max_messages"] = json!(2);
1573 assert_eq!(
1574 parse(&duplicate),
1575 Err(WebSocketReviewPolicyError::DuplicateMessageId)
1576 );
1577
1578 let mut unsafe_id = valid_value();
1579 unsafe_id["messages"][0]["id"] = json!("../unsafe");
1580 assert_eq!(
1581 parse(&unsafe_id),
1582 Err(WebSocketReviewPolicyError::InvalidMessageId)
1583 );
1584
1585 let mut oversized = valid_value();
1586 oversized["messages"][0]["text"] =
1587 json!("x".repeat(MAX_WEBSOCKET_REVIEW_MESSAGE_TEXT_BYTES + 1));
1588 assert_eq!(
1589 parse(&oversized),
1590 Err(WebSocketReviewPolicyError::MessageTextTooLarge)
1591 );
1592
1593 let mut aggregate = valid_value();
1594 let mut messages = Vec::new();
1595 for index in 0..5 {
1596 let mut current = message.clone();
1597 current["id"] = json!(format!("message-{index}"));
1598 current["text"] = json!("x".repeat(MAX_WEBSOCKET_REVIEW_MESSAGE_TEXT_BYTES));
1599 messages.push(current);
1600 }
1601 aggregate["messages"] = Value::Array(messages);
1602 aggregate["limits"]["max_messages"] = json!(5);
1603 aggregate["limits"]["max_outbound_message_bytes"] =
1604 json!(MAX_WEBSOCKET_REVIEW_MESSAGE_TEXT_BYTES);
1605 assert_eq!(
1606 parse(&aggregate),
1607 Err(WebSocketReviewPolicyError::MessageTextAggregateTooLarge)
1608 );
1609 }
1610
1611 #[test]
1612 fn expected_responses_and_runtime_limits_are_bounded() {
1613 for digest in ["a".repeat(63), "A".repeat(64), "g".repeat(64)] {
1614 let mut value = valid_value();
1615 value["messages"][0]["expected_response"]["sha256"] = json!(digest);
1616 assert_eq!(
1617 parse(&value),
1618 Err(WebSocketReviewPolicyError::InvalidExpectedResponse)
1619 );
1620 }
1621 for (field, invalid) in [
1622 ("max_inbound_message_bytes", 0_u64),
1623 (
1624 "max_inbound_message_bytes",
1625 MAX_WEBSOCKET_REVIEW_DIRECTION_BYTES + 1,
1626 ),
1627 ("max_outbound_message_bytes", 0),
1628 ("max_messages", 0),
1629 (
1630 "max_messages",
1631 u64::try_from(MAX_WEBSOCKET_REVIEW_MESSAGES).unwrap() + 1,
1632 ),
1633 ("max_control_frames", 0),
1634 (
1635 "max_control_frames",
1636 u64::from(MAX_WEBSOCKET_REVIEW_CONTROL_FRAMES) + 1,
1637 ),
1638 ("max_wall_time_ms", 0),
1639 ("max_wall_time_ms", MAX_WEBSOCKET_REVIEW_WALL_TIME_MS + 1),
1640 ] {
1641 let mut value = valid_value();
1642 value["limits"][field] = json!(invalid);
1643 assert_eq!(
1644 parse(&value),
1645 Err(WebSocketReviewPolicyError::InvalidLimits),
1646 "{field}={invalid}"
1647 );
1648 }
1649
1650 let mut too_short = valid_value();
1651 too_short["limits"]["max_outbound_message_bytes"] = json!(TEXT_CANARY.len() - 1);
1652 assert_eq!(
1653 parse(&too_short),
1654 Err(WebSocketReviewPolicyError::InvalidLimits)
1655 );
1656
1657 let mut inbound_aggregate = valid_value();
1658 let mut messages = Vec::new();
1659 for index in 0..2 {
1660 let mut current = valid_value()["messages"][0].clone();
1661 current["id"] = json!(format!("expected-{index}"));
1662 current["expected_response"]["length"] = json!(40_000);
1663 messages.push(current);
1664 }
1665 inbound_aggregate["messages"] = Value::Array(messages);
1666 inbound_aggregate["limits"]["max_messages"] = json!(2);
1667 inbound_aggregate["limits"]["max_inbound_message_bytes"] = json!(40_000);
1668 assert_eq!(
1669 parse(&inbound_aggregate),
1670 Err(WebSocketReviewPolicyError::ExpectedResponseAggregateTooLarge)
1671 );
1672 }
1673
1674 #[test]
1675 fn malformed_unknown_oversized_and_invalid_application_inputs_are_static_errors() {
1676 assert_eq!(
1677 WebSocketReviewPolicy::parse_json(&application(), b""),
1678 Err(WebSocketReviewPolicyError::EmptyPolicy)
1679 );
1680 assert_eq!(
1681 WebSocketReviewPolicy::parse_json(
1682 &application(),
1683 &vec![b' '; MAX_WEBSOCKET_REVIEW_POLICY_BYTES + 1]
1684 ),
1685 Err(WebSocketReviewPolicyError::PolicyTooLarge)
1686 );
1687 assert_eq!(
1688 WebSocketReviewPolicy::parse_json(&application(), b"not-json"),
1689 Err(WebSocketReviewPolicyError::MalformedPolicy)
1690 );
1691 let mut unsupported_schema = valid_value();
1692 unsupported_schema["schema"] = json!("security.websocket-review-policy/v2");
1693 assert_eq!(
1694 parse(&unsupported_schema),
1695 Err(WebSocketReviewPolicyError::UnsupportedSchema)
1696 );
1697 let mut unknown = valid_value();
1698 unknown["credential"] = json!("secret");
1699 assert_eq!(
1700 parse(&unknown),
1701 Err(WebSocketReviewPolicyError::MalformedPolicy)
1702 );
1703 let invalid_application = Url::parse("https://example.test/application/?query=1").unwrap();
1704 assert_eq!(
1705 WebSocketReviewPolicy::parse_json(
1706 &invalid_application,
1707 &serde_json::to_vec(&valid_value()).unwrap()
1708 ),
1709 Err(WebSocketReviewPolicyError::InvalidApplicationTarget)
1710 );
1711 }
1712}