diff --git a/meshexplorer/proto/meshexplorer/v1/chat.proto b/meshexplorer/proto/meshexplorer/v1/chat.proto index 08aeb9f..3b6be7f 100644 --- a/meshexplorer/proto/meshexplorer/v1/chat.proto +++ b/meshexplorer/proto/meshexplorer/v1/chat.proto @@ -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 diff --git a/meshexplorer/proto/meshexplorer/v1/map.proto b/meshexplorer/proto/meshexplorer/v1/map.proto index 39a24a2..d57a7af 100644 --- a/meshexplorer/proto/meshexplorer/v1/map.proto +++ b/meshexplorer/proto/meshexplorer/v1/map.proto @@ -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; } diff --git a/meshexplorer/proto/meshexplorer/v1/neighbors.proto b/meshexplorer/proto/meshexplorer/v1/neighbors.proto index fb6e189..72acc28 100644 --- a/meshexplorer/proto/meshexplorer/v1/neighbors.proto +++ b/meshexplorer/proto/meshexplorer/v1/neighbors.proto @@ -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 { diff --git a/meshexplorer/proto/meshexplorer/v1/node.proto b/meshexplorer/proto/meshexplorer/v1/node.proto index 8b85e94..888fd20 100644 --- a/meshexplorer/proto/meshexplorer/v1/node.proto +++ b/meshexplorer/proto/meshexplorer/v1/node.proto @@ -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 { diff --git a/meshexplorer/proto/meshexplorer/v1/packets.proto b/meshexplorer/proto/meshexplorer/v1/packets.proto index 407642e..4e0c0c3 100644 --- a/meshexplorer/proto/meshexplorer/v1/packets.proto +++ b/meshexplorer/proto/meshexplorer/v1/packets.proto @@ -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 diff --git a/meshexplorer/proto/meshexplorer/v1/rules.proto b/meshexplorer/proto/meshexplorer/v1/rules.proto new file mode 100644 index 0000000..de3c06b --- /dev/null +++ b/meshexplorer/proto/meshexplorer/v1/rules.proto @@ -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._/-]*$'))" + }]; +} diff --git a/meshexplorer/proto/meshexplorer/v1/stats.proto b/meshexplorer/proto/meshexplorer/v1/stats.proto index abe7096..5a1848e 100644 --- a/meshexplorer/proto/meshexplorer/v1/stats.proto +++ b/meshexplorer/proto/meshexplorer/v1/stats.proto @@ -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 { diff --git a/meshexplorer/src/server/connect/validation.ts b/meshexplorer/src/server/connect/validation.ts index ce98097..a5c77d5 100644 --- a/meshexplorer/src/server/connect/validation.ts +++ b/meshexplorer/src/server/connect/validation.ts @@ -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(schema: Desc, message: MessageShape): void { const result = validator.validate(schema, message);