fix(protocol): harden gateway contract validation
This commit is contained in:
+28
-15
@@ -30,6 +30,9 @@ SECRET_PATTERNS = (
|
||||
re.compile(rb"\bgh[pousr]_[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:
|
||||
@@ -53,19 +56,36 @@ def check_generated_provenance() -> None:
|
||||
def check_manifest_schema() -> None:
|
||||
schema = json.loads((ROOT / "schemas/control-v1.schema.json").read_text(encoding="utf-8"))
|
||||
definitions = schema.get("$defs", {})
|
||||
for name in ("ConnectionManifest", "ManifestGateway", "ManifestTunnel", "ManifestProfile", "ManifestBounds", "GrantReference"):
|
||||
properties = definitions.get(name, {}).get("properties", {})
|
||||
forbidden = sorted(FORBIDDEN_WIRE_FIELDS.intersection(properties))
|
||||
if forbidden:
|
||||
fail(f"{name} exposes forbidden wire fields: {forbidden}")
|
||||
for name, definition in definitions.items():
|
||||
for field in definition.get("properties", {}):
|
||||
if any(forbidden in field.lower() for forbidden in FORBIDDEN_WIRE_FIELDS):
|
||||
if (name, field) not in ALLOWED_SECRET_PROPERTIES:
|
||||
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:
|
||||
paths = [
|
||||
ROOT / "openapi/control-v1.yaml",
|
||||
ROOT / "schemas/control-v1.schema.json",
|
||||
ROOT / "proto/versevdi/control/v1/control.proto",
|
||||
ROOT / "proto/versevdi/tunnel/v1/tunnel.proto",
|
||||
ROOT / "frames/datagram-v1.md",
|
||||
ROOT / "frames/registry.json",
|
||||
ROOT / "registries/features.json",
|
||||
@@ -77,14 +97,7 @@ def check_text_boundaries() -> None:
|
||||
if field in text:
|
||||
fail(f"{path.relative_to(ROOT)} contains forbidden wire field {field}")
|
||||
|
||||
generated_paths = list((ROOT / "gen").rglob("*"))
|
||||
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}")
|
||||
check_proto_boundaries(ROOT / "proto/versevdi/tunnel/v1/tunnel.proto")
|
||||
|
||||
|
||||
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\"}}) }}")
|
||||
if "maxLength" in prop:
|
||||
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:
|
||||
lines.append(f"\tif v.{field} != \"{prop['const']}\" && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}")
|
||||
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"}) } }'
|
||||
% (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 "minimum" in prop:
|
||||
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 (",
|
||||
"\"bytes\"",
|
||||
"\"encoding/base64\"",
|
||||
"\"encoding/json\"",
|
||||
"\"errors\"",
|
||||
"\"fmt\"",
|
||||
@@ -223,7 +228,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
||||
% (prop_name, prop_name)
|
||||
)
|
||||
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(
|
||||
'\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)
|
||||
@@ -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\")); }}")
|
||||
if "maxLength" in prop:
|
||||
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:
|
||||
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
||||
if "enum" in prop:
|
||||
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
|
||||
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 "minimum" in prop:
|
||||
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 }",
|
||||
"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):
|
||||
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\") }}")
|
||||
if "maxLength" in prop:
|
||||
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:
|
||||
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
||||
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\") }}")
|
||||
if prop.get("format") == "date-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 "minimum" in prop:
|
||||
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"]}"',
|
||||
"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",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
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_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 matches!(values.get("direction").map(String::as_str), Some("client_to_provider") | Some("provider_to_client"))
|
||||
&& values.get("encoding").map(String::as_str) == Some("utf-8")
|
||||
&& values.get("loop_token").map_or(false, |value| (16..=128).contains(&value.len()))
|
||||
&& values.get("text").map_or(false, |value| value.len() <= 65536) => "valid",
|
||||
"gateway_clipboard" => "invalid:clipboard",
|
||||
"gateway_clipboard" => match (
|
||||
values.get("direction"),
|
||||
values.get("text"),
|
||||
values.get("encoding"),
|
||||
values.get("loop_token"),
|
||||
) {
|
||||
(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 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_clipboard":
|
||||
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"
|
||||
case "gateway_clipboard_audit":
|
||||
if values["text"] != nil { return "invalid:forbidden" }
|
||||
|
||||
@@ -20,7 +20,14 @@ def main() -> int:
|
||||
temp = pathlib.Path(directory)
|
||||
rust_bin = temp / "rust-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)])
|
||||
main_source = temp / "main.swift"
|
||||
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")
|
||||
} 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",
|
||||
)
|
||||
@@ -130,6 +149,19 @@ fn main() {
|
||||
assert!(AllocationPolicy::new(
|
||||
100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300,
|
||||
).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