test(#68): adversarial codec test suite (A1, TDD-RED)

11 tests for ObserverConfigClient / BrokerListDecoder. 9 green (builders,
response parsing, happy-path decode, malformed-NUL, garbage-numeric, stray-KV).
2 RED BY DESIGN, documenting the Gemini findings to be fixed by C1 (#72):
- DoS: BrokerListDecoder retains 256 slots from a hostile BROKER_KV flood
  (must bound to brokerSlotCount=10).
- out-of-range slot 200 is stored (must be dropped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/99/head
Strycher 4 weeks ago
parent a31b8146d5
commit ce9126fbc6

@ -0,0 +1,211 @@
// Adversarial + correctness tests for the Offband config codec (#64 A1).
//
// TDD: the "hostile device" group is written to FAIL on the un-hardened codec
// it documents the Gemini BLOCKER (unbounded BrokerListDecoder / DoS) and MINOR
// (out-of-range slot accepted). C1 (#72) hardens the codec until these pass.
// The builder / parsing / happy-path groups assert the contract and should be
// green already.
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/observer_config_client.dart';
import 'package:meshcore_open/models/observer_config.dart';
void main() {
Uint8List resp(int sub, [List<int>? payload]) {
final b = BytesBuilder();
b.addByte(ObserverConfigClient.respConfig);
b.addByte(sub);
if (payload != null) b.add(payload);
return b.toBytes();
}
Uint8List respText(int sub, String text) =>
resp(sub, [...utf8.encode(text), 0]);
Uint8List brokerKv(int slot, String kv) => Uint8List.fromList([
ObserverConfigClient.respConfig,
ObserverConfigClient.rBrokerKv,
slot,
...utf8.encode(kv),
0,
]);
group('frame builders', () {
test('buildSet = [0xC0, OCFG_SET, "key value", NUL]', () {
final f = ObserverConfigClient.buildSet('mqtt.iata', 'HAO');
expect(f[0], ObserverConfigClient.cmdConfig);
expect(f[1], ObserverConfigClient.opSet);
expect(utf8.decode(f.sublist(2, f.length - 1)), 'mqtt.iata HAO');
expect(f.last, 0);
});
test('buildGet / buildBrokers sub-types', () {
expect(
ObserverConfigClient.buildGet('wifi.ssid')[1],
ObserverConfigClient.opGet,
);
final br = ObserverConfigClient.buildBrokers();
expect(br.length, 2);
expect(br[1], ObserverConfigClient.opBrokers);
});
test(
'SET value with spaces is verbatim (first-space split is firmware-side)',
() {
final f = ObserverConfigClient.buildSet('wifi.ssid', 'My Home Net');
expect(
utf8.decode(f.sublist(2, f.length - 1)),
'wifi.ssid My Home Net',
);
},
);
});
group('response parsing', () {
test('ACK / ERR / VALUE', () {
expect(
ObserverConfigClient.parse(respText(ObserverConfigClient.rAck, 'ok')),
isA<ConfigAck>(),
);
expect(
ObserverConfigClient.parse(
respText(ObserverConfigClient.rErr, 'ERROR boom'),
),
isA<ConfigErr>(),
);
final v = ObserverConfigClient.parse(
respText(ObserverConfigClient.rValue, 'wifi.ssid = MyNet'),
);
expect(v, isA<ConfigValue>());
expect((v as ConfigValue).value, 'MyNet');
});
test('wrong code -> ConfigUnknown', () {
expect(
ObserverConfigClient.parse(Uint8List.fromList([0x42, 0x00])),
isA<ConfigUnknown>(),
);
});
test('too-short frame -> ConfigUnknown', () {
expect(
ObserverConfigClient.parse(Uint8List.fromList([0xC0])),
isA<ConfigUnknown>(),
);
});
test('BROKER_KV splits on the FIRST = only (value may contain =)', () {
final r =
ObserverConfigClient.parse(brokerKv(0, 'url=mqtt://h/?a=b'))
as ConfigBrokerKv;
expect(r.slot, 0);
expect(r.key, 'url');
expect(r.value, 'mqtt://h/?a=b');
});
});
group('broker decode — happy path', () {
test('START -> KV -> END assembles a broker', () {
final d = BrokerListDecoder();
expect(
d.add(
ObserverConfigClient.parse(
resp(ObserverConfigClient.rBrokersStart, [1]),
),
),
isNull,
);
d.add(ObserverConfigClient.parse(brokerKv(0, 'url=mqtt://h')));
d.add(ObserverConfigClient.parse(brokerKv(0, 'port=8883')));
final list = d.add(
ObserverConfigClient.parse(resp(ObserverConfigClient.rBrokersEnd)),
);
expect(list, isNotNull);
expect(list!.length, 1);
expect(list.first.url, 'mqtt://h');
expect(list.first.port, 8883);
});
});
// ---- ADVERSARIAL: assume a malicious/compromised device controls the wire ----
group('adversarial: hostile device', () {
test(
'DoS — flood of BROKER_KV must not retain slots past brokerSlotCount',
() {
final d = BrokerListDecoder();
d.add(
ObserverConfigClient.parse(
resp(ObserverConfigClient.rBrokersStart, [0]),
),
);
// Malicious device floods 1000 frames across 256 slots, far over the
// 10-slot contract, then (eventually) END.
for (var i = 0; i < 1000; i++) {
d.add(
ObserverConfigClient.parse(brokerKv(i % 256, 'url=mqtt://flood$i')),
);
}
final list = d.add(
ObserverConfigClient.parse(resp(ObserverConfigClient.rBrokersEnd)),
);
expect(
list!.length,
lessThanOrEqualTo(ObserverConfigClient.brokerSlotCount),
reason:
'BrokerListDecoder must bound retained slots to brokerSlotCount (DoS guard)',
);
},
);
test('out-of-range slot index is dropped, never surfaced', () {
final d = BrokerListDecoder();
d.add(
ObserverConfigClient.parse(
resp(ObserverConfigClient.rBrokersStart, [1]),
),
);
d.add(ObserverConfigClient.parse(brokerKv(200, 'url=mqtt://evil')));
final list = d.add(
ObserverConfigClient.parse(resp(ObserverConfigClient.rBrokersEnd)),
);
expect(
list!.any((b) => b.slot >= ObserverConfigClient.brokerSlotCount),
isFalse,
reason: 'slots >= brokerSlotCount must be dropped, not stored',
);
});
test('malformed: missing NUL terminator parses without throwing', () {
final f = Uint8List.fromList([
ObserverConfigClient.respConfig,
ObserverConfigClient.rValue,
...utf8.encode('wifi.ssid = y'),
]);
expect(() => ObserverConfigClient.parse(f), returnsNormally);
});
test('garbage numeric fields fall back to defaults, never throw', () {
final b = BrokerConfig.fromWireFields(0, {
'url': 'h',
'port': 'NaN',
'jwt_refresh': '',
});
expect(b.port, 0);
expect(b.jwtRefresh, 0);
});
test('KV before START is ignored (no implicit accumulation)', () {
final d = BrokerListDecoder();
// No START a stray KV must not be retained.
d.add(ObserverConfigClient.parse(brokerKv(0, 'url=mqtt://stray')));
final list = d.add(
ObserverConfigClient.parse(resp(ObserverConfigClient.rBrokersEnd)),
);
// END with no open stream returns null (nothing to assemble).
expect(list, isNull);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.