fix(protocol): harden gateway contract validation
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m12s

This commit is contained in:
sechmachine
2026-07-29 21:16:53 +07:00
parent 0ea21cd3f2
commit ebfe07376d
18 changed files with 337 additions and 44 deletions
+13 -7
View File
@@ -3,6 +3,7 @@ package protocol
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -12,7 +13,7 @@ import (
"time"
)
const SchemaSHA256 = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17"
const SchemaSHA256 = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
const ProtocolVersion = "1.0.0"
const CurrentWireVersion = "1"
const NMinus1WireVersion = "0"
@@ -968,6 +969,9 @@ func (v ChannelFrame) Validate() error {
if len(v.Payload) > 87384 {
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 {
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")) {
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.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
@@ -2144,6 +2145,9 @@ func (v GatewayClipboardText) Validate() error {
if len(v.Text) > 65536 {
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 == "" {
violations = append(violations, FieldViolation{Field: "encoding", Code: "required"})
}
@@ -2159,6 +2163,11 @@ func (v GatewayClipboardText) Validate() error {
if len(v.LoopToken) > 128 {
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 {
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")) {
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.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
+2 -2
View File
@@ -12,7 +12,7 @@
"2"
]
},
"generator_sha256": "e9c6ee1541585fcb00dcc5e94a5a6d93dbe3a719a5c545f31e5eda268f2638ab",
"generator_sha256": "922983e07a8ecc559771778fbf139155b14664742d9873be062880102777dccb",
"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.
#![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 N_MINUS_1_WIRE_VERSION: &str = "0";
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 }
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)]
pub struct AllocationPolicy {
minimumKbps: i64,
@@ -259,6 +280,7 @@ impl ChannelFrame {
if self.fragmentCount > 16 { return Err(ValidationError::new("fragment_count", "maximum")); }
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.as_bytes().len() > 65536 { return Err(ValidationError::new("payload", "max_bytes")); }
if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new("fragment_index", "invalid_order")); }
Ok(())
}
@@ -692,10 +714,12 @@ impl GatewayClipboardText {
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.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.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.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(())
}
pub fn direction(&self) -> &String { &self.direction }
+13 -1
View File
@@ -1,12 +1,21 @@
// Code generated by tools/generate.py; DO NOT EDIT.
import Foundation
public typealias JSONObject = [String: String]
public let schemaSHA256 = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17"
public let schemaSHA256 = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
public let currentWireVersion = "1"
public let nMinus1WireVersion = "0"
public let nMinus2WireVersion = "-1"
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 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 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.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 > 65536 { throw ContractValidationError(field: "payload", code: "max_bytes") }
if fragmentIndex >= fragmentCount { throw ContractValidationError(field: "fragment_index", code: "invalid_order") }
}
@@ -926,10 +936,12 @@ public struct GatewayClipboardText: Codable, Equatable {
public func validate() throws {
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_bytes") }
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 && 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 !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) }