diff --git a/CHANGELOG.md b/CHANGELOG.md index f749429..441d738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## [1.1.20] — 2026-07-05 + +**Fix: IPv6 lease parser didn't validate the jail-name charset.** +`Ip6AllocPure::parseLeaseLine6` only rejected an empty name, while its +IPv4 twin (`IpAllocPure::parseLeaseLine` → `nameLooksValid`) restricts +the name to `[A-Za-z0-9._-]`, 1–64 chars. A hand-edited or corrupt +IPv6 lease line whose name held control characters, embedded +whitespace, or `..` / `/` path-traversal parsed as **valid** on the v6 +path and flowed downstream — an asymmetry with the v4 path that already +rejected them. + +`parseLeaseLine6` now applies the same charset check (a local +`leaseNameLooksValid` mirroring the v4 rule) and rejects trailing +whitespace in the address, closing the v4/v6 gap. Found in the +project-wide pure-module audit. + +New `ip6_alloc_pure_test` case rejects traversal / slash / shell-meta / +tab / oversized names and confirms a clean name still round-trips. + ## [1.1.19] — 2026-06-10 **Security: fail closed when `getpeereid` fails on the privops libnv diff --git a/cli/args.cpp b/cli/args.cpp index c66c7a9..a662b1c 100644 --- a/cli/args.cpp +++ b/cli/args.cpp @@ -753,7 +753,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) { args.noColor = true; break; } else if (strEq(argv[a], "--version")) { - std::cout << "crate 1.1.19" << std::endl; + std::cout << "crate 1.1.20" << std::endl; exit(0); } else if (auto argShort = isShort(argv[a])) { switch (argShort) { @@ -764,7 +764,7 @@ Args parseArguments(int argc, char** argv, unsigned &processed) { args.logProgress = true; break; case 'V': - std::cout << "crate 1.1.19" << std::endl; + std::cout << "crate 1.1.20" << std::endl; exit(0); default: err("unsupported short option '%s'", argv[a]); diff --git a/docs/trust-model.md b/docs/trust-model.md index cca3cec..5079d2c 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -4,7 +4,7 @@ operators on one machine) and contributors extending the privileged surface. -**Applies to:** 1.1.19 (rootless model + per-tenant authz series 1.1.12 → +**Applies to:** 1.1.20 (rootless model + per-tenant authz series 1.1.12 → 1.1.17 covering every privops verb that carries an operator-controlled ownership signal). For the ≤ 0.9.x setuid model and the migration, see [`rootless-migration.md`](rootless-migration.md). @@ -62,7 +62,7 @@ surface; 1.0.0 removed the setuid bit (`Makefile`, comment at the operator and delegates privileged operations to crated(8)"*). The single-trust-domain property did **not** disappear — it relocated. -Reasoning about isolation on 1.1.19 means reasoning about who can reach +Reasoning about isolation on 1.1.20 means reasoning about who can reach **privops**, not who can run `crate(1)`. --- diff --git a/docs/trust-model.uk.md b/docs/trust-model.uk.md index a1b8f74..7da4a15 100644 --- a/docs/trust-model.uk.md +++ b/docs/trust-model.uk.md @@ -4,7 +4,7 @@ (кілька операторів на одній машині), і контрибʼютори, які розширюють привілейовану поверхню. -**Стосується:** 1.1.19 (rootless-модель + серія per-tenant authz 1.1.12 → +**Стосується:** 1.1.20 (rootless-модель + серія per-tenant authz 1.1.12 → 1.1.17 покриває кожен privops-верб з operator-controlled ownership- сигналом). Про ≤ 0.9.x setuid-модель і міграцію див. [`rootless-migration.md`](rootless-migration.md). @@ -62,7 +62,7 @@ privops-сокета, ніколи admin-токен і ніколи Unix-сок privileged operations to crated(8)»*). Властивість «єдиний домен довіри» **не зникла** — вона переїхала. -Міркувати про ізоляцію на 1.1.19 — це міркувати про те, хто має доступ +Міркувати про ізоляцію на 1.1.20 — це міркувати про те, хто має доступ до **privops**, а не хто може запустити `crate(1)`. --- diff --git a/lib/ip6_alloc_pure.cpp b/lib/ip6_alloc_pure.cpp index 1a7e749..7517d0c 100644 --- a/lib/ip6_alloc_pure.cpp +++ b/lib/ip6_alloc_pure.cpp @@ -12,6 +12,20 @@ namespace Ip6AllocPure { namespace { +// 1.1.20: mirror of IpAllocPure's v4 lease-name check (alnum + . _ -, +// 1..64 chars). Duplicated here rather than exported so the two lease +// modules stay decoupled; the rule must stay in sync with the v4 twin. +bool leaseNameLooksValid(const std::string &n) { + if (n.empty() || n.size() > 64) return false; + for (char c : n) { + bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || c == '.' || c == '_' || c == '-'; + if (!ok) return false; + } + return true; +} + bool isHexChar(char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') @@ -249,7 +263,15 @@ std::string parseLeaseLine6(const std::string &line, Lease6 &out) { if (sp == std::string::npos) return "no space separator"; auto name = line.substr(0, sp); auto ip = line.substr(sp + 1); - if (name.empty()) return "empty name"; + // 1.1.20: validate the jail name charset, matching the v4 twin + // (IpAllocPure::parseLeaseLine -> nameLooksValid). Before this, a + // hand-edited or corrupt IPv6 lease line whose name held control + // chars, embedded whitespace, or `..`/`/` path-traversal parsed as + // VALID and flowed downstream (the v4 path already rejected these). + if (ip.find_first_of(" \t\r\n") != std::string::npos) + return "ip has internal whitespace"; + if (!leaseNameLooksValid(name)) + return "invalid jail name '" + name + "'"; Addr6 a{}; if (auto e = parseIp6(ip, a); !e.empty()) return "ip parse: " + e; diff --git a/tests/unit/ip6_alloc_pure_test.cpp b/tests/unit/ip6_alloc_pure_test.cpp index 4a251b6..0ae8242 100644 --- a/tests/unit/ip6_alloc_pure_test.cpp +++ b/tests/unit/ip6_alloc_pure_test.cpp @@ -220,6 +220,22 @@ ATF_TEST_CASE_BODY(lease_parse_rejects_garbage) { ATF_REQUIRE(!parseLeaseLine6("name not-an-ip", out).empty()); } +ATF_TEST_CASE_WITHOUT_HEAD(lease_parse_rejects_bad_name); +ATF_TEST_CASE_BODY(lease_parse_rejects_bad_name) { + // 1.1.20: the jail-name charset is now validated (matching the v4 + // twin). Names with path-traversal, control chars, or shell-ish + // punctuation must be rejected even though the IP is well-formed. + Lease6 out; + ATF_REQUIRE(!parseLeaseLine6("../../etc fd00::5", out).empty()); // traversal + ATF_REQUIRE(!parseLeaseLine6("a/b fd00::5", out).empty()); // slash + ATF_REQUIRE(!parseLeaseLine6("na$me fd00::5", out).empty()); // shell meta + ATF_REQUIRE(!parseLeaseLine6(std::string("na\tme fd00::5"), out).empty()); // tab in name + ATF_REQUIRE(!parseLeaseLine6(std::string(65, 'x') + " fd00::5", out).empty()); // >64 + // A clean name with a well-formed address still round-trips. + ATF_REQUIRE_EQ(parseLeaseLine6("web-1.app_2 fd00::5", out), std::string()); + ATF_REQUIRE_EQ(out.name, std::string("web-1.app_2")); +} + ATF_INIT_TEST_CASES(tcs) { ATF_ADD_TEST_CASE(tcs, parseIp6_full_form); ATF_ADD_TEST_CASE(tcs, parseIp6_shorthand_middle); @@ -239,4 +255,5 @@ ATF_INIT_TEST_CASES(tcs) { ATF_ADD_TEST_CASE(tcs, inPool_predicate); ATF_ADD_TEST_CASE(tcs, lease_round_trip); ATF_ADD_TEST_CASE(tcs, lease_parse_rejects_garbage); + ATF_ADD_TEST_CASE(tcs, lease_parse_rejects_bad_name); }