Validate packet resource identifiers when decoding

This commit is contained in:
voidsip committed 2026-11-06 05:14:09 -06:00
1 parent f4dcf8a2fb
commit 5fd9d40d4c
5 files changed
+263 -2

No files matched your search

@@ -0,0 +1,37 @@
//! The packet identifier parity corpus, without a connection or game resources.
use horizon_protocol::buf::{Codec, Ident, Reader};
use std::{
error::Error,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<_> = std::env::args().collect();
let mut output = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let hex = line?;
if hex.len() % 2 != 0 {
return Err("odd hex length".into());
}
let bytes = hex
.as_bytes()
.chunks_exact(2)
.map(|pair| {
u8::from_str_radix(std::str::from_utf8(pair)?, 16).map_err(Box::<dyn Error>::from)
})
.collect::<Result<Vec<_>, Box<dyn Error>>>()?;
let mut reader = Reader::new(&bytes);
match Ident::read(&mut reader) {
Ok(value) => {
write!(output, "OK {} ", reader.position())?;
for unit in value.encode_utf16() {
write!(output, "{unit:04x}")?;
}
writeln!(output)?;
}
Err(_) => writeln!(output, "ERR {}", reader.position())?,
}
}
Ok(())
}
+26 -2
View File
@@ -343,8 +343,32 @@ impl<const N: usize> Codec<String> for StrN<N> {
w.string(v)
}
}
/// Identifier.STREAM_CODEC: a resource location string.
pub type Ident = StrN<32767>;
/// Identifier.STREAM_CODEC: a validated resource location, with the default namespace.
pub struct Ident;
impl Codec<String> for Ident {
fn read(r: &mut Reader) -> Result<String> {
let value = r.string(32767)?;
let (namespace, path) = value.split_once(':').unwrap_or(("", &value));
let namespace = if namespace.is_empty() {
"minecraft"
} else {
namespace
};
if !namespace
.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"_.-".contains(&c))
|| !path
.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"/_.-".contains(&c))
{
return Err(Error::new("invalid resource identifier"));
}
Ok(format!("{namespace}:{path}"))
}
fn write(v: &String, w: &mut Writer) {
w.string(v)
}
}
/// VarInt-prefixed bytes.
pub struct Bytes;
impl Codec<Vec<u8>> for Bytes {
@@ -0,0 +1,85 @@
use horizon_protocol::{
buf::{Codec, Ident, Reader, Str, Writer},
packets::{configuration, play},
};
fn wire(value: &str) -> Vec<u8> {
let mut writer = Writer::new();
writer.string(value);
writer.data
}
#[test]
fn identifiers_normalize_default_namespace_without_restricting_empty_paths() {
for (input, expected) in [
("stone", "minecraft:stone"),
(":stone", "minecraft:stone"),
("", "minecraft:"),
(":", "minecraft:"),
("mod:", "mod:"),
("mod:dir/file.-_09", "mod:dir/file.-_09"),
] {
let bytes = wire(input);
let mut reader = Reader::new(&bytes);
assert_eq!(Ident::read(&mut reader).unwrap(), expected, "{input:?}");
assert_eq!(reader.remaining(), 0);
}
}
#[test]
fn identifier_validation_does_not_change_ordinary_packet_strings() {
for input in [
"STONE",
"mod:UPPER",
"a:b:c",
"a+b:c",
"a/b:c",
"a b:c",
"a:é",
] {
let bytes = wire(input);
assert!(Ident::read(&mut Reader::new(&bytes)).is_err(), "{input:?}");
assert_eq!(Str::read(&mut Reader::new(&bytes)).unwrap(), input);
}
}
#[test]
fn configuration_and_play_custom_payloads_share_identifier_decoding() {
let mut body = wire("request_secret");
body.extend_from_slice(&[0, 1, 255]);
let cfg = configuration::Clientbound::decode(
horizon_protocol::ids::configuration::clientbound::CUSTOM_PAYLOAD,
&body,
)
.unwrap();
let configuration::Clientbound::CustomPayload(payload) = cfg else {
panic!("wrong packet")
};
assert_eq!(payload.channel, "minecraft:request_secret");
assert_eq!(payload.data, [0, 1, 255]);
let packet = play::Clientbound::decode(
horizon_protocol::ids::play::clientbound::CUSTOM_PAYLOAD,
&body,
)
.unwrap();
let play::Clientbound::CustomPayload(payload) = packet else {
panic!("wrong packet")
};
assert_eq!(payload.channel, "minecraft:request_secret");
assert_eq!(payload.data, [0, 1, 255]);
let invalid = wire("bad:CHANNEL");
assert!(
configuration::Clientbound::decode(
horizon_protocol::ids::configuration::clientbound::CUSTOM_PAYLOAD,
&invalid
)
.is_err()
);
assert!(
play::Clientbound::decode(
horizon_protocol::ids::play::clientbound::CUSTOM_PAYLOAD,
&invalid
)
.is_err()
);
}
@@ -0,0 +1,29 @@
package dev.horizonlab.parity;
import io.netty.buffer.Unpooled;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.Identifier;
/** Identifier.STREAM_CODEC against raw packet bytes; no game/server bootstrap. */
public final class VanillaProtocolIdentifierOracle {
public static void main(String[] args) throws Exception {
try (var output = Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8)) {
for (String line : Files.readAllLines(Path.of(args[0]), StandardCharsets.UTF_8)) {
var buffer = new FriendlyByteBuf(Unpooled.wrappedBuffer(HexFormat.of().parseHex(line)));
try {
String value = Identifier.STREAM_CODEC.decode(buffer).toString();
output.write("OK " + buffer.readerIndex() + " ");
for (int i = 0; i < value.length(); i++) output.write(String.format("%04x", (int) value.charAt(i)));
output.newLine();
} catch (RuntimeException error) {
output.write("ERR " + buffer.readerIndex());
output.newLine();
} finally { buffer.release(); }
}
}
}
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Identifier.STREAM_CODEC packet reads, independent of game resources."""
import json
import os
from pathlib import Path
import random
import subprocess
try:
from . import lab, oracle_harness as h, test_rust_parity as base
except ImportError:
import lab
import oracle_harness as h
import test_rust_parity as base
def fixtures():
def packet(value):
raw = value.encode('utf-8')
length, prefix = len(raw), bytearray()
while length > 127:
prefix.append((length & 127) | 128)
length >>= 7
prefix.append(length)
return bytes(prefix) + raw
values = ['', ':', 'stone', ':stone', 'minecraft:stone', 'mod:', 'a:b:c',
'A:b', 'a:B', 'a/b:c', 'a+b:c', 'a b:c', 'a:b/c.d-e_f09',
'stone' * 6553, 'a' * 32767, 'a' * 32768]
for code in range(256):
char = chr(code)
values.extend([char, 'a' + char + ':path', 'ns:dir/' + char])
rng = random.Random(0x1DE17)
alphabet = 'abcxyz01239._-/:'
for _ in range(2500):
values.append(''.join(rng.choice(alphabet) for _ in range(rng.randrange(32))))
cases = [packet(value) for value in values]
cases.extend([bytes.fromhex(x) for x in ['', '01', '036162', 'ffffffff0f',
'8080808080', '03eda080', '02c080', '0261ff']])
return list(dict.fromkeys(cases))
def main():
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / 'protocol-identifiers-parity'
build.mkdir(parents=True, exist_ok=True)
paths = ['crates/horizon-protocol/src/buf.rs',
'crates/horizon-protocol/src/packets/configuration.rs',
'crates/horizon-protocol/src/packets/play.rs',
'crates/horizon-protocol/examples/protocol_identifiers_probe.rs',
'crates/horizon-protocol/tests/identifier_codec.rs',
'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolIdentifierOracle.java',
'tools/test_protocol_identifiers_parity.py']
sources = [lab.ROOT / path for path in paths]
hashes = base.source_hashes(sources)
cases = fixtures()
inputs = build / 'inputs.txt'
inputs.write_text(''.join(case.hex() + '\n' for case in cases), encoding='utf-8')
input_hash = lab.digest(inputs, 'sha256')
classes, dependencies, _ = h.compile_oracle(build, 'VanillaProtocolIdentifierOracle',
{'client': jars['server']}, libraries, lock['minecraft']['java_major'])
h.java_run(build, classes, dependencies, 'VanillaProtocolIdentifierOracle',
[inputs, build / 'java.txt'], 'java')
h.run_logged(['cargo', 'build', '--locked', '-p', 'horizon-protocol',
'--example', 'protocol_identifiers_probe'], build, 'compile-rust',
env=dict(os.environ, CARGO_BUILD_JOBS='2'))
binary = lab.cargo_target() / 'debug/examples' / (
'protocol_identifiers_probe.exe' if os.name == 'nt' else 'protocol_identifiers_probe')
h.run_logged([binary, inputs, build / 'rust.txt'], build, 'rust')
matched = base.compare(build / 'java.txt', build / 'rust.txt')
base.ensure_sources_unchanged(sources, hashes)
if lab.digest(inputs, 'sha256') != input_hash:
raise AssertionError('Input fixtures changed')
report = {'minecraft': lock['minecraft']['version'], 'cases': len(cases),
'manifest_sha1': lock['minecraft']['manifest_sha1'],
'server_jar_sha256': lab.digest(jars['server'], 'sha256'),
'matched': matched, 'sources_sha256': hashes,
'input_sha256': input_hash,
'scope': 'Identifier.STREAM_CODEC decoding, normalized text and reader position; '
'includes real CustomPayload regression tests separately; encoding unchanged'}
lab.write_json(build / 'report.json', report)
print(f'{matched} packet identifier cases match pinned Minecraft: {build / "report.json"}')
if __name__ == '__main__':
main()