mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-08-07 00:52:45 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user