fix(protocol): harden gateway contract validation

This commit is contained in:
sechmachine
2026-07-29 21:16:53 +07:00
parent 0ea21cd3f2
commit 6bf109623d
18 changed files with 337 additions and 44 deletions
+1
View File
@@ -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
1 id version kind input expected
2 valid-client-to-provider 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop valid
3 valid-provider-to-client 1 gateway_clipboard direction=provider_to_client;text=host%20text;encoding=utf-8;loop_token=qrstuvwxyzABCDEF valid
4 valid-token-alphabet 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid
5 valid-token-max 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid
6 invalid-direction 1 gateway_clipboard direction=bidirectional;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop invalid:clipboard
7 invalid-token 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=short invalid:clipboard
8 invalid-token-15 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmno invalid:clipboard
9 invalid-token-129 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_A invalid:clipboard
10 invalid-token-character 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=!!!!!!!!!!!!!!!! invalid:clipboard
11 invalid-token-trailing-bits 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=AAAAAAAAAAAAAAAAAB invalid:clipboard
12 invalid-file 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop;file=file.txt invalid:forbidden
+1 -1
View File
@@ -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"
} }
+4 -4
View File
@@ -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.
+13 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
+13 -1
View File
@@ -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
+1 -1
View File
@@ -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": {
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+12 -6
View File
@@ -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"))
+5 -1
View File
@@ -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" }
+8 -1
View File
@@ -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")
+100
View File
@@ -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()
+32
View File
@@ -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());
} }
""" """
) )