fix(protocol): harden gateway contract validation
This commit is contained in:
@@ -20,6 +20,7 @@ source-verify:
|
|||||||
$(PYTHON) -B tools/fixture_digest.py
|
$(PYTHON) -B tools/fixture_digest.py
|
||||||
|
|
||||||
scope-verify:
|
scope-verify:
|
||||||
|
$(PYTHON) -B tools/test_check_scope.py
|
||||||
$(PYTHON) -B tools/check_scope.py
|
$(PYTHON) -B tools/check_scope.py
|
||||||
|
|
||||||
go-test:
|
go-test:
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
id version kind input expected
|
id version kind input expected
|
||||||
valid-client-to-provider 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop valid
|
valid-client-to-provider 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop valid
|
||||||
valid-provider-to-client 1 gateway_clipboard direction=provider_to_client;text=host%20text;encoding=utf-8;loop_token=qrstuvwxyzABCDEF valid
|
valid-provider-to-client 1 gateway_clipboard direction=provider_to_client;text=host%20text;encoding=utf-8;loop_token=qrstuvwxyzABCDEF valid
|
||||||
|
valid-token-alphabet 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid
|
||||||
|
valid-token-max 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid
|
||||||
invalid-direction 1 gateway_clipboard direction=bidirectional;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop invalid:clipboard
|
invalid-direction 1 gateway_clipboard direction=bidirectional;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop invalid:clipboard
|
||||||
invalid-token 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=short invalid:clipboard
|
invalid-token 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=short invalid:clipboard
|
||||||
|
invalid-token-15 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmno invalid:clipboard
|
||||||
|
invalid-token-129 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_A invalid:clipboard
|
||||||
|
invalid-token-character 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=!!!!!!!!!!!!!!!! invalid:clipboard
|
||||||
|
invalid-token-trailing-bits 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=AAAAAAAAAAAAAAAAAB invalid:clipboard
|
||||||
invalid-file 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop;file=file.txt invalid:forbidden
|
invalid-file 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop;file=file.txt invalid:forbidden
|
||||||
|
|||||||
|
@@ -9,5 +9,5 @@
|
|||||||
"fixtures/conformance/gateway-input-feedback-v1.tsv",
|
"fixtures/conformance/gateway-input-feedback-v1.tsv",
|
||||||
"fixtures/conformance/tunnel-v1.tsv"
|
"fixtures/conformance/tunnel-v1.tsv"
|
||||||
],
|
],
|
||||||
"corpus_sha256": "92c84440bbcc08703e5465584a400b0463fea77559d41262855e778e7c3284cf"
|
"corpus_sha256": "69d5b12a533ff0d9786784b99aecc8a74a7ec2c6855b75c52e46ecff5bd3e6c5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ flow. Its UTF-8 JSON payload is a `GatewayClipboardText` object:
|
|||||||
`direction` is exact: the client may send only `client_to_provider`, and the
|
`direction` is exact: the client may send only `client_to_provider`, and the
|
||||||
gateway may send only `provider_to_client`. The text contains no file name,
|
gateway may send only `provider_to_client`. The text contains no file name,
|
||||||
URL, binary value, or client-folder field and is at most the Server-owned
|
URL, binary value, or client-folder field and is at most the Server-owned
|
||||||
`clipboard_policy.max_text_bytes` value. `loop_token` is a 16--128 ASCII
|
`clipboard_policy.max_text_bytes` value. `loop_token` is a 16--128 character
|
||||||
base64url-character token generated by the originating endpoint. An endpoint MUST retain
|
canonical unpadded ASCII base64url token generated by the originating endpoint. An endpoint MUST retain
|
||||||
recent token/value pairs only for the bounded policy window and MUST suppress a
|
recent token/value pairs only for the bounded policy window and MUST suppress a
|
||||||
matching reflected value; a mismatched, malformed, expired, or replayed token
|
matching reflected value; a mismatched, malformed, expired, or replayed token
|
||||||
is rejected without clipboard mutation.
|
is rejected without clipboard mutation.
|
||||||
@@ -21,8 +21,8 @@ direction, a rate above `max_updates_per_minute`, invalid UTF-8, an oversized
|
|||||||
payload, or an unknown field fails closed. Clipboard bytes are never emitted to
|
payload, or an unknown field fails closed. Clipboard bytes are never emitted to
|
||||||
provider-state, audit, telemetry, or error payloads.
|
provider-state, audit, telemetry, or error payloads.
|
||||||
|
|
||||||
For every accepted, loop-suppressed, or policy/rate/provider/malformed rejection,
|
For every successfully delivered, loop-suppressed, or policy/rate/provider/malformed
|
||||||
the gateway sends an mTLS control-plane `GatewayClipboardAudit` record. It contains
|
rejection, the gateway sends an mTLS control-plane `GatewayClipboardAudit` record. It contains
|
||||||
only the session identifier, direction, bounded text-byte count, outcome, and a
|
only the session identifier, direction, bounded text-byte count, outcome, and a
|
||||||
fixed reason code; it contains neither text nor loop token. The Server persists it
|
fixed reason code; it contains neither text nor loop token. The Server persists it
|
||||||
against the broker session using the authenticated gateway identity.
|
against the broker session using the authenticated gateway identity.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package protocol
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SchemaSHA256 = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17"
|
const SchemaSHA256 = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
|
||||||
const ProtocolVersion = "1.0.0"
|
const ProtocolVersion = "1.0.0"
|
||||||
const CurrentWireVersion = "1"
|
const CurrentWireVersion = "1"
|
||||||
const NMinus1WireVersion = "0"
|
const NMinus1WireVersion = "0"
|
||||||
@@ -968,6 +969,9 @@ func (v ChannelFrame) Validate() error {
|
|||||||
if len(v.Payload) > 87384 {
|
if len(v.Payload) > 87384 {
|
||||||
violations = append(violations, FieldViolation{Field: "payload", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "payload", Code: "max_length"})
|
||||||
}
|
}
|
||||||
|
if len(v.Payload) > 65536 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "payload", Code: "max_bytes"})
|
||||||
|
}
|
||||||
if v.FragmentIndex >= v.FragmentCount {
|
if v.FragmentIndex >= v.FragmentCount {
|
||||||
violations = append(violations, FieldViolation{Field: "fragment_index", Code: "invalid_order"})
|
violations = append(violations, FieldViolation{Field: "fragment_index", Code: "invalid_order"})
|
||||||
}
|
}
|
||||||
@@ -1010,9 +1014,6 @@ func DecodeChannelFrame(data []byte) (ChannelFrame, error) {
|
|||||||
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
|
||||||
}
|
}
|
||||||
if raw, ok := fields["payload"]; ok && len(raw) > 65536 {
|
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "payload", Code: "max_bytes"}}}
|
|
||||||
}
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(&value); err != nil {
|
if err := decoder.Decode(&value); err != nil {
|
||||||
@@ -2144,6 +2145,9 @@ func (v GatewayClipboardText) Validate() error {
|
|||||||
if len(v.Text) > 65536 {
|
if len(v.Text) > 65536 {
|
||||||
violations = append(violations, FieldViolation{Field: "text", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "text", Code: "max_length"})
|
||||||
}
|
}
|
||||||
|
if len(v.Text) > 65536 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "text", Code: "max_bytes"})
|
||||||
|
}
|
||||||
if v.Encoding == "" {
|
if v.Encoding == "" {
|
||||||
violations = append(violations, FieldViolation{Field: "encoding", Code: "required"})
|
violations = append(violations, FieldViolation{Field: "encoding", Code: "required"})
|
||||||
}
|
}
|
||||||
@@ -2159,6 +2163,11 @@ func (v GatewayClipboardText) Validate() error {
|
|||||||
if len(v.LoopToken) > 128 {
|
if len(v.LoopToken) > 128 {
|
||||||
violations = append(violations, FieldViolation{Field: "loop_token", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "loop_token", Code: "max_length"})
|
||||||
}
|
}
|
||||||
|
if v.LoopToken != "" {
|
||||||
|
if _, err := base64.RawURLEncoding.Strict().DecodeString(v.LoopToken); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "loop_token", Code: "invalid_format"})
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -2186,9 +2195,6 @@ func DecodeGatewayClipboardText(data []byte) (GatewayClipboardText, error) {
|
|||||||
if raw, ok := fields["text"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["text"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "text", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "text", Code: "required"}}}
|
||||||
}
|
}
|
||||||
if raw, ok := fields["text"]; ok && len(raw) > 65536 {
|
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "text", Code: "max_bytes"}}}
|
|
||||||
}
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(&value); err != nil {
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@
|
|||||||
"2"
|
"2"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generator_sha256": "e9c6ee1541585fcb00dcc5e94a5a6d93dbe3a719a5c545f31e5eda268f2638ab",
|
"generator_sha256": "922983e07a8ecc559771778fbf139155b14664742d9873be062880102777dccb",
|
||||||
"protocol_version": "1.0.0",
|
"protocol_version": "1.0.0",
|
||||||
"schema_sha256": "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17"
|
"schema_sha256": "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by tools/generate.py; DO NOT EDIT.
|
// Code generated by tools/generate.py; DO NOT EDIT.
|
||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
pub const SCHEMA_SHA256: &str = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17";
|
pub const SCHEMA_SHA256: &str = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8";
|
||||||
pub const CURRENT_WIRE_VERSION: &str = "1";
|
pub const CURRENT_WIRE_VERSION: &str = "1";
|
||||||
pub const N_MINUS_1_WIRE_VERSION: &str = "0";
|
pub const N_MINUS_1_WIRE_VERSION: &str = "0";
|
||||||
pub const N_MINUS_2_WIRE_VERSION: &str = "-1";
|
pub const N_MINUS_2_WIRE_VERSION: &str = "-1";
|
||||||
@@ -10,6 +10,27 @@ pub type JsonObject = std::collections::BTreeMap<String, String>;
|
|||||||
pub struct ValidationError { pub field: &'static str, pub code: &'static str }
|
pub struct ValidationError { pub field: &'static str, pub code: &'static str }
|
||||||
impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }
|
impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }
|
||||||
|
|
||||||
|
fn base64url_value(value: u8) -> Option<u8> {
|
||||||
|
match value {
|
||||||
|
b'A'..=b'Z' => Some(value - b'A'),
|
||||||
|
b'a'..=b'z' => Some(value - b'a' + 26),
|
||||||
|
b'0'..=b'9' => Some(value - b'0' + 52),
|
||||||
|
b'-' => Some(62),
|
||||||
|
b'_' => Some(63),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn valid_base64_url(value: &str) -> bool {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) { return false; }
|
||||||
|
match bytes.len() % 4 {
|
||||||
|
0 => true,
|
||||||
|
2 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x0f == 0,
|
||||||
|
3 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x03 == 0,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AllocationPolicy {
|
pub struct AllocationPolicy {
|
||||||
minimumKbps: i64,
|
minimumKbps: i64,
|
||||||
@@ -259,6 +280,7 @@ impl ChannelFrame {
|
|||||||
if self.fragmentCount > 16 { return Err(ValidationError::new("fragment_count", "maximum")); }
|
if self.fragmentCount > 16 { return Err(ValidationError::new("fragment_count", "maximum")); }
|
||||||
if self.timestampMs < 0 { return Err(ValidationError::new("timestamp_ms", "minimum")); }
|
if self.timestampMs < 0 { return Err(ValidationError::new("timestamp_ms", "minimum")); }
|
||||||
if self.payload.len() > 87384 { return Err(ValidationError::new("payload", "max_length")); }
|
if self.payload.len() > 87384 { return Err(ValidationError::new("payload", "max_length")); }
|
||||||
|
if self.payload.as_bytes().len() > 65536 { return Err(ValidationError::new("payload", "max_bytes")); }
|
||||||
if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new("fragment_index", "invalid_order")); }
|
if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new("fragment_index", "invalid_order")); }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -692,10 +714,12 @@ impl GatewayClipboardText {
|
|||||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
if self.direction != "client_to_provider" && self.direction != "provider_to_client" { return Err(ValidationError::new("direction", "invalid_value")); }
|
if self.direction != "client_to_provider" && self.direction != "provider_to_client" { return Err(ValidationError::new("direction", "invalid_value")); }
|
||||||
if self.text.len() > 65536 { return Err(ValidationError::new("text", "max_length")); }
|
if self.text.len() > 65536 { return Err(ValidationError::new("text", "max_length")); }
|
||||||
|
if self.text.as_bytes().len() > 65536 { return Err(ValidationError::new("text", "max_bytes")); }
|
||||||
if self.encoding != "utf-8" { return Err(ValidationError::new("encoding", "invalid_value")); }
|
if self.encoding != "utf-8" { return Err(ValidationError::new("encoding", "invalid_value")); }
|
||||||
if self.loopToken.is_empty() { return Err(ValidationError::new("loop_token", "required")); }
|
if self.loopToken.is_empty() { return Err(ValidationError::new("loop_token", "required")); }
|
||||||
if !self.loopToken.is_empty() && self.loopToken.len() < 16 { return Err(ValidationError::new("loop_token", "min_length")); }
|
if !self.loopToken.is_empty() && self.loopToken.len() < 16 { return Err(ValidationError::new("loop_token", "min_length")); }
|
||||||
if self.loopToken.len() > 128 { return Err(ValidationError::new("loop_token", "max_length")); }
|
if self.loopToken.len() > 128 { return Err(ValidationError::new("loop_token", "max_length")); }
|
||||||
|
if !valid_base64_url(self.loopToken.as_str()) { return Err(ValidationError::new("loop_token", "invalid_format")); }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn direction(&self) -> &String { &self.direction }
|
pub fn direction(&self) -> &String { &self.direction }
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
// Code generated by tools/generate.py; DO NOT EDIT.
|
// Code generated by tools/generate.py; DO NOT EDIT.
|
||||||
import Foundation
|
import Foundation
|
||||||
public typealias JSONObject = [String: String]
|
public typealias JSONObject = [String: String]
|
||||||
public let schemaSHA256 = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17"
|
public let schemaSHA256 = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
|
||||||
public let currentWireVersion = "1"
|
public let currentWireVersion = "1"
|
||||||
public let nMinus1WireVersion = "0"
|
public let nMinus1WireVersion = "0"
|
||||||
public let nMinus2WireVersion = "-1"
|
public let nMinus2WireVersion = "-1"
|
||||||
public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }
|
public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }
|
||||||
private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }
|
private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }
|
||||||
|
private func validBase64URL(_ value: String) -> Bool {
|
||||||
|
guard !value.isEmpty, value.utf8.allSatisfy({ byte in
|
||||||
|
(byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95
|
||||||
|
}) else { return false }
|
||||||
|
let padding = String(repeating: "=", count: (4 - value.utf8.count % 4) % 4)
|
||||||
|
let standard = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + padding
|
||||||
|
guard let decoded = Data(base64Encoded: standard) else { return false }
|
||||||
|
return decoded.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") == value
|
||||||
|
}
|
||||||
|
|
||||||
public struct AllocationPolicy: Codable, Equatable {
|
public struct AllocationPolicy: Codable, Equatable {
|
||||||
public let minimumKbps: Int64
|
public let minimumKbps: Int64
|
||||||
@@ -343,6 +352,7 @@ public struct ChannelFrame: Codable, Equatable {
|
|||||||
if self.fragmentCount > 16 { throw ContractValidationError(field: "fragment_count", code: "maximum") }
|
if self.fragmentCount > 16 { throw ContractValidationError(field: "fragment_count", code: "maximum") }
|
||||||
if self.timestampMs < 0 { throw ContractValidationError(field: "timestamp_ms", code: "minimum") }
|
if self.timestampMs < 0 { throw ContractValidationError(field: "timestamp_ms", code: "minimum") }
|
||||||
if self.payload.utf8.count > 87384 { throw ContractValidationError(field: "payload", code: "max_length") }
|
if self.payload.utf8.count > 87384 { throw ContractValidationError(field: "payload", code: "max_length") }
|
||||||
|
if self.payload.utf8.count > 65536 { throw ContractValidationError(field: "payload", code: "max_bytes") }
|
||||||
if fragmentIndex >= fragmentCount { throw ContractValidationError(field: "fragment_index", code: "invalid_order") }
|
if fragmentIndex >= fragmentCount { throw ContractValidationError(field: "fragment_index", code: "invalid_order") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,10 +936,12 @@ public struct GatewayClipboardText: Codable, Equatable {
|
|||||||
public func validate() throws {
|
public func validate() throws {
|
||||||
if !["client_to_provider", "provider_to_client"].contains(self.direction) { throw ContractValidationError(field: "direction", code: "invalid_value") }
|
if !["client_to_provider", "provider_to_client"].contains(self.direction) { throw ContractValidationError(field: "direction", code: "invalid_value") }
|
||||||
if self.text.utf8.count > 65536 { throw ContractValidationError(field: "text", code: "max_length") }
|
if self.text.utf8.count > 65536 { throw ContractValidationError(field: "text", code: "max_length") }
|
||||||
|
if self.text.utf8.count > 65536 { throw ContractValidationError(field: "text", code: "max_bytes") }
|
||||||
if self.encoding != "utf-8" { throw ContractValidationError(field: "encoding", code: "invalid_value") }
|
if self.encoding != "utf-8" { throw ContractValidationError(field: "encoding", code: "invalid_value") }
|
||||||
if self.loopToken.isEmpty { throw ContractValidationError(field: "loop_token", code: "required") }
|
if self.loopToken.isEmpty { throw ContractValidationError(field: "loop_token", code: "required") }
|
||||||
if !self.loopToken.isEmpty && self.loopToken.utf8.count < 16 { throw ContractValidationError(field: "loop_token", code: "min_length") }
|
if !self.loopToken.isEmpty && self.loopToken.utf8.count < 16 { throw ContractValidationError(field: "loop_token", code: "min_length") }
|
||||||
if self.loopToken.utf8.count > 128 { throw ContractValidationError(field: "loop_token", code: "max_length") }
|
if self.loopToken.utf8.count > 128 { throw ContractValidationError(field: "loop_token", code: "max_length") }
|
||||||
|
if !validBase64URL(self.loopToken) { throw ContractValidationError(field: "loop_token", code: "invalid_format") }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ an implementation-specific release-all provider command.
|
|||||||
packets reliably before starting provider disconnect.
|
packets reliably before starting provider disconnect.
|
||||||
|
|
||||||
### Requirement: Bounded provider feedback control envelope
|
### Requirement: Bounded provider feedback control envelope
|
||||||
The bidirectional reliable control flow SHALL define an ASCII `VGF1` envelope
|
The registered bidirectional reliable `control.ack.v1` flow SHALL define an ASCII `VGF1` envelope
|
||||||
with a direction byte, type byte, big-endian payload length, and exact payload
|
with a direction byte, type byte, big-endian payload length, and exact payload
|
||||||
bytes. Only host termination, rumble, and HDR feedback SHALL be valid from the
|
bytes. Only host termination, rumble, and HDR feedback SHALL be valid from the
|
||||||
gateway to the client; only IDR and FEC/loss feedback SHALL be valid from the
|
gateway to the client; only IDR and FEC/loss feedback SHALL be valid from the
|
||||||
@@ -53,7 +53,8 @@ certificate, credential, or opaque provider packet.
|
|||||||
|
|
||||||
### Requirement: Policy-bound text clipboard envelope
|
### Requirement: Policy-bound text clipboard envelope
|
||||||
The reliable `clipboard.text.v1` flow SHALL carry only a typed UTF-8 text
|
The reliable `clipboard.text.v1` flow SHALL carry only a typed UTF-8 text
|
||||||
envelope with exact direction and a bounded loop token. The Server SHALL mint
|
envelope with exact direction and a 16--128 character canonical unpadded ASCII
|
||||||
|
base64url loop token. The Server SHALL mint
|
||||||
the enabled directions, maximum text bytes, and maximum updates per minute in
|
the enabled directions, maximum text bytes, and maximum updates per minute in
|
||||||
authenticated provider work. The gateway SHALL reject disabled direction,
|
authenticated provider work. The gateway SHALL reject disabled direction,
|
||||||
unknown fields, files, file URLs, client folders, binary data, malformed UTF-8,
|
unknown fields, files, file URLs, client folders, binary data, malformed UTF-8,
|
||||||
@@ -62,11 +63,15 @@ SHALL not put clipboard content, provider routes, or credentials in telemetry,
|
|||||||
audit, state, or errors.
|
audit, state, or errors.
|
||||||
|
|
||||||
#### Scenario: Clipboard audit metadata
|
#### Scenario: Clipboard audit metadata
|
||||||
- **WHEN** the gateway accepts, suppresses, or rejects a clipboard update
|
- **WHEN** the gateway successfully delivers, suppresses, or rejects a clipboard update
|
||||||
- **THEN** it sends an authenticated Server audit record with only direction,
|
- **THEN** it sends an authenticated Server audit record with only direction,
|
||||||
bounded byte count, outcome, and a fixed reason; it never includes text or
|
bounded byte count, outcome, and a fixed reason; it never includes text or
|
||||||
the loop token.
|
the loop token.
|
||||||
|
|
||||||
|
#### Scenario: Clipboard delivery failure
|
||||||
|
- **WHEN** provider-to-client control delivery fails
|
||||||
|
- **THEN** the gateway does not report the update as forwarded.
|
||||||
|
|
||||||
#### Scenario: Reflected clipboard value
|
#### Scenario: Reflected clipboard value
|
||||||
- **WHEN** a client-originated text value returns from the provider with the
|
- **WHEN** a client-originated text value returns from the provider with the
|
||||||
matching retained token/value pair
|
matching retained token/value pair
|
||||||
|
|||||||
@@ -354,7 +354,7 @@
|
|||||||
"direction": {"type": "string", "enum": ["client_to_provider", "provider_to_client"]},
|
"direction": {"type": "string", "enum": ["client_to_provider", "provider_to_client"]},
|
||||||
"text": {"type": "string", "maxLength": 65536, "x-max-bytes": 65536},
|
"text": {"type": "string", "maxLength": 65536, "x-max-bytes": 65536},
|
||||||
"encoding": {"type": "string", "const": "utf-8"},
|
"encoding": {"type": "string", "const": "utf-8"},
|
||||||
"loop_token": {"type": "string", "minLength": 16, "maxLength": 128}
|
"loop_token": {"type": "string", "format": "base64url", "minLength": 16, "maxLength": 128}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"VersionNegotiation": {
|
"VersionNegotiation": {
|
||||||
|
|||||||
@@ -141,3 +141,37 @@ func TestGatewayClipboardAuditIsMetadataOnlyAndStrict(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGatewayClipboardTextMeasuresDecodedUTF8Bytes(t *testing.T) {
|
||||||
|
for name, text := range map[string]string{
|
||||||
|
"ascii-boundary": strings.Repeat("a", 65536),
|
||||||
|
"utf8-boundary": strings.Repeat("é", 32768),
|
||||||
|
"escape-heavy": strings.Repeat(`"`, 32768),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
value := protocol.GatewayClipboardText{
|
||||||
|
Direction: "client_to_provider",
|
||||||
|
Text: text,
|
||||||
|
Encoding: "utf-8",
|
||||||
|
LoopToken: "abcdefghijklmnop",
|
||||||
|
}
|
||||||
|
encoded, err := protocol.EncodeGatewayClipboardText(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeGatewayClipboardText() error = %v", err)
|
||||||
|
}
|
||||||
|
decoded, err := protocol.DecodeGatewayClipboardText(encoded)
|
||||||
|
if err != nil || decoded.Text != text {
|
||||||
|
t.Fatalf("DecodeGatewayClipboardText() = %d bytes, %v", len(decoded.Text), err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tooLarge := protocol.GatewayClipboardText{
|
||||||
|
Direction: "client_to_provider",
|
||||||
|
Text: strings.Repeat("a", 65537),
|
||||||
|
Encoding: "utf-8",
|
||||||
|
LoopToken: "abcdefghijklmnop",
|
||||||
|
}
|
||||||
|
if _, err := protocol.EncodeGatewayClipboardText(tooLarge); err == nil {
|
||||||
|
t.Fatal("EncodeGatewayClipboardText() accepted 65,537 decoded UTF-8 bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+28
-15
@@ -30,6 +30,9 @@ SECRET_PATTERNS = (
|
|||||||
re.compile(rb"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
|
re.compile(rb"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
|
||||||
re.compile(rb"\bsk-[A-Za-z0-9]{20,}\b"),
|
re.compile(rb"\bsk-[A-Za-z0-9]{20,}\b"),
|
||||||
)
|
)
|
||||||
|
ALLOWED_SECRET_PROPERTIES = {
|
||||||
|
("ProviderSessionWork", "client_private_key_pem"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def fail(message: str) -> None:
|
def fail(message: str) -> None:
|
||||||
@@ -53,19 +56,36 @@ def check_generated_provenance() -> None:
|
|||||||
def check_manifest_schema() -> None:
|
def check_manifest_schema() -> None:
|
||||||
schema = json.loads((ROOT / "schemas/control-v1.schema.json").read_text(encoding="utf-8"))
|
schema = json.loads((ROOT / "schemas/control-v1.schema.json").read_text(encoding="utf-8"))
|
||||||
definitions = schema.get("$defs", {})
|
definitions = schema.get("$defs", {})
|
||||||
for name in ("ConnectionManifest", "ManifestGateway", "ManifestTunnel", "ManifestProfile", "ManifestBounds", "GrantReference"):
|
for name, definition in definitions.items():
|
||||||
properties = definitions.get(name, {}).get("properties", {})
|
for field in definition.get("properties", {}):
|
||||||
forbidden = sorted(FORBIDDEN_WIRE_FIELDS.intersection(properties))
|
if any(forbidden in field.lower() for forbidden in FORBIDDEN_WIRE_FIELDS):
|
||||||
if forbidden:
|
if (name, field) not in ALLOWED_SECRET_PROPERTIES:
|
||||||
fail(f"{name} exposes forbidden wire fields: {forbidden}")
|
fail(f"{name} exposes forbidden wire field {field}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_proto_boundaries(path: pathlib.Path) -> None:
|
||||||
|
message = ""
|
||||||
|
depth = 0
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
match = re.match(r"\s*message\s+([A-Za-z0-9_]+)\s*\{", line)
|
||||||
|
if match and depth == 0:
|
||||||
|
message = match.group(1)
|
||||||
|
if any(field in line.lower() for field in FORBIDDEN_WIRE_FIELDS):
|
||||||
|
allowed = (
|
||||||
|
message == "ProviderSessionWork"
|
||||||
|
and re.fullmatch(r"\s*string\s+client_private_key_pem\s*=\s*[0-9]+;\s*", line)
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
fail(f"{path.relative_to(ROOT)} exposes forbidden wire field in {message or 'file scope'}")
|
||||||
|
depth += line.count("{") - line.count("}")
|
||||||
|
if depth == 0:
|
||||||
|
message = ""
|
||||||
|
|
||||||
|
|
||||||
def check_text_boundaries() -> None:
|
def check_text_boundaries() -> None:
|
||||||
paths = [
|
paths = [
|
||||||
ROOT / "openapi/control-v1.yaml",
|
ROOT / "openapi/control-v1.yaml",
|
||||||
ROOT / "schemas/control-v1.schema.json",
|
|
||||||
ROOT / "proto/versevdi/control/v1/control.proto",
|
ROOT / "proto/versevdi/control/v1/control.proto",
|
||||||
ROOT / "proto/versevdi/tunnel/v1/tunnel.proto",
|
|
||||||
ROOT / "frames/datagram-v1.md",
|
ROOT / "frames/datagram-v1.md",
|
||||||
ROOT / "frames/registry.json",
|
ROOT / "frames/registry.json",
|
||||||
ROOT / "registries/features.json",
|
ROOT / "registries/features.json",
|
||||||
@@ -77,14 +97,7 @@ def check_text_boundaries() -> None:
|
|||||||
if field in text:
|
if field in text:
|
||||||
fail(f"{path.relative_to(ROOT)} contains forbidden wire field {field}")
|
fail(f"{path.relative_to(ROOT)} contains forbidden wire field {field}")
|
||||||
|
|
||||||
generated_paths = list((ROOT / "gen").rglob("*"))
|
check_proto_boundaries(ROOT / "proto/versevdi/tunnel/v1/tunnel.proto")
|
||||||
for path in generated_paths:
|
|
||||||
if not path.is_file() or path.name == "manifest.json" or path.suffix in {".pb", ".binpb"}:
|
|
||||||
continue
|
|
||||||
text = path.read_text(encoding="utf-8").lower()
|
|
||||||
for field in FORBIDDEN_WIRE_FIELDS:
|
|
||||||
if field in text:
|
|
||||||
fail(f"generated output {path.relative_to(ROOT)} contains forbidden wire field {field}")
|
|
||||||
|
|
||||||
|
|
||||||
def check_secret_canaries() -> None:
|
def check_secret_canaries() -> None:
|
||||||
|
|||||||
+44
-1
@@ -125,6 +125,8 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
lines.append(f"\tif len(v.{field}) < {prop['minLength']} && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_length\"}}) }}")
|
lines.append(f"\tif len(v.{field}) < {prop['minLength']} && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_length\"}}) }}")
|
||||||
if "maxLength" in prop:
|
if "maxLength" in prop:
|
||||||
lines.append(f"\tif len(v.{field}) > {prop['maxLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_length\"}}) }}")
|
lines.append(f"\tif len(v.{field}) > {prop['maxLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_length\"}}) }}")
|
||||||
|
if "x-max-bytes" in prop:
|
||||||
|
lines.append(f"\tif len(v.{field}) > {prop['x-max-bytes']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_bytes\"}}) }}")
|
||||||
if "const" in prop:
|
if "const" in prop:
|
||||||
lines.append(f"\tif v.{field} != \"{prop['const']}\" && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}")
|
lines.append(f"\tif v.{field} != \"{prop['const']}\" && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}")
|
||||||
if "enum" in prop:
|
if "enum" in prop:
|
||||||
@@ -135,6 +137,8 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
'\tif v.%s != "" { if parsed, err := time.Parse(time.RFC3339Nano, v.%s); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.%s { violations = append(violations, FieldViolation{Field: "%s", Code: "invalid_time"}) } }'
|
'\tif v.%s != "" { if parsed, err := time.Parse(time.RFC3339Nano, v.%s); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.%s { violations = append(violations, FieldViolation{Field: "%s", Code: "invalid_time"}) } }'
|
||||||
% (field, field, field, prop_name)
|
% (field, field, field, prop_name)
|
||||||
)
|
)
|
||||||
|
if prop.get("format") == "base64url":
|
||||||
|
lines.append(f"\tif v.{field} != \"\" {{ if _, err := base64.RawURLEncoding.Strict().DecodeString(v.{field}); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_format\"}}) }} }}")
|
||||||
if prop.get("type") == "integer":
|
if prop.get("type") == "integer":
|
||||||
if "minimum" in prop:
|
if "minimum" in prop:
|
||||||
lines.append(f"\tif v.{field} != 0 && v.{field} < {prop['minimum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"minimum\"}}) }}")
|
lines.append(f"\tif v.{field} != 0 && v.{field} < {prop['minimum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"minimum\"}}) }}")
|
||||||
@@ -167,6 +171,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
"",
|
"",
|
||||||
"import (",
|
"import (",
|
||||||
"\"bytes\"",
|
"\"bytes\"",
|
||||||
|
"\"encoding/base64\"",
|
||||||
"\"encoding/json\"",
|
"\"encoding/json\"",
|
||||||
"\"errors\"",
|
"\"errors\"",
|
||||||
"\"fmt\"",
|
"\"fmt\"",
|
||||||
@@ -223,7 +228,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
% (prop_name, prop_name)
|
% (prop_name, prop_name)
|
||||||
)
|
)
|
||||||
for prop_name, prop in defs[name].get("properties", {}).items():
|
for prop_name, prop in defs[name].get("properties", {}).items():
|
||||||
if "x-max-bytes" in prop:
|
if "x-max-bytes" in prop and prop.get("type") != "string":
|
||||||
out.append(
|
out.append(
|
||||||
'\tif raw, ok := fields["%s"]; ok && len(raw) > %d { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "max_bytes"}}} }'
|
'\tif raw, ok := fields["%s"]; ok && len(raw) > %d { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "max_bytes"}}} }'
|
||||||
% (prop_name, prop["x-max-bytes"], prop_name)
|
% (prop_name, prop["x-max-bytes"], prop_name)
|
||||||
@@ -322,11 +327,15 @@ def rust_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
lines.append(f" {prefix}if !{value}.is_empty() && {value}.len() < {prop['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_length\")); }}")
|
lines.append(f" {prefix}if !{value}.is_empty() && {value}.len() < {prop['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_length\")); }}")
|
||||||
if "maxLength" in prop:
|
if "maxLength" in prop:
|
||||||
lines.append(f" {prefix}if {value}.len() > {prop['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_length\")); }}")
|
lines.append(f" {prefix}if {value}.len() > {prop['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_length\")); }}")
|
||||||
|
if "x-max-bytes" in prop:
|
||||||
|
lines.append(f" {prefix}if {value}.as_bytes().len() > {prop['x-max-bytes']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_bytes\")); }}")
|
||||||
if "const" in prop:
|
if "const" in prop:
|
||||||
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
||||||
if "enum" in prop:
|
if "enum" in prop:
|
||||||
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
|
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
|
||||||
lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
||||||
|
if prop.get("format") == "base64url":
|
||||||
|
lines.append(f" {prefix}if !valid_base64_url({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_format\")); }}")
|
||||||
if prop.get("type") == "integer":
|
if prop.get("type") == "integer":
|
||||||
if "minimum" in prop:
|
if "minimum" in prop:
|
||||||
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"minimum\")); }}")
|
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"minimum\")); }}")
|
||||||
@@ -369,6 +378,27 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
"pub struct ValidationError { pub field: &'static str, pub code: &'static str }",
|
"pub struct ValidationError { pub field: &'static str, pub code: &'static str }",
|
||||||
"impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }",
|
"impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }",
|
||||||
"",
|
"",
|
||||||
|
"fn base64url_value(value: u8) -> Option<u8> {",
|
||||||
|
" match value {",
|
||||||
|
" b'A'..=b'Z' => Some(value - b'A'),",
|
||||||
|
" b'a'..=b'z' => Some(value - b'a' + 26),",
|
||||||
|
" b'0'..=b'9' => Some(value - b'0' + 52),",
|
||||||
|
" b'-' => Some(62),",
|
||||||
|
" b'_' => Some(63),",
|
||||||
|
" _ => None,",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"fn valid_base64_url(value: &str) -> bool {",
|
||||||
|
" let bytes = value.as_bytes();",
|
||||||
|
" if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) { return false; }",
|
||||||
|
" match bytes.len() % 4 {",
|
||||||
|
" 0 => true,",
|
||||||
|
" 2 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x0f == 0,",
|
||||||
|
" 3 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x03 == 0,",
|
||||||
|
" _ => false,",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
]
|
]
|
||||||
for name in sorted(defs):
|
for name in sorted(defs):
|
||||||
definition = defs[name]
|
definition = defs[name]
|
||||||
@@ -451,6 +481,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
lines.append(f" {prefix}if !{value}.isEmpty && {value}.utf8.count < {prop['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_length\") }}")
|
lines.append(f" {prefix}if !{value}.isEmpty && {value}.utf8.count < {prop['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_length\") }}")
|
||||||
if "maxLength" in prop:
|
if "maxLength" in prop:
|
||||||
lines.append(f" {prefix}if {value}.utf8.count > {prop['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_length\") }}")
|
lines.append(f" {prefix}if {value}.utf8.count > {prop['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_length\") }}")
|
||||||
|
if "x-max-bytes" in prop:
|
||||||
|
lines.append(f" {prefix}if {value}.utf8.count > {prop['x-max-bytes']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_bytes\") }}")
|
||||||
if "const" in prop:
|
if "const" in prop:
|
||||||
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
||||||
if "enum" in prop:
|
if "enum" in prop:
|
||||||
@@ -458,6 +490,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
||||||
if prop.get("format") == "date-time":
|
if prop.get("format") == "date-time":
|
||||||
lines.append(f" {prefix}if ISO8601DateFormatter().date(from: {value}) == nil {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
|
lines.append(f" {prefix}if ISO8601DateFormatter().date(from: {value}) == nil {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
|
||||||
|
if prop.get("format") == "base64url":
|
||||||
|
lines.append(f" {prefix}if !validBase64URL({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_format\") }}")
|
||||||
if prop.get("type") == "integer":
|
if prop.get("type") == "integer":
|
||||||
if "minimum" in prop:
|
if "minimum" in prop:
|
||||||
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"minimum\") }}")
|
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"minimum\") }}")
|
||||||
@@ -497,6 +531,15 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"',
|
f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"',
|
||||||
"public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }",
|
"public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }",
|
||||||
"private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }",
|
"private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }",
|
||||||
|
"private func validBase64URL(_ value: String) -> Bool {",
|
||||||
|
" guard !value.isEmpty, value.utf8.allSatisfy({ byte in",
|
||||||
|
" (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95",
|
||||||
|
" }) else { return false }",
|
||||||
|
" let padding = String(repeating: \"=\", count: (4 - value.utf8.count % 4) % 4)",
|
||||||
|
" let standard = value.replacingOccurrences(of: \"-\", with: \"+\").replacingOccurrences(of: \"_\", with: \"/\") + padding",
|
||||||
|
" guard let decoded = Data(base64Encoded: standard) else { return false }",
|
||||||
|
" return decoded.base64EncodedString().replacingOccurrences(of: \"+\", with: \"-\").replacingOccurrences(of: \"/\", with: \"_\").replacingOccurrences(of: \"=\", with: \"\") == value",
|
||||||
|
"}",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for name in sorted(defs):
|
for name in sorted(defs):
|
||||||
|
|||||||
@@ -56,12 +56,18 @@ fn evaluate(kind: &str, input: &str) -> &'static str {
|
|||||||
"gateway_input" => classify_gateway_input(values.get("hex").map(String::as_str).unwrap_or_default()),
|
"gateway_input" => classify_gateway_input(values.get("hex").map(String::as_str).unwrap_or_default()),
|
||||||
"gateway_feedback" => classify_gateway_feedback(values.get("hex").map(String::as_str).unwrap_or_default()),
|
"gateway_feedback" => classify_gateway_feedback(values.get("hex").map(String::as_str).unwrap_or_default()),
|
||||||
"gateway_clipboard" if values.contains_key("file") => "invalid:forbidden",
|
"gateway_clipboard" if values.contains_key("file") => "invalid:forbidden",
|
||||||
"gateway_clipboard"
|
"gateway_clipboard" => match (
|
||||||
if matches!(values.get("direction").map(String::as_str), Some("client_to_provider") | Some("provider_to_client"))
|
values.get("direction"),
|
||||||
&& values.get("encoding").map(String::as_str) == Some("utf-8")
|
values.get("text"),
|
||||||
&& values.get("loop_token").map_or(false, |value| (16..=128).contains(&value.len()))
|
values.get("encoding"),
|
||||||
&& values.get("text").map_or(false, |value| value.len() <= 65536) => "valid",
|
values.get("loop_token"),
|
||||||
"gateway_clipboard" => "invalid:clipboard",
|
) {
|
||||||
|
(Some(direction), Some(text), Some(encoding), Some(token))
|
||||||
|
if GatewayClipboardText::new(
|
||||||
|
direction.clone(), text.clone(), encoding.clone(), token.clone(),
|
||||||
|
).is_ok() => "valid",
|
||||||
|
_ => "invalid:clipboard",
|
||||||
|
},
|
||||||
"gateway_clipboard_audit" if values.contains_key("text") => "invalid:forbidden",
|
"gateway_clipboard_audit" if values.contains_key("text") => "invalid:forbidden",
|
||||||
"gateway_clipboard_audit"
|
"gateway_clipboard_audit"
|
||||||
if matches!(values.get("direction").map(String::as_str), Some("client_to_provider") | Some("provider_to_client"))
|
if matches!(values.get("direction").map(String::as_str), Some("client_to_provider") | Some("provider_to_client"))
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ func evaluate(_ kind: String, _ input: String) -> String {
|
|||||||
case "gateway_feedback": return classifyGatewayFeedback(values["hex"] ?? "")
|
case "gateway_feedback": return classifyGatewayFeedback(values["hex"] ?? "")
|
||||||
case "gateway_clipboard":
|
case "gateway_clipboard":
|
||||||
if values["file"] != nil { return "invalid:forbidden" }
|
if values["file"] != nil { return "invalid:forbidden" }
|
||||||
guard ["client_to_provider", "provider_to_client"].contains(values["direction"] ?? ""), values["encoding"] == "utf-8", let token = values["loop_token"], (16...128).contains(token.utf8.count), let text = values["text"], text.utf8.count <= 65536 else { return "invalid:clipboard" }
|
guard let direction = values["direction"], let text = values["text"],
|
||||||
|
let encoding = values["encoding"], let token = values["loop_token"],
|
||||||
|
(try? GatewayClipboardText(
|
||||||
|
direction: direction, text: text, encoding: encoding, loopToken: token
|
||||||
|
)) != nil else { return "invalid:clipboard" }
|
||||||
return "valid"
|
return "valid"
|
||||||
case "gateway_clipboard_audit":
|
case "gateway_clipboard_audit":
|
||||||
if values["text"] != nil { return "invalid:forbidden" }
|
if values["text"] != nil { return "invalid:forbidden" }
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ def main() -> int:
|
|||||||
temp = pathlib.Path(directory)
|
temp = pathlib.Path(directory)
|
||||||
rust_bin = temp / "rust-conformance"
|
rust_bin = temp / "rust-conformance"
|
||||||
swift_bin = temp / "swift-conformance"
|
swift_bin = temp / "swift-conformance"
|
||||||
run(["rustc", "tools/native_conformance.rs", "-O", "-o", str(rust_bin)])
|
rust_source = temp / "main.rs"
|
||||||
|
rust_source.write_text(
|
||||||
|
(ROOT / "gen/rust/protocol.rs").read_text(encoding="utf-8")
|
||||||
|
+ "\n"
|
||||||
|
+ (ROOT / "tools/native_conformance.rs").read_text(encoding="utf-8"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
run(["rustc", str(rust_source), "-O", "-o", str(rust_bin)])
|
||||||
run([str(rust_bin)])
|
run([str(rust_bin)])
|
||||||
main_source = temp / "main.swift"
|
main_source = temp / "main.swift"
|
||||||
main_source.write_text((ROOT / "tools/native_conformance.swift").read_text(encoding="utf-8"), encoding="utf-8")
|
main_source.write_text((ROOT / "tools/native_conformance.swift").read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Focused contract-boundary regressions for check_scope.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import check_scope
|
||||||
|
|
||||||
|
|
||||||
|
TEXT_PATHS = (
|
||||||
|
"openapi/control-v1.yaml",
|
||||||
|
"proto/versevdi/control/v1/control.proto",
|
||||||
|
"proto/versevdi/tunnel/v1/tunnel.proto",
|
||||||
|
"frames/datagram-v1.md",
|
||||||
|
"frames/registry.json",
|
||||||
|
"registries/features.json",
|
||||||
|
"registries/datagrams.json",
|
||||||
|
)
|
||||||
|
PROVIDER_WORK_PROTO = """
|
||||||
|
message ProviderSessionWork {
|
||||||
|
string client_private_key_pem = 15;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def schema_with_private_key(owner: str) -> dict[str, object]:
|
||||||
|
definitions = {
|
||||||
|
name: {"type": "object", "properties": {}}
|
||||||
|
for name in (
|
||||||
|
"ConnectionManifest",
|
||||||
|
"ManifestGateway",
|
||||||
|
"ManifestTunnel",
|
||||||
|
"ManifestProfile",
|
||||||
|
"ManifestBounds",
|
||||||
|
"GrantReference",
|
||||||
|
"ProviderSessionWork",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
definitions[owner]["properties"] = {
|
||||||
|
"client_private_key_pem": {"type": "string"},
|
||||||
|
}
|
||||||
|
return {"$defs": definitions}
|
||||||
|
|
||||||
|
|
||||||
|
def run_scope(schema: dict[str, object], overrides: dict[str, str] | None = None) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = pathlib.Path(directory)
|
||||||
|
schema_path = root / "schemas/control-v1.schema.json"
|
||||||
|
schema_path.parent.mkdir(parents=True)
|
||||||
|
schema_path.write_text(json.dumps(schema), encoding="utf-8")
|
||||||
|
for relative in TEXT_PATHS:
|
||||||
|
path = root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
default = PROVIDER_WORK_PROTO if relative == "proto/versevdi/tunnel/v1/tunnel.proto" else ""
|
||||||
|
path.write_text((overrides or {}).get(relative, default), encoding="utf-8")
|
||||||
|
(root / "gen").mkdir()
|
||||||
|
|
||||||
|
original_root = check_scope.ROOT
|
||||||
|
check_scope.ROOT = root
|
||||||
|
try:
|
||||||
|
check_scope.check_manifest_schema()
|
||||||
|
check_scope.check_text_boundaries()
|
||||||
|
finally:
|
||||||
|
check_scope.ROOT = original_root
|
||||||
|
|
||||||
|
|
||||||
|
def expect_rejected(schema: dict[str, object], overrides: dict[str, str] | None = None) -> None:
|
||||||
|
try:
|
||||||
|
run_scope(schema, overrides)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
raise AssertionError("client-visible private-key material was accepted")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
run_scope(schema_with_private_key("ProviderSessionWork"))
|
||||||
|
expect_rejected(schema_with_private_key("ConnectionManifest"))
|
||||||
|
expect_rejected(schema_with_private_key("ManifestProfile"))
|
||||||
|
expect_rejected(
|
||||||
|
schema_with_private_key("ProviderSessionWork"),
|
||||||
|
{
|
||||||
|
"proto/versevdi/tunnel/v1/tunnel.proto": """
|
||||||
|
message ConnectionManifest {
|
||||||
|
string client_private_key_pem = 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect_rejected(
|
||||||
|
schema_with_private_key("ProviderSessionWork"),
|
||||||
|
{"frames/datagram-v1.md": "client_private_key_pem"},
|
||||||
|
)
|
||||||
|
print("Protocol contract-aware scope regression passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -85,6 +85,25 @@ do {
|
|||||||
)
|
)
|
||||||
fatalError("invalid allocation bounds were accepted")
|
fatalError("invalid allocation bounds were accepted")
|
||||||
} catch { }
|
} catch { }
|
||||||
|
for text in [
|
||||||
|
String(repeating: "a", count: 65536),
|
||||||
|
String(repeating: "é", count: 32768),
|
||||||
|
String(repeating: "\\\"", count: 32768),
|
||||||
|
] {
|
||||||
|
let clipboard = try GatewayClipboardText(
|
||||||
|
direction: "client_to_provider", text: text, encoding: "utf-8",
|
||||||
|
loopToken: "abcdefghijklmnop"
|
||||||
|
)
|
||||||
|
let decoded = try GatewayClipboardText.decodeJSON(clipboard.encodeJSON())
|
||||||
|
guard decoded.text == text else { fatalError("clipboard text changed during round-trip") }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try GatewayClipboardText(
|
||||||
|
direction: "client_to_provider", text: String(repeating: "a", count: 65537),
|
||||||
|
encoding: "utf-8", loopToken: "abcdefghijklmnop"
|
||||||
|
)
|
||||||
|
fatalError("oversized clipboard text was accepted")
|
||||||
|
} catch { }
|
||||||
""",
|
""",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
@@ -130,6 +149,19 @@ fn main() {
|
|||||||
assert!(AllocationPolicy::new(
|
assert!(AllocationPolicy::new(
|
||||||
100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300,
|
100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300,
|
||||||
).is_err());
|
).is_err());
|
||||||
|
for text in [
|
||||||
|
"a".repeat(65536),
|
||||||
|
"é".repeat(32768),
|
||||||
|
"\\\"".repeat(32768),
|
||||||
|
] {
|
||||||
|
assert!(GatewayClipboardText::new(
|
||||||
|
"client_to_provider".into(), text, "utf-8".into(), "abcdefghijklmnop".into(),
|
||||||
|
).is_ok());
|
||||||
|
}
|
||||||
|
assert!(GatewayClipboardText::new(
|
||||||
|
"client_to_provider".into(), "a".repeat(65537), "utf-8".into(),
|
||||||
|
"abcdefghijklmnop".into(),
|
||||||
|
).is_err());
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user