Harden protovalidate rules on the ConnectRPC API

Tighten request validation so invalid input is rejected at the edge with a
clear invalid_argument instead of silently producing empty results or opaque
ClickHouse 500s. Grounded against live data (pubkeys are exactly 64 hex,
channel_hash is one byte, before/after are DateTime64 cursors, etc.).

- Add reusable predefined string rules in rules.proto (proto2, required to
  extend protovalidate's rule messages): hex64, datetime64, region. The
  validator registers these via a registry in createValidator().
- public_key / origin_pubkey: exact 64-hex (hex64) instead of min_len 10.
- chat before/after: DateTime64 format (datetime64); channel_id: hex, max_len 2.
- private_keys: bounded list + base64/hex charset per element.
- node_types: bounded list + per-item length; SearchNodes requires >=1 query.
- region: length+charset bound (hex64/datetime64/region rules), now also on the
  four StatsService requests which had no validation.
- map/neighbors: message-level CEL enforcing bbox ordering (min_lat<=max_lat,
  min_lng<=max_lng).

Verified end-to-end against the Docker stack: all unary + server-streaming
validation rules reject bad input and accept valid input.
This commit is contained in:
Alex Vanderpot
2026-05-29 05:03:08 -04:00
parent 1e456f04b8
commit 637ee470c7
8 changed files with 143 additions and 24 deletions
+34 -8
View File
@@ -3,6 +3,7 @@ syntax = "proto3";
package meshexplorer.v1;
import "buf/validate/validate.proto";
import "meshexplorer/v1/rules.proto";
// One (origin, origin_pubkey, path, broker, topic) reception tuple.
message OriginPathInfo {
@@ -41,12 +42,25 @@ message GetChatRequest {
gte: 1
lte: 1000
}];
optional string before = 2;
optional string after = 3;
optional string channel_id = 4 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]+$"];
optional string region = 5;
// ClickHouse DateTime64 cursor, e.g. "2026-05-29 08:39:22.328".
optional string before = 2 [(buf.validate.field).string.(meshexplorer.v1.datetime64) = true];
optional string after = 3 [(buf.validate.field).string.(meshexplorer.v1.datetime64) = true];
optional string channel_id = 4 [(buf.validate.field).string = {
max_len: 2
pattern: "^[0-9A-Fa-f]+$"
}];
optional string region = 5 [(buf.validate.field).string.(meshexplorer.v1.region) = true];
bool decrypt = 6;
repeated string private_keys = 7;
repeated string private_keys = 7 [(buf.validate.field).repeated = {
max_items: 50
items: {
string: {
min_len: 1
max_len: 64
pattern: "^[A-Za-z0-9+/=]+$"
}
}
}];
}
message GetChatResponse {
@@ -54,10 +68,22 @@ message GetChatResponse {
}
message StreamChatRequest {
optional string channel_id = 1 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]+$"];
optional string region = 2;
optional string channel_id = 1 [(buf.validate.field).string = {
max_len: 2
pattern: "^[0-9A-Fa-f]+$"
}];
optional string region = 2 [(buf.validate.field).string.(meshexplorer.v1.region) = true];
bool decrypt = 3;
repeated string private_keys = 4;
repeated string private_keys = 4 [(buf.validate.field).repeated = {
max_items: 50
items: {
string: {
min_len: 1
max_len: 64
pattern: "^[A-Za-z0-9+/=]+$"
}
}
}];
// Poll interval in ms (clamped 100..10000, default 1000).
optional int32 poll_interval = 5 [(buf.validate.field).int32 = {
gte: 100
+22 -2
View File
@@ -4,6 +4,7 @@ package meshexplorer.v1;
import "buf/validate/validate.proto";
import "meshexplorer/v1/common.proto";
import "meshexplorer/v1/rules.proto";
// Latest known position of a node. Mirrors unified_latest_nodeinfo rows
// returned by getNodePositions().
@@ -19,6 +20,17 @@ message NodePosition {
}
message GetMapRequest {
option (buf.validate.message).cel = {
id: "bbox.lat_order"
message: "min_lat must be <= max_lat"
expression: "!has(this.min_lat) || !has(this.max_lat) || this.min_lat <= this.max_lat"
};
option (buf.validate.message).cel = {
id: "bbox.lng_order"
message: "min_lng must be <= max_lng"
expression: "!has(this.min_lng) || !has(this.max_lng) || this.min_lng <= this.max_lng"
};
// Bounding box (decimal degrees). Unset fields mean "unbounded" on that edge.
optional double min_lat = 1 [(buf.validate.field).double = {
gte: -90
@@ -36,10 +48,18 @@ message GetMapRequest {
gte: -180
lte: 180
}];
repeated string node_types = 5;
repeated string node_types = 5 [(buf.validate.field).repeated = {
max_items: 20
items: {
string: {
min_len: 1
max_len: 64
}
}
}];
// Only include nodes seen within this many seconds.
optional int32 last_seen = 6 [(buf.validate.field).int32.gte = 0];
optional string region = 7;
optional string region = 7 [(buf.validate.field).string.(meshexplorer.v1.region) = true];
// When true, also compute and return the neighbor edge graph.
bool include_neighbors = 8;
}
@@ -4,8 +4,20 @@ package meshexplorer.v1;
import "buf/validate/validate.proto";
import "meshexplorer/v1/common.proto";
import "meshexplorer/v1/rules.proto";
message GetAllNeighborsRequest {
option (buf.validate.message).cel = {
id: "bbox.lat_order"
message: "min_lat must be <= max_lat"
expression: "!has(this.min_lat) || !has(this.max_lat) || this.min_lat <= this.max_lat"
};
option (buf.validate.message).cel = {
id: "bbox.lng_order"
message: "min_lng must be <= max_lng"
expression: "!has(this.min_lng) || !has(this.max_lng) || this.min_lng <= this.max_lng"
};
optional double min_lat = 1 [(buf.validate.field).double = {
gte: -90
lte: 90
@@ -22,9 +34,17 @@ message GetAllNeighborsRequest {
gte: -180
lte: 180
}];
repeated string node_types = 5;
repeated string node_types = 5 [(buf.validate.field).repeated = {
max_items: 20
items: {
string: {
min_len: 1
max_len: 64
}
}
}];
optional int32 last_seen = 6 [(buf.validate.field).int32.gte = 0];
optional string region = 7;
optional string region = 7 [(buf.validate.field).string.(meshexplorer.v1.region) = true];
}
message GetAllNeighborsResponse {
@@ -3,6 +3,7 @@ syntax = "proto3";
package meshexplorer.v1;
import "buf/validate/validate.proto";
import "meshexplorer/v1/rules.proto";
// Basic node identity/capabilities from the latest advert (getMeshcoreNodeInfo).
message NodeInfo {
@@ -65,7 +66,7 @@ message MqttInfo {
}
message GetNodeRequest {
string public_key = 1 [(buf.validate.field).string.min_len = 10];
string public_key = 1 [(buf.validate.field).string.(meshexplorer.v1.hex64) = true];
// Max number of recent adverts to return (default 50).
optional int32 limit = 2 [(buf.validate.field).int32 = {
gte: 1
@@ -96,7 +97,7 @@ message Neighbor {
}
message GetNodeNeighborsRequest {
string public_key = 1 [(buf.validate.field).string.min_len = 10];
string public_key = 1 [(buf.validate.field).string.(meshexplorer.v1.hex64) = true];
optional int32 last_seen = 2 [(buf.validate.field).int32.gte = 0];
}
@@ -107,7 +108,7 @@ message GetNodeNeighborsResponse {
// One search request within a (possibly batched) SearchNodes call.
message SearchQuery {
optional string query = 1 [(buf.validate.field).string.max_len = 100];
optional string region = 2;
optional string region = 2 [(buf.validate.field).string.(meshexplorer.v1.region) = true];
optional int32 last_seen = 3 [(buf.validate.field).int32.gte = 0];
optional int32 limit = 4 [(buf.validate.field).int32 = {
gte: 1
@@ -139,7 +140,10 @@ message SearchResultList {
}
message SearchNodesRequest {
repeated SearchQuery queries = 1 [(buf.validate.field).repeated.max_items = 500];
repeated SearchQuery queries = 1 [(buf.validate.field).repeated = {
min_items: 1
max_items: 500
}];
}
message SearchNodesResponse {
@@ -3,6 +3,7 @@ syntax = "proto3";
package meshexplorer.v1;
import "buf/validate/validate.proto";
import "meshexplorer/v1/rules.proto";
// A raw mesh packet (meshcore_packets), hex fields kept as hex strings.
message Packet {
@@ -21,7 +22,7 @@ message Packet {
}
message StreamPacketsRequest {
optional string region = 1;
optional string region = 1 [(buf.validate.field).string.(meshexplorer.v1.region) = true];
// Payload type filter (0..15).
optional int32 payload_type = 2 [(buf.validate.field).int32 = {
gte: 0
@@ -32,7 +33,7 @@ message StreamPacketsRequest {
gte: 0
lte: 3
}];
optional string origin_pubkey = 4 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]+$"];
optional string origin_pubkey = 4 [(buf.validate.field).string.(meshexplorer.v1.hex64) = true];
// Poll interval in ms (clamped 100..10000, default 500).
optional int32 poll_interval = 5 [(buf.validate.field).int32 = {
gte: 100
@@ -0,0 +1,39 @@
// proto2 syntax is required to extend protovalidate's rule messages (proto3
// only permits extending options messages). protovalidate's own validate.proto
// is proto2 for the same reason.
syntax = "proto2";
package meshexplorer.v1;
import "buf/validate/validate.proto";
// Reusable protovalidate string-rule "types" for this schema, so the same regex
// isn't duplicated on every field. Apply with, e.g.,
// string public_key = 1 [(buf.validate.field).string.(meshexplorer.v1.hex64) = true];
extend buf.validate.StringRules {
// A 32-byte meshcore key rendered as 64 hexadecimal characters
// (case-insensitive). Used for public keys / origin pubkeys, which are matched
// exactly in ClickHouse.
optional bool hex64 = 1001 [(buf.validate.predefined).cel = {
id: "string.hex64"
message: "must be 64 hexadecimal characters"
expression: "!rule || this.matches('^[0-9A-Fa-f]{64}$')"
}];
// A ClickHouse DateTime64 literal, e.g. "2026-05-29 08:39:22.328".
optional bool datetime64 = 1002 [(buf.validate.predefined).cel = {
id: "string.datetime64"
message: "must be a DateTime64 timestamp like \"YYYY-MM-DD HH:MM:SS[.fff]\""
expression: "!rule || this.matches('^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?$')"
}];
// A region identifier (a config slug like "seattle", resolved server-side).
// Kept permissive on purpose — regions are config/lookup-driven, so this only
// bounds length and charset rather than enumerating values. Empty is allowed
// and means "no region filter".
optional bool region = 1003 [(buf.validate.predefined).cel = {
id: "string.region"
message: "must be a region identifier of at most 64 characters"
expression: "!rule || (size(this) <= 64 && this.matches('^[A-Za-z0-9._/-]*$'))"
}];
}
@@ -2,20 +2,23 @@ syntax = "proto3";
package meshexplorer.v1;
import "buf/validate/validate.proto";
import "meshexplorer/v1/rules.proto";
message GetTotalNodesRequest {
optional string region = 1;
optional string region = 1 [(buf.validate.field).string.(meshexplorer.v1.region) = true];
}
message GetNodesOverTimeRequest {
optional string region = 1;
optional string region = 1 [(buf.validate.field).string.(meshexplorer.v1.region) = true];
}
message GetPopularChannelsRequest {
optional string region = 1;
optional string region = 1 [(buf.validate.field).string.(meshexplorer.v1.region) = true];
}
message GetRepeaterPrefixesRequest {
optional string region = 1;
optional string region = 1 [(buf.validate.field).string.(meshexplorer.v1.region) = true];
}
message GetTotalNodesResponse {
@@ -1,10 +1,16 @@
import type { DescMessage, MessageShape } from "@bufbuild/protobuf";
import { createRegistry } from "@bufbuild/protobuf";
import { createValidator } from "@bufbuild/protovalidate";
import { Code, ConnectError, type Interceptor } from "@connectrpc/connect";
import { file_meshexplorer_v1_rules } from "@/gen/meshexplorer/v1/rules_pb";
// A single validator instance compiles and caches CEL programs per message
// type, so reuse it across all requests.
const validator = createValidator();
// type, so reuse it across all requests. The registry carries our predefined
// rule extensions (hex64, datetime64 in rules.proto); without it the validator
// rejects those custom rules as unknown extensions.
const validator = createValidator({
registry: createRegistry(file_meshexplorer_v1_rules),
});
function assertValid<Desc extends DescMessage>(schema: Desc, message: MessageShape<Desc>): void {
const result = validator.validate(schema, message);