diff --git a/CHANGELOG.md b/CHANGELOG.md index fa1487c17..8afb9ec7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Deterministic WebDriver BiDi `browsingContext.locateNodes` command serialization that accepts only protocol-range `js-uint` identifiers and bounded injection-safe browsing-context identifiers, JSON-escapes reviewed query text, and emits the exact accessibility locator, finite node budget, and minimal serialization options without performing transport I/O or granting browser or Agent authority. - Fail-closed WebDriver BiDi `locateNodes` response correlation that consumes the exact serialized command, rejects out-of-range or mismatched response `id` values, and returns non-cloneable correlation evidence carrying only the matched command identifier and browsing context without parsing the response or granting browser/Agent authority. - Typed WebDriver BiDi response-envelope correlation that preserves success/error classification, rejects success responses with absent ids, treats nullable error ids as explicitly uncorrelatable, and prevents correlated error envelopes from becoming success correlation evidence without raw JSON parsing or browser/Agent authority grants. +- Bounded raw WebDriver BiDi response-document admission before JSON parsing, with a 65,536-byte product safety budget, exact wire-text retention, JSON-whitespace-aware top-level object-boundary checks, and typed fail-closed errors; this coarse boundary deliberately does not claim JSON validity, response correlation, browser authenticity, or Agent authority. - Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, rejecting control and whitespace so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. - Same-call `locateNodes` result admission that revalidates the exact current session, browsing context, canonical origin, and document epoch, rejects an over-budget or non-node result, and translates each admitted `sharedId` through the authority registry into an `ObservedNodeHandle` without performing browser I/O. - Same-call QueryNodes admission that transfers a non-cloneable SemanticObservation protocol-use proof by ownership into `bind_current_nodes` before an untrusted `locateNodes` result can become current `ObservedNodeHandle` values, so Navigation-only or TypedInput-only proofs cannot mint observation handles. diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 654d40863..fb43a48b9 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -32,6 +32,7 @@ mod browser_registry; mod browser_registry_coverage; mod contracts; mod webdriver_bidi_command; +mod webdriver_bidi_response_document; mod webdriver_bidi_result; pub use browser_authority_registry::BrowserAuthorityRegistry; @@ -70,6 +71,10 @@ pub use webdriver_bidi_command::{ WebDriverBiDiLocateNodesResponseCorrelationError, WebDriverBiDiLocateNodesResponseEnvelopeError, }; +pub use webdriver_bidi_response_document::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + WebDriverBiDiResponseDocumentAdmissionError, +}; pub use webdriver_bidi_result::{ ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, }; diff --git a/crates/originweave-core/src/webdriver_bidi_response_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document.rs new file mode 100644 index 000000000..4eacf84cb --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document.rs @@ -0,0 +1,76 @@ +use std::fmt; + +/// Maximum raw WebDriver BiDi response-document size admitted before parsing. +/// +/// This is an OriginWeave product safety budget, not a WebDriver BiDi protocol +/// limit. Browser adapters must enforce it before handing raw response text to a +/// JSON parser so an untrusted or malfunctioning peer cannot cause unbounded +/// parser input allocation. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES: usize = 65_536; + +/// Fail-closed reasons for rejecting a raw WebDriver BiDi response document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiResponseDocumentAdmissionError { + /// The response contains no JSON document after removing JSON whitespace. + EmptyDocument, + /// The raw response exceeds the OriginWeave pre-parser byte budget. + DocumentTooLarge, + /// The first and last non-whitespace bytes do not delimit a JSON object. + InvalidObjectBoundary, +} + +impl fmt::Display for WebDriverBiDiResponseDocumentAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyDocument => formatter.write_str("WebDriver BiDi response document is empty"), + Self::DocumentTooLarge => write!( + formatter, + "WebDriver BiDi response document exceeds {MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES} bytes" + ), + Self::InvalidObjectBoundary => formatter.write_str( + "WebDriver BiDi response document must have a top-level JSON object boundary", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiResponseDocumentAdmissionError {} + +/// Exact raw WebDriver BiDi response text admitted to the parser boundary. +/// +/// Construction proves only the OriginWeave byte budget and an obvious +/// top-level object boundary. It deliberately does not claim JSON validity, +/// response correlation, browser authenticity, or action authority. The exact +/// text is retained so downstream parsing/evidence can remain bound to the +/// admitted bytes. +#[derive(Debug, PartialEq, Eq)] +pub struct BoundedWebDriverBiDiResponseDocument { + raw: String, +} + +impl BoundedWebDriverBiDiResponseDocument { + /// Admits exact raw response text under the pre-parser safety contract. + pub fn new(raw: &str) -> Result { + if raw.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES { + return Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge); + } + + let bounded = raw.trim_matches(|character| matches!(character, ' ' | '\t' | '\r' | '\n')); + if bounded.is_empty() { + return Err(WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument); + } + if !bounded.starts_with('{') || !bounded.ends_with('}') { + return Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary); + } + + Ok(Self { + raw: raw.to_owned(), + }) + } + + /// Returns the exact admitted response text, including surrounding JSON whitespace. + #[must_use] + pub fn as_str(&self) -> &str { + &self.raw + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs new file mode 100644 index 000000000..7909c4f28 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs @@ -0,0 +1,75 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + WebDriverBiDiResponseDocumentAdmissionError, +}; + +#[test] +fn bounded_response_document_retains_exact_wire_text() -> Result<(), Box> { + let raw = " \r\n{\"id\":42,\"type\":\"success\",\"result\":{}}\t"; + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + + assert_eq!(document.as_str(), raw); + Ok(()) +} + +#[test] +fn empty_or_json_whitespace_only_response_document_fails_closed() { + for raw in ["", " ", "\t\r\n"] { + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(raw), + Err(WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument) + ); + } +} + +#[test] +fn response_document_requires_an_object_boundary_without_claiming_json_validation() +-> Result<(), Box> { + for raw in ["[]", "null", "{", "}", "\u{00a0}{}\u{00a0}"] { + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(raw), + Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary) + ); + } + + let coarse_only = BoundedWebDriverBiDiResponseDocument::new("{not-json}")?; + assert_eq!(coarse_only.as_str(), "{not-json}"); + Ok(()) +} + +#[test] +fn response_document_budget_accepts_exact_limit_and_rejects_one_more_byte() +-> Result<(), Box> { + const OBJECT_OVERHEAD_BYTES: usize = 8; + let exact = format!( + "{{\"x\":\"{}\"}}", + "a".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - OBJECT_OVERHEAD_BYTES) + ); + assert_eq!(exact.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); + assert!(BoundedWebDriverBiDiResponseDocument::new(&exact).is_ok()); + + let oversized = format!("{exact} "); + assert_eq!( + oversized.len(), + MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1 + ); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&oversized), + Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge) + ); + Ok(()) +} + +#[test] +fn response_document_errors_are_deterministic_and_source_free() { + for error in [ + WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument, + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge, + WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +}