From d4a46ff247d1fe6568ffd8c54721e315a9b29b17 Mon Sep 17 00:00:00 2001 From: Weidong Cui Date: Fri, 28 Aug 2026 15:36:54 -0700 Subject: [PATCH 1/2] Add standalone hostname-filtering egress proxy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 239ec5f7-870a-4259-bcae-4ca85fb913a0 --- .github/workflows/ci.yml | 6 + Cargo.lock | 478 +++++++++++-- Cargo.toml | 2 + litebox_egress_proxy/Cargo.toml | 70 ++ litebox_egress_proxy/src/authority.rs | 268 +++++++ litebox_egress_proxy/src/config.rs | 314 +++++++++ litebox_egress_proxy/src/dns.rs | 577 ++++++++++++++++ litebox_egress_proxy/src/headers.rs | 420 +++++++++++ litebox_egress_proxy/src/lib.rs | 195 ++++++ litebox_egress_proxy/src/limits.rs | 83 +++ litebox_egress_proxy/src/listener.rs | 229 ++++++ litebox_egress_proxy/src/main.rs | 49 ++ litebox_egress_proxy/src/policy.rs | 549 +++++++++++++++ litebox_egress_proxy/src/proxy.rs | 592 ++++++++++++++++ litebox_egress_proxy/src/request_head.rs | 108 +++ litebox_egress_proxy/src/stream.rs | 331 +++++++++ litebox_egress_proxy/src/upstream.rs | 52 ++ litebox_egress_proxy/tests/loopback.rs | 845 +++++++++++++++++++++++ 18 files changed, 5124 insertions(+), 44 deletions(-) create mode 100644 litebox_egress_proxy/Cargo.toml create mode 100644 litebox_egress_proxy/src/authority.rs create mode 100644 litebox_egress_proxy/src/config.rs create mode 100644 litebox_egress_proxy/src/dns.rs create mode 100644 litebox_egress_proxy/src/headers.rs create mode 100644 litebox_egress_proxy/src/lib.rs create mode 100644 litebox_egress_proxy/src/limits.rs create mode 100644 litebox_egress_proxy/src/listener.rs create mode 100644 litebox_egress_proxy/src/main.rs create mode 100644 litebox_egress_proxy/src/policy.rs create mode 100644 litebox_egress_proxy/src/proxy.rs create mode 100644 litebox_egress_proxy/src/request_head.rs create mode 100644 litebox_egress_proxy/src/stream.rs create mode 100644 litebox_egress_proxy/src/upstream.rs create mode 100644 litebox_egress_proxy/tests/loopback.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 357bafe4c..a36e0ddc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,7 @@ jobs: AARCH64_CRATES: >- -p litebox -p litebox_common_linux + -p litebox_egress_proxy -p litebox_syscall_rewriter -p litebox_packager -p litebox_platform_linux_userland @@ -199,6 +200,7 @@ jobs: WINDOWS_CRATES: >- -p litebox_common_linux -p litebox_common_windows + -p litebox_egress_proxy -p litebox_syscall_rewriter -p litebox_packager -p litebox_broker_protocol @@ -290,6 +292,9 @@ jobs: # - `litebox_broker_userland` is allowed to have `std` access, # since it is the hosted userland broker executable. # + # - `litebox_egress_proxy` is allowed to have `std` access, since it + # is the hosted userland HTTP/HTTPS proxy executable. + # # - `litebox_platform_lvbs` has a custom target (`no_std`), so it does # not work with the current no_std checker. # @@ -351,6 +356,7 @@ jobs: -not -path './litebox_broker_transport_linux_userland/Cargo.toml' \ -not -path './litebox_broker_transport_windows_userland/Cargo.toml' \ -not -path './litebox_broker_userland/Cargo.toml' \ + -not -path './litebox_egress_proxy/Cargo.toml' \ -not -path './litebox_platform_linux_userland/Cargo.toml' \ -not -path './litebox_platform_windows_userland/Cargo.toml' \ -not -path './litebox_runner_linux_on_windows_userland/Cargo.toml' \ diff --git a/Cargo.lock b/Cargo.lock index f22752081..12a6cea2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,7 +16,7 @@ checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", ] @@ -115,6 +115,17 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -174,7 +185,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.106", ] [[package]] @@ -266,6 +277,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -331,7 +353,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -358,6 +380,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "console" version = "0.15.11" @@ -421,6 +453,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -430,6 +471,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -495,7 +551,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.106", ] [[package]] @@ -506,9 +562,15 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.106", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "der" version = "0.7.10" @@ -530,7 +592,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -551,7 +613,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -561,7 +623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.106", ] [[package]] @@ -606,7 +668,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -667,7 +729,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -816,7 +878,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -876,10 +938,22 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + [[package]] name = "getset" version = "0.1.7" @@ -888,7 +962,7 @@ checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -933,6 +1007,71 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand 0.10.2", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipnet", + "moka", + "once_cell", + "parking_lot", + "rand 0.10.2", + "smallvec", + "thiserror", + "tokio", + "tracing", +] + [[package]] name = "hmac" version = "0.12.1" @@ -990,6 +1129,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1003,6 +1148,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1227,7 +1373,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1235,6 +1381,9 @@ name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] [[package]] name = "iri-string" @@ -1288,7 +1437,56 @@ checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.106", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.106", ] [[package]] @@ -1355,7 +1553,7 @@ dependencies = [ "bitflags", "libc", "plain", - "redox_syscall", + "redox_syscall 0.7.3", ] [[package]] @@ -1530,6 +1728,23 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "litebox_egress_proxy" +version = "0.1.0" +dependencies = [ + "bytes", + "clap", + "hickory-resolver", + "http-body-util", + "httparse", + "hyper", + "hyper-util", + "ipnet", + "libc", + "thiserror", + "tokio", +] + [[package]] name = "litebox_packager" version = "0.1.0" @@ -1608,7 +1823,7 @@ dependencies = [ "object", "once_cell", "rand_chacha", - "rand_core", + "rand_core 0.6.4", "rangemap", "raw-cpuid", "rsa", @@ -1865,7 +2080,7 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1964,7 +2179,24 @@ checksum = "f8eec4327f127d4d18c54c8bfbf7b05d74cc9a1befdcc6283a241238ffbc84c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", ] [[package]] @@ -2014,7 +2246,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -2067,7 +2299,7 @@ checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -2138,6 +2370,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2173,7 +2409,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -2194,6 +2430,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" @@ -2226,7 +2485,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "rand", + "rand 0.8.6", ] [[package]] @@ -2307,6 +2566,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2314,7 +2584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.106", ] [[package]] @@ -2334,7 +2604,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "version_check", ] @@ -2353,6 +2623,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -2367,7 +2643,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2377,7 +2664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2389,6 +2676,12 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rangemap" version = "1.6.0" @@ -2424,6 +2717,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.7.3" @@ -2501,6 +2803,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ringbuf" version = "0.4.8" @@ -2524,7 +2840,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2537,6 +2853,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.2" @@ -2627,6 +2952,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -2654,7 +2985,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -2698,7 +3029,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2709,7 +3040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2735,7 +3066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2744,6 +3075,22 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -2838,7 +3185,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -2936,6 +3283,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2953,7 +3311,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -2962,6 +3320,12 @@ version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d0e35dc7d73976a53c7e6d7d177ef804a0c0ee774ec77bcc520c2216fd7cbe" +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" @@ -3020,7 +3384,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3080,7 +3444,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3171,7 +3535,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3258,6 +3622,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3282,6 +3652,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3422,7 +3803,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -3488,7 +3869,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3499,7 +3880,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3526,6 +3907,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -3789,7 +4179,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "synstructure", ] @@ -3810,7 +4200,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3830,7 +4220,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "synstructure", ] @@ -3870,5 +4260,5 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] diff --git a/Cargo.toml b/Cargo.toml index 8c92e48ef..1aa7295bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "litebox_common_windows", "litebox_common_optee", "litebox_common_lvbs", + "litebox_egress_proxy", "litebox_platform_linux_kernel", "litebox_platform_linux_userland", "litebox_platform_windows_userland", @@ -52,6 +53,7 @@ default-members = [ "litebox_common_windows", "litebox_common_optee", "litebox_common_lvbs", + "litebox_egress_proxy", "litebox_platform_linux_kernel", "litebox_platform_linux_userland", "litebox_platform_windows_userland", diff --git a/litebox_egress_proxy/Cargo.toml b/litebox_egress_proxy/Cargo.toml new file mode 100644 index 000000000..b00a1db8f --- /dev/null +++ b/litebox_egress_proxy/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "litebox_egress_proxy" +version = "0.1.0" +edition = "2024" + +[dependencies] +# `bytes` is the buffer type used by `hyper` bodies; only `std` support is +# needed here. +bytes = { version = "1.10", default-features = false, features = ["std"] } +# Argument parsing for the standalone executable. The workspace already uses +# `clap` with the derive interface elsewhere. +clap = { version = "4.5", default-features = false, features = [ + "derive", + "error-context", + "help", + "std", + "usage", +] } +# Controlled DNS. Only the tokio runtime integration is enabled; notably the +# `system-config` default feature is disabled so that the process can never +# read the host resolver configuration. +hickory-resolver = { version = "0.26.1", default-features = false, features = [ + "tokio", +] } +# Body combinators (`BoxBody`, `Empty`) for the proxied bodies. +http-body-util = { version = "0.1.3", default-features = false } +# HTTP/1 framing for both the client-facing server and the per-request upstream +# connection. No HTTP/2 support is enabled. +hyper = { version = "1.8", default-features = false, features = [ + "client", + "http1", + "server", +] } +# `hyper` runtime adapters (`TokioIo`, `TokioTimer`) only. +hyper-util = { version = "0.1.20", default-features = false, features = [ + "tokio", +] } +# Raw request-head validation before `hyper` applies RFC framing normalization. +httparse = { version = "1.10.1", default-features = false } +# Canonical IPv4 CIDRs for the proxy-only resolved-destination envelope. +ipnet = { version = "2.11", default-features = false } +thiserror = { version = "2.0", default-features = false, features = ["std"] } +tokio = { version = "1.50", default-features = false, features = [ + "io-util", + "net", + "rt", + "sync", + "time", +] } + +[dev-dependencies] +# Loopback integration tests drive the proxy on a multi-threaded runtime and +# use tokio's time control to exercise the stream timeouts. +tokio = { version = "1.50", default-features = false, features = [ + "io-util", + "macros", + "net", + "rt", + "rt-multi-thread", + "sync", + "test-util", + "time", +] } + +# Only the Linux inherited-listener path needs raw socket introspection. +[target.'cfg(target_os = "linux")'.dependencies] +libc = { version = "0.2", default-features = false } + +[lints] +workspace = true diff --git a/litebox_egress_proxy/src/authority.rs b/litebox_egress_proxy/src/authority.rs new file mode 100644 index 000000000..62b40a49a --- /dev/null +++ b/litebox_egress_proxy/src/authority.rs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Request authority canonicalization. +//! +//! Every request authority reaching the policy check goes through +//! [`parse_authority`], which shares [`Hostname`] canonicalization with the +//! policy parser. Ambiguous or reinterpretable forms are rejected before a URI +//! can be split differently by the proxy and by an upstream server. + +use core::fmt; + +use thiserror::Error; + +use crate::policy::{Hostname, HostnameError, PortRangeError, parse_port}; + +/// Default destination port of a plain `http` request target. +pub const DEFAULT_HTTP_PORT: u16 = 80; + +/// Reason an authority was rejected. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum AuthorityError { + /// The authority was empty. + #[error("authority is empty")] + Empty, + /// The authority contained non-ASCII bytes. + #[error("authority is not ASCII")] + NotAscii, + /// The authority contained a control byte or ASCII whitespace. + #[error("authority contains a control byte or whitespace")] + ControlOrWhitespace, + /// The authority contained a percent sign; percent-decoding could change + /// how the authority is interpreted. + #[error("authority contains percent-encoding")] + PercentEncoding, + /// The authority contained `@`, i.e. userinfo, which is never accepted. + #[error("authority contains userinfo")] + UserInfo, + /// The authority contained a delimiter that would let the authority be + /// re-split into a different URI. + #[error("authority contains a URI delimiter")] + Delimiter, + /// The authority contained an IP literal, including bracketed IPv6. + #[error("authority is an IP literal")] + IpLiteral, + /// The authority contained more than one colon. + #[error("authority contains more than one port separator")] + AmbiguousPort, + /// The request form requires an explicit port and none was present. + #[error("authority is missing an explicit port")] + MissingPort, + /// The port was not a valid destination port. + #[error("invalid port: {0}")] + Port(#[from] PortRangeError), + /// The host part was not a valid hostname. + #[error("invalid hostname: {0}")] + Hostname(#[from] HostnameError), +} + +/// A canonical request authority: an exact hostname and an effective +/// destination port. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RequestAuthority { + host: Hostname, + port: u16, +} + +impl RequestAuthority { + /// Returns the canonical hostname. + pub fn host(&self) -> &Hostname { + &self.host + } + + /// Returns the effective destination port. + pub fn port(&self) -> u16 { + self.port + } + + /// Renders the canonical `Host` header value for this authority. + /// + /// The port is omitted when it is the default `http` port, matching what + /// an origin server expects from a direct client. + pub fn host_header_value(&self) -> String { + if self.port == DEFAULT_HTTP_PORT { + self.host.as_str().to_owned() + } else { + format!("{}:{}", self.host, self.port) + } + } +} + +impl fmt::Display for RequestAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}:{}", self.host, self.port) + } +} + +/// Parses a request authority into canonical form. +/// +/// `default_port` supplies the effective port when the authority carries none. +/// Passing [`None`] requires an explicit port, which is what CONNECT +/// authority-form targets must use. +/// +/// Bytes that could make the proxy and an upstream server disagree about where +/// the authority ends -- control bytes, ASCII whitespace, percent-encoding, +/// `@`, and URI delimiters -- are rejected before any further interpretation. +pub fn parse_authority( + raw: &str, + default_port: Option, +) -> Result { + if raw.is_empty() { + return Err(AuthorityError::Empty); + } + if !raw.is_ascii() { + return Err(AuthorityError::NotAscii); + } + + for byte in raw.bytes() { + match byte { + // Everything up to and including SPACE, plus DEL. + 0x00..=0x20 | 0x7f => return Err(AuthorityError::ControlOrWhitespace), + b'%' => return Err(AuthorityError::PercentEncoding), + b'@' => return Err(AuthorityError::UserInfo), + b'[' | b']' => return Err(AuthorityError::IpLiteral), + b'/' | b'\\' | b'?' | b'#' => return Err(AuthorityError::Delimiter), + _ => {} + } + } + + let (host_part, port_part) = match raw.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (raw, None), + }; + + let port = match port_part { + Some(port) if port.contains(':') => return Err(AuthorityError::AmbiguousPort), + Some(port) => parse_port(port)?, + None => default_port.ok_or(AuthorityError::MissingPort)?, + }; + + // The hostname parser rejects IP literals and numeric forms, keeping + // addresses out of hostname policy entirely. + let host = Hostname::parse(host_part)?; + Ok(RequestAuthority { host, port }) +} + +/// Returns whether a `Host` header identifies the same canonical host and +/// effective port as the request target. +/// +/// `default_port` must be the default that applies to the request form: the +/// `http` default for absolute-form requests, and [`None`] for CONNECT, where +/// the header has to state the port explicitly to be unambiguous. +pub fn host_header_matches( + raw_header: &str, + target: &RequestAuthority, + default_port: Option, +) -> bool { + parse_authority(raw_header, default_port).is_ok_and(|header| &header == target) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn authority(raw: &str, default_port: Option) -> RequestAuthority { + parse_authority(raw, default_port).unwrap() + } + + #[test] + fn canonicalizes_host_and_port() { + let parsed = authority("Example.COM.:8080", None); + assert_eq!(parsed.host().as_str(), "example.com"); + assert_eq!(parsed.port(), 8080); + assert_eq!(parsed.to_string(), "example.com:8080"); + } + + #[test] + fn applies_default_port_only_when_provided() { + assert_eq!(authority("example.com", Some(DEFAULT_HTTP_PORT)).port(), 80); + assert_eq!( + parse_authority("example.com", None), + Err(AuthorityError::MissingPort) + ); + } + + #[test] + fn rejects_reinterpretable_authorities() { + assert_eq!(parse_authority("", None), Err(AuthorityError::Empty)); + assert_eq!( + parse_authority("exa\u{fe}mple.com:80", None), + Err(AuthorityError::NotAscii) + ); + assert_eq!( + parse_authority("example.com\r\n:80", None), + Err(AuthorityError::ControlOrWhitespace) + ); + assert_eq!( + parse_authority("example.com :80", None), + Err(AuthorityError::ControlOrWhitespace) + ); + assert_eq!( + parse_authority("exam%70le.com:80", None), + Err(AuthorityError::PercentEncoding) + ); + assert_eq!( + parse_authority("user@example.com:80", None), + Err(AuthorityError::UserInfo) + ); + assert_eq!( + parse_authority("example.com:80/evil.com", None), + Err(AuthorityError::Delimiter) + ); + assert_eq!( + parse_authority("example.com:80#frag", None), + Err(AuthorityError::Delimiter) + ); + assert_eq!( + parse_authority("[2001:db8::1]:443", None), + Err(AuthorityError::IpLiteral) + ); + assert_eq!( + parse_authority("example.com:80:443", None), + Err(AuthorityError::AmbiguousPort) + ); + } + + #[test] + fn rejects_ip_and_invalid_ports() { + assert!(matches!( + parse_authority("192.0.2.10:443", None), + Err(AuthorityError::Hostname(HostnameError::NumericForm)) + )); + assert!(matches!( + parse_authority("example.com:0", None), + Err(AuthorityError::Port(PortRangeError::ZeroPort)) + )); + assert!(matches!( + parse_authority("example.com:http", None), + Err(AuthorityError::Port(PortRangeError::NotANumber)) + )); + } + + #[test] + fn host_header_comparison() { + let target = authority("example.com:8080", None); + assert!(host_header_matches("Example.com:8080", &target, None)); + assert!(!host_header_matches("other.example:8080", &target, None)); + assert!(!host_header_matches("example.com", &target, None)); + assert!(!host_header_matches("example.com:80", &target, None)); + + let default_target = authority("example.com", Some(DEFAULT_HTTP_PORT)); + assert!(host_header_matches( + "example.com", + &default_target, + Some(DEFAULT_HTTP_PORT) + )); + assert!(host_header_matches( + "example.com:80", + &default_target, + Some(DEFAULT_HTTP_PORT) + )); + assert!(!host_header_matches( + "example.com:8080", + &default_target, + Some(DEFAULT_HTTP_PORT) + )); + } +} diff --git a/litebox_egress_proxy/src/config.rs b/litebox_egress_proxy/src/config.rs new file mode 100644 index 000000000..15b0fb7b6 --- /dev/null +++ b/litebox_egress_proxy/src/config.rs @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Executable configuration. +//! +//! Command-line arguments are parsed into typed, canonical values once. Only +//! those typed values reach the rest of the proxy: no raw argument string is +//! ever re-interpreted later. + +use std::net::{Ipv4Addr, SocketAddrV4}; + +use clap::{ArgGroup, Parser}; +use thiserror::Error; + +use crate::dns::{TargetPolicy, TargetPolicyError, is_permitted_dns_server_ipv4}; +use crate::listener::ListenerSource; +use crate::policy::{HostPolicy, HostRule, HostRuleError, PolicyError, parse_port}; + +/// Default DNS server port used when `--dns-server` carries no port. +pub const DEFAULT_DNS_PORT: u16 = 53; + +/// The standalone egress proxy for LiteBox sandboxes. +/// +/// Exactly one listener mode must be selected: `--listen` binds a loopback +/// listener itself, while `--listener-fd` adopts a listener that a launcher +/// bound and inherited to this process. +#[derive(Debug, Parser)] +#[command( + name = "litebox_egress_proxy", + about = "Hostname-filtering HTTP/HTTPS egress proxy", + group(ArgGroup::new("listener").required(true).args(["listen", "listener_fd"])) +)] +pub struct Cli { + /// Loopback address to bind, for example `127.0.0.1:0`. + #[arg(long, value_name = "IPV4:PORT")] + listen: Option, + + /// Inherited, already-bound loopback listener descriptor. + #[arg(long, value_name = "FD", conflicts_with = "listen")] + listener_fd: Option, + + /// The only DNS server used to resolve policy hostnames. + #[arg(long, value_name = "IPV4[:PORT]")] + dns_server: String, + + /// Allowed hostname and destination ports, repeatable. + #[arg(long = "allow-host", value_name = "HOST:PORT[-PORT]")] + allow_host: Vec, + + /// Additional proxy-only CIDRs to which policy hostnames may resolve. + /// + /// Public IPv4 targets are permitted by default. This option deliberately + /// does not grant the guest direct access to the CIDR. + #[arg(long = "allow-resolved-destination", value_name = "CIDR")] + allow_resolved_destination: Vec, +} + +/// Reason the arguments were rejected. +#[derive(Debug, Error)] +pub enum ConfigError { + /// `--listen` was not a socket address. + #[error("--listen must be an IPv4 address and port, for example 127.0.0.1:0")] + ListenAddress, + /// `--listen` was not canonical IPv4 loopback. + #[error("--listen must use the canonical loopback address 127.0.0.1")] + ListenNotLoopback, + /// `--dns-server` was not an IPv4 address with an optional port. + #[error("--dns-server must be an IPv4 address with an optional port")] + DnsServerAddress, + /// `--dns-server` was not an externally usable unicast address. + #[error( + "--dns-server must be a non-loopback unicast IPv4 address; unspecified, multicast, \ + broadcast, and reserved addresses are rejected" + )] + DnsServerNotExternal, + /// An `--allow-host` rule was invalid. + #[error("invalid --allow-host rule: {0}")] + Rule(#[from] HostRuleError), + /// The rules could not be combined into a policy. + #[error("invalid policy: {0}")] + Policy(#[from] PolicyError), + /// A proxy-only resolved-destination CIDR was invalid. + #[error("invalid --allow-resolved-destination CIDR: {0}")] + ResolvedDestination(String), + /// Too many resolved-destination CIDRs were configured. + #[error("invalid resolved-destination policy: {0}")] + TargetPolicy(#[from] TargetPolicyError), +} + +/// The validated configuration of one proxy process. +#[derive(Clone, Debug)] +pub struct ProxyConfig { + /// Where the listener comes from. + pub listener: ListenerSource, + /// The single DNS server used for startup resolution. + pub dns_server: SocketAddrV4, + /// The immutable hostname policy. + pub policy: HostPolicy, + /// Permitted resolved upstream addresses. + pub targets: TargetPolicy, +} + +impl Cli { + /// Converts parsed arguments into a validated configuration. + pub fn into_config(self) -> Result { + let listener = match (self.listen, self.listener_fd) { + (Some(address), _) => ListenerSource::Bind(parse_listen_address(&address)?), + (None, Some(descriptor)) => ListenerSource::Inherited(descriptor), + // `clap` enforces that one of the two is present. + (None, None) => return Err(ConfigError::ListenAddress), + }; + + let dns_server = parse_dns_server(&self.dns_server)?; + + let mut rules = Vec::with_capacity(self.allow_host.len()); + for rule in &self.allow_host { + rules.push(rule.parse::()?); + } + let policy = HostPolicy::from_rules(rules)?; + let targets = TargetPolicy::new( + self.allow_resolved_destination + .iter() + .map(|cidr| parse_resolved_destination(cidr)) + .collect::, _>>()?, + )?; + + Ok(ProxyConfig { + listener, + dns_server, + policy, + targets, + }) + } +} + +/// Parses one canonical proxy-only resolved-destination CIDR. +fn parse_resolved_destination(raw: &str) -> Result { + let network: ipnet::Ipv4Net = raw + .parse() + .map_err(|error| ConfigError::ResolvedDestination(format!("{raw}: {error}")))?; + if network.addr() != network.network() { + return Err(ConfigError::ResolvedDestination(format!( + "{raw}: network address contains host bits" + ))); + } + Ok(network) +} + +/// Parses `--listen`, which is restricted to canonical IPv4 loopback. +fn parse_listen_address(raw: &str) -> Result { + let address: SocketAddrV4 = raw.parse().map_err(|_| ConfigError::ListenAddress)?; + if *address.ip() != Ipv4Addr::LOCALHOST { + return Err(ConfigError::ListenNotLoopback); + } + Ok(address) +} + +/// Parses `--dns-server`, which accepts an optional port. +/// +/// The server must be an externally usable unicast IPv4 address. Private and +/// link-local servers are accepted because this address is selected directly +/// by the trusted operator rather than learned from an untrusted DNS answer. +fn parse_dns_server(raw: &str) -> Result { + let (address, port) = match raw.split_once(':') { + Some((address, port)) => ( + address, + parse_port(port).map_err(|_| ConfigError::DnsServerAddress)?, + ), + None => (raw, DEFAULT_DNS_PORT), + }; + + let address: Ipv4Addr = address.parse().map_err(|_| ConfigError::DnsServerAddress)?; + if !is_permitted_dns_server_ipv4(address) { + return Err(ConfigError::DnsServerNotExternal); + } + Ok(SocketAddrV4::new(address, port)) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::policy::Hostname; + + fn parse(arguments: &[&str]) -> Result { + let mut all = vec!["litebox_egress_proxy"]; + all.extend_from_slice(arguments); + Cli::try_parse_from(all).unwrap().into_config() + } + + #[test] + fn parses_a_standalone_configuration() { + let config = parse(&[ + "--listen", + "127.0.0.1:0", + "--dns-server", + "9.9.9.9", + "--allow-host", + "Example.COM:443", + "--allow-host", + "example.com:8000-8100", + "--allow-resolved-destination", + "10.0.0.0/8", + ]) + .unwrap(); + + assert_eq!( + config.listener, + ListenerSource::Bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + ); + assert_eq!( + config.dns_server, + SocketAddrV4::new(Ipv4Addr::new(9, 9, 9, 9), DEFAULT_DNS_PORT) + ); + + let host = Hostname::parse("example.com").unwrap(); + assert_eq!(config.policy.len(), 1); + assert!(config.policy.allows(&host, 443)); + assert!(config.policy.allows(&host, 8100)); + assert!(!config.policy.allows(&host, 80)); + assert!(config.targets.allows(Ipv4Addr::new(10, 1, 2, 3))); + } + + #[test] + fn parses_an_inherited_listener_configuration() { + let config = parse(&["--listener-fd", "7", "--dns-server", "9.9.9.9:5353"]).unwrap(); + assert_eq!(config.listener, ListenerSource::Inherited(7)); + assert_eq!(config.dns_server.port(), 5353); + assert!(config.policy.is_empty()); + } + + #[test] + fn listener_modes_are_mutually_exclusive_and_required() { + assert!( + Cli::try_parse_from([ + "litebox_egress_proxy", + "--listen", + "127.0.0.1:0", + "--listener-fd", + "3", + "--dns-server", + "9.9.9.9", + ]) + .is_err() + ); + assert!(Cli::try_parse_from(["litebox_egress_proxy", "--dns-server", "9.9.9.9"]).is_err()); + } + + #[test] + fn rejects_non_loopback_listen_addresses() { + assert!(matches!( + parse(&["--listen", "0.0.0.0:8080", "--dns-server", "9.9.9.9"]), + Err(ConfigError::ListenNotLoopback) + )); + assert!(matches!( + parse(&["--listen", "127.0.0.2:8080", "--dns-server", "9.9.9.9"]), + Err(ConfigError::ListenNotLoopback) + )); + assert!(matches!( + parse(&["--listen", "localhost:8080", "--dns-server", "9.9.9.9"]), + Err(ConfigError::ListenAddress) + )); + } + + #[test] + fn accepts_explicit_private_dns_servers_but_rejects_invalid_endpoints() { + assert!(parse(&["--listen", "127.0.0.1:0", "--dns-server", "10.0.0.1"]).is_ok()); + assert!(parse(&["--listen", "127.0.0.1:0", "--dns-server", "169.254.169.253",]).is_ok()); + + for server in ["127.0.0.1:5353", "224.0.0.1", "240.0.0.1", "0.0.0.0"] { + assert!( + matches!( + parse(&["--listen", "127.0.0.1:0", "--dns-server", server]), + Err(ConfigError::DnsServerNotExternal) + ), + "{server} must be rejected" + ); + } + assert!(matches!( + parse(&["--listen", "127.0.0.1:0", "--dns-server", "not-an-address"]), + Err(ConfigError::DnsServerAddress) + )); + } + + #[test] + fn rejects_reserved_dns_ports_in_rules() { + assert!(matches!( + parse(&[ + "--listen", + "127.0.0.1:0", + "--dns-server", + "9.9.9.9", + "--allow-host", + "example.com:853", + ]), + Err(ConfigError::Rule(_)) + )); + } + + #[test] + fn rejects_invalid_resolved_destination_cidr() { + assert!(matches!( + parse(&[ + "--listen", + "127.0.0.1:0", + "--dns-server", + "9.9.9.9", + "--allow-resolved-destination", + "10.0.0.1/8", + ]), + Err(ConfigError::ResolvedDestination(_)) + )); + } +} diff --git a/litebox_egress_proxy/src/dns.rs b/litebox_egress_proxy/src/dns.rs new file mode 100644 index 000000000..135f80c29 --- /dev/null +++ b/litebox_egress_proxy/src/dns.rs @@ -0,0 +1,577 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Controlled DNS resolution and immutable startup pinning. +//! +//! Every allowed hostname is resolved exactly once, before the listener is +//! announced ready, and the resulting addresses are pinned for the lifetime of +//! the process. Request handling never performs a lookup, so an upstream +//! connection can only target an address that was validated at startup. + +use core::future::Future; +use core::pin::Pin; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddrV4}; +use std::sync::Arc; + +use hickory_resolver::Resolver; +use hickory_resolver::config::{ + ConnectionConfig, LookupIpStrategy, NameServerConfig, ResolveHosts, ResolverConfig, + ResolverOpts, +}; +use hickory_resolver::net::runtime::TokioRuntimeProvider; +use hickory_resolver::proto::rr::{Name, RData}; +use ipnet::Ipv4Net; +use thiserror::Error; +use tokio::task::JoinSet; +use tokio::time::timeout; + +use crate::limits::{ + DNS_ATTEMPT_TIMEOUT, DNS_QUERY_TIMEOUT, MAX_CONCURRENT_STARTUP_RESOLUTIONS, + MAX_PINNED_ADDRESSES_PER_HOST, MAX_RESOLVED_DESTINATION_RULES, MAX_UDP_DNS_RESPONSE_BYTES, +}; +use crate::policy::{HostPolicy, Hostname}; + +/// A future returned by a [`HostResolver`]. +pub type ResolveFuture<'a> = + Pin, ResolveError>> + Send + 'a>>; + +/// Resolves policy hostnames to upstream IPv4 addresses. +/// +/// # Contract +/// +/// Implementations return DNS data only. [`PinnedTable::resolve`] applies the +/// destination policy before retaining any address, keeping target safety in +/// one path for production and injected resolvers. +pub trait HostResolver: Send + Sync + 'static { + /// Resolves one canonical hostname. + /// + /// Returning an empty vector is a protocol error; implementations should + /// return [`ResolveError::NoAddresses`] instead. + fn resolve(&self, host: Hostname) -> ResolveFuture<'_>; +} + +/// Reason a hostname could not be resolved into pinned addresses. +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum ResolveError { + /// The canonical name could not be expressed as a DNS name. + #[error("hostname is not a valid DNS name: {0}")] + InvalidName(String), + /// The configured DNS server did not answer successfully. + #[error("DNS query failed: {0}")] + Query(String), + /// The answer contained no IPv4 address. + #[error("DNS answer contained no IPv4 address")] + NoAddresses, + /// The answer contained an address that is not a permitted upstream + /// target. + #[error("DNS answer contained non-global address {0}")] + UnsafeAddress(Ipv4Addr), +} + +/// Reason startup pinning failed. +#[derive(Clone, Debug, Error)] +pub enum PinError { + /// One hostname failed to resolve. + #[error("failed to resolve `{host}`: {source}")] + Host { + /// The hostname that failed. + host: Hostname, + /// The underlying resolution failure. + source: ResolveError, + }, + /// One hostname exceeded the per-name DNS timeout. + #[error("timed out resolving `{host}`")] + Timeout { + /// The hostname that timed out. + host: Hostname, + }, + /// A resolution task could not be run to completion. + #[error("resolution task failed: {0}")] + Task(String), +} + +/// Returns whether `address` is a public proxy destination. +/// +/// Only globally routable unicast addresses are permitted. This is a +/// proxy-specific safety rule: hostname policy must never be able to reach the +/// host's own networks, link-local metadata services, or any special-purpose +/// range. +pub fn is_public_proxy_target(address: Ipv4Addr) -> bool { + let [a, b, c, _] = address.octets(); + + // 0.0.0.0/8 "this network", including the unspecified address. + if a == 0 { + return false; + } + // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. + if address.is_private() { + return false; + } + // 100.64.0.0/10 shared address space (carrier-grade NAT). + if a == 100 && (64..128).contains(&b) { + return false; + } + // 127.0.0.0/8 loopback. + if address.is_loopback() { + return false; + } + // 169.254.0.0/16 link-local. + if address.is_link_local() { + return false; + } + // 192.0.0.0/24 IETF protocol assignments. + if a == 192 && b == 0 && c == 0 { + return false; + } + // 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 documentation. + if (a == 192 && b == 0 && c == 2) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + { + return false; + } + // 198.18.0.0/15 benchmarking. + if a == 198 && (b == 18 || b == 19) { + return false; + } + // 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, 255.255.255.255 broadcast. + if address.is_multicast() || a >= 240 { + return false; + } + true +} + +/// Returns whether an explicitly configured DNS server is externally usable. +/// +/// Private and link-local unicast addresses are accepted because the trusted +/// operator selects this endpoint directly. Resolved proxy targets use the +/// stricter [`TargetPolicy`] instead. +pub fn is_permitted_dns_server_ipv4(address: Ipv4Addr) -> bool { + let first = address.octets()[0]; + first != 0 && !address.is_loopback() && !address.is_multicast() && first < 240 +} + +/// The immutable envelope for addresses learned from DNS. +/// +/// Public destinations are accepted by default. Additional canonical CIDRs +/// permit private services through the proxy without granting the guest direct +/// access to those ranges. Unspecified, loopback, multicast, broadcast, and +/// reserved addresses remain hard-denied even if an additional CIDR covers +/// them. +#[derive(Clone, Debug, Default)] +pub struct TargetPolicy { + additional: Vec, +} + +/// Reason a resolved-destination policy was rejected. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum TargetPolicyError { + /// More than the fixed number of additional CIDRs was supplied. + #[error( + "resolved-destination policy contains more than {MAX_RESOLVED_DESTINATION_RULES} CIDRs" + )] + TooManyCidrs, +} + +impl TargetPolicy { + /// Creates a target policy from additional canonical IPv4 CIDRs. + pub fn new(mut additional: Vec) -> Result { + if additional.len() > MAX_RESOLVED_DESTINATION_RULES { + return Err(TargetPolicyError::TooManyCidrs); + } + additional.sort_unstable(); + additional.dedup(); + Ok(Self { additional }) + } + + /// Returns a policy that permits public destinations only. + pub fn public_only() -> Self { + Self::default() + } + + /// Returns whether a resolved address may be pinned. + pub fn allows(&self, address: Ipv4Addr) -> bool { + if !is_permitted_dns_server_ipv4(address) { + return false; + } + is_public_proxy_target(address) + || self + .additional + .iter() + .any(|network| network.contains(&address)) + } +} + +/// The production resolver: a stub resolver bound to one configured server. +/// +/// The resolver never consults the host's resolver configuration or hosts +/// file, sends queries only to the configured server, and falls back from UDP +/// to TCP when a response is truncated or a UDP exchange fails. +pub struct ConfiguredDnsResolver { + resolver: Resolver, +} + +impl ConfiguredDnsResolver { + /// Builds a resolver that queries only `server`. + pub fn new(server: SocketAddrV4) -> Result { + let mut udp = ConnectionConfig::udp(); + udp.port = server.port(); + let mut tcp = ConnectionConfig::tcp(); + tcp.port = server.port(); + + let name_server = NameServerConfig::new(IpAddr::V4(*server.ip()), true, vec![udp, tcp]); + let config = ResolverConfig::from_parts(None, Vec::new(), vec![name_server]); + + // `ResolverOpts` is `#[non_exhaustive]`, so the defaults have to be + // adjusted field by field rather than through a struct literal. + #[allow(clippy::field_reassign_with_default)] + let mut options = ResolverOpts::default(); + // Query fully qualified names only; there is no search list and no + // host-configured domain. + options.ndots = 0; + options.timeout = DNS_ATTEMPT_TIMEOUT; + options.attempts = 1; + options.try_tcp_on_error = true; + options.edns0 = true; + options.edns_payload_len = MAX_UDP_DNS_RESPONSE_BYTES; + options.ip_strategy = LookupIpStrategy::Ipv4Only; + options.use_hosts_file = ResolveHosts::Never; + options.num_concurrent_reqs = 1; + options.preserve_intermediates = false; + // Pinning happens once; a cache would only add state that must not + // influence later behaviour. + options.cache_size = 0; + + let resolver = Resolver::builder_with_config(config, TokioRuntimeProvider::default()) + .with_options(options) + .build() + .map_err(|error| ResolveError::Query(error.to_string()))?; + Ok(Self { resolver }) + } +} + +impl HostResolver for ConfiguredDnsResolver { + fn resolve(&self, host: Hostname) -> ResolveFuture<'_> { + Box::pin(async move { + // The trailing dot makes the query fully qualified, so no search + // list can ever be appended. + let name = Name::from_ascii(format!("{host}.")) + .map_err(|error| ResolveError::InvalidName(error.to_string()))?; + + let lookup = self + .resolver + .ipv4_lookup(name) + .await + .map_err(|error| ResolveError::Query(error.to_string()))?; + + let mut addresses: Vec = Vec::new(); + for record in lookup.answers() { + // CNAME chains are followed by the resolver itself; only the + // terminal A records matter here. + let RData::A(address) = &record.data else { + continue; + }; + let address = address.0; + if addresses.len() < MAX_PINNED_ADDRESSES_PER_HOST && !addresses.contains(&address) + { + addresses.push(address); + } + } + + if addresses.is_empty() { + return Err(ResolveError::NoAddresses); + } + Ok(addresses) + }) + } +} + +/// The immutable startup resolution table. +/// +/// Addresses are stored in the order the resolver returned them, which is also +/// the order in which upstream connection attempts are made. +#[derive(Clone, Debug, Default)] +pub struct PinnedTable { + entries: HashMap>, +} + +impl PinnedTable { + /// Resolves every hostname of `policy` and pins the results. + /// + /// At most [`MAX_CONCURRENT_STARTUP_RESOLUTIONS`] lookups run at a time, + /// and each lookup is bounded by [`DNS_QUERY_TIMEOUT`]. A single failure + /// fails the whole table: startup must fail closed. + pub async fn resolve( + policy: &HostPolicy, + targets: &TargetPolicy, + resolver: Arc, + ) -> Result { + let mut pending = policy.hostnames().cloned().collect::>().into_iter(); + let mut tasks: JoinSet<(Hostname, Result, PinError>)> = JoinSet::new(); + let mut entries = HashMap::with_capacity(policy.len()); + + loop { + while tasks.len() < MAX_CONCURRENT_STARTUP_RESOLUTIONS { + let Some(host) = pending.next() else { + break; + }; + let resolver = Arc::clone(&resolver); + tasks.spawn(async move { + let outcome = + match timeout(DNS_QUERY_TIMEOUT, resolver.resolve(host.clone())).await { + Ok(Ok(addresses)) => Ok(addresses), + Ok(Err(source)) => Err(PinError::Host { + host: host.clone(), + source, + }), + Err(_elapsed) => Err(PinError::Timeout { host: host.clone() }), + }; + (host, outcome) + }); + } + + let Some(joined) = tasks.join_next().await else { + break; + }; + let (host, outcome) = joined.map_err(|error| PinError::Task(error.to_string()))?; + let mut addresses = outcome?; + if addresses.is_empty() { + return Err(PinError::Host { + host, + source: ResolveError::NoAddresses, + }); + } + for address in &addresses { + if !targets.allows(*address) { + // Defence in depth: a resolver that violates its contract + // must not be able to pin an unsafe address. + return Err(PinError::Host { + host, + source: ResolveError::UnsafeAddress(*address), + }); + } + } + addresses.truncate(MAX_PINNED_ADDRESSES_PER_HOST); + entries.insert(host, addresses); + } + + Ok(Self { entries }) + } + + /// Returns the pinned addresses for `host`, in startup order. + /// + /// An unknown hostname yields an empty slice; the policy check has already + /// rejected such a request before this point. + pub fn addresses(&self, host: &Hostname) -> &[Ipv4Addr] { + self.entries.get(host).map_or(&[], Vec::as_slice) + } + + /// Returns the number of pinned hostnames. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns whether nothing is pinned. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::HostRule; + + struct StaticResolver { + answers: HashMap, ResolveError>>, + } + + impl HostResolver for StaticResolver { + fn resolve(&self, host: Hostname) -> ResolveFuture<'_> { + let answer = self + .answers + .get(host.as_str()) + .cloned() + .unwrap_or(Err(ResolveError::NoAddresses)); + Box::pin(async move { answer }) + } + } + + fn policy(rules: &[&str]) -> HostPolicy { + HostPolicy::from_rules( + rules + .iter() + .map(|rule| rule.parse::().unwrap()) + .collect::>(), + ) + .unwrap() + } + + fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + #[test] + fn global_unicast_addresses_are_permitted() { + assert!(is_public_proxy_target(Ipv4Addr::new(93, 184, 216, 34))); + assert!(is_public_proxy_target(Ipv4Addr::new(8, 8, 8, 8))); + assert!(is_public_proxy_target(Ipv4Addr::new(1, 1, 1, 1))); + } + + #[test] + fn special_purpose_addresses_are_rejected() { + for address in [ + Ipv4Addr::UNSPECIFIED, + Ipv4Addr::new(0, 1, 2, 3), + Ipv4Addr::new(10, 0, 0, 1), + Ipv4Addr::new(172, 16, 0, 1), + Ipv4Addr::new(192, 168, 1, 1), + Ipv4Addr::new(100, 64, 0, 1), + Ipv4Addr::LOCALHOST, + Ipv4Addr::new(169, 254, 169, 254), + Ipv4Addr::new(192, 0, 0, 1), + Ipv4Addr::new(192, 0, 2, 1), + Ipv4Addr::new(198, 51, 100, 1), + Ipv4Addr::new(203, 0, 113, 1), + Ipv4Addr::new(198, 18, 0, 1), + Ipv4Addr::new(224, 0, 0, 1), + Ipv4Addr::new(240, 0, 0, 1), + Ipv4Addr::BROADCAST, + ] { + assert!( + !is_public_proxy_target(address), + "{address} must not be a permitted upstream target" + ); + } + } + + #[test] + fn pins_every_policy_hostname() { + let resolver = StaticResolver { + answers: [ + ( + "a.example".to_owned(), + Ok(vec![ + Ipv4Addr::new(93, 184, 216, 34), + Ipv4Addr::new(1, 1, 1, 1), + ]), + ), + ("b.example".to_owned(), Ok(vec![Ipv4Addr::new(8, 8, 4, 4)])), + ] + .into_iter() + .collect(), + }; + + let policy = policy(&["a.example:80", "b.example:443"]); + let table = runtime() + .block_on(PinnedTable::resolve( + &policy, + &TargetPolicy::public_only(), + Arc::new(resolver), + )) + .unwrap(); + + assert_eq!(table.len(), 2); + assert_eq!( + table.addresses(&Hostname::parse("a.example").unwrap()), + [Ipv4Addr::new(93, 184, 216, 34), Ipv4Addr::new(1, 1, 1, 1)] + ); + assert!( + table + .addresses(&Hostname::parse("c.example").unwrap()) + .is_empty() + ); + } + + #[test] + fn unresolved_hostname_fails_startup() { + let resolver = StaticResolver { + answers: HashMap::new(), + }; + let policy = policy(&["a.example:80"]); + let error = runtime() + .block_on(PinnedTable::resolve( + &policy, + &TargetPolicy::public_only(), + Arc::new(resolver), + )) + .unwrap_err(); + assert!(matches!( + error, + PinError::Host { + source: ResolveError::NoAddresses, + .. + } + )); + } + + #[test] + fn unsafe_address_fails_startup() { + let resolver = StaticResolver { + answers: [("a.example".to_owned(), Ok(vec![Ipv4Addr::LOCALHOST]))] + .into_iter() + .collect(), + }; + let policy = policy(&["a.example:80"]); + let error = runtime() + .block_on(PinnedTable::resolve( + &policy, + &TargetPolicy::public_only(), + Arc::new(resolver), + )) + .unwrap_err(); + assert!(matches!( + error, + PinError::Host { + source: ResolveError::UnsafeAddress(_), + .. + } + )); + } + + #[test] + fn pinned_addresses_are_bounded_per_host() { + let many = (1..=40) + .map(|index| Ipv4Addr::new(93, 184, 216, index)) + .collect::>(); + let resolver = StaticResolver { + answers: [("a.example".to_owned(), Ok(many))].into_iter().collect(), + }; + let policy = policy(&["a.example:80"]); + let table = runtime() + .block_on(PinnedTable::resolve( + &policy, + &TargetPolicy::public_only(), + Arc::new(resolver), + )) + .unwrap(); + assert_eq!( + table + .addresses(&Hostname::parse("a.example").unwrap()) + .len(), + MAX_PINNED_ADDRESSES_PER_HOST + ); + } + + #[test] + fn additional_target_cidr_allows_private_but_not_loopback() { + let targets = TargetPolicy::new(vec!["10.0.0.0/8".parse().unwrap()]).unwrap(); + assert!(targets.allows(Ipv4Addr::new(10, 1, 2, 3))); + assert!(!targets.allows(Ipv4Addr::LOCALHOST)); + } + + #[test] + fn additional_target_cidrs_are_bounded() { + let networks = (0..=MAX_RESOLVED_DESTINATION_RULES) + .map(|index| format!("10.{index}.0.0/16").parse().unwrap()) + .collect(); + assert_eq!( + TargetPolicy::new(networks).unwrap_err(), + TargetPolicyError::TooManyCidrs + ); + } +} diff --git a/litebox_egress_proxy/src/headers.rs b/litebox_egress_proxy/src/headers.rs new file mode 100644 index 000000000..2c1a9f528 --- /dev/null +++ b/litebox_egress_proxy/src/headers.rs @@ -0,0 +1,420 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Header handling: framing validation and hop-by-hop removal. +//! +//! `hyper` already rejects most malformed HTTP/1 messages while parsing, such +//! as obsolete line folding and whitespace before a header colon. The checks +//! here are applied on top of that, so that framing ambiguity is rejected by +//! this proxy's own rules rather than by whatever a particular parser version +//! happens to tolerate. + +use hyper::header::{HeaderMap, HeaderName}; +use hyper::{Version, header}; +use thiserror::Error; + +/// Headers that never travel beyond a single hop. +const HOP_BY_HOP_HEADERS: [&str; 9] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Reason a message was rejected as malformed or framing-ambiguous. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum FramingError { + /// The message carried both `Content-Length` and `Transfer-Encoding`. + #[error("message carries both Content-Length and Transfer-Encoding")] + ConflictingLength, + /// The message carried conflicting `Content-Length` values. + #[error("message carries conflicting Content-Length values")] + ConflictingContentLength, + /// A `Content-Length` value was not a single decimal number. + #[error("Content-Length is not a decimal number")] + InvalidContentLength, + /// A transfer coding other than a single `chunked` was requested. + #[error("unsupported transfer coding")] + UnsupportedTransferCoding, + /// `Transfer-Encoding` was used on an HTTP/1.0 message. + #[error("Transfer-Encoding is not valid for HTTP/1.0")] + TransferEncodingOnHttp10, + /// A header value was not valid ASCII text. + #[error("header value is not valid ASCII")] + NonAsciiHeaderValue, + /// More than one `Host` header was present. + #[error("message carries more than one Host header")] + DuplicateHost, + /// A CONNECT request carried a body. + #[error("CONNECT request carries a body")] + BodyOnConnect, +} + +/// Validates the framing headers of a client request. +/// +/// Returns an error for every form that could be framed differently by this +/// proxy and by an upstream server. +pub fn validate_request_framing(headers: &HeaderMap, version: Version) -> Result<(), FramingError> { + validate_message_framing(headers, version)?; + + if headers.get_all(header::HOST).iter().count() > 1 { + return Err(FramingError::DuplicateHost); + } + + Ok(()) +} + +/// Validates request framing before `hyper` normalizes the raw header list. +pub(crate) fn validate_raw_request_framing( + method: &str, + version: u8, + headers: &[httparse::Header<'_>], +) -> Result<(), FramingError> { + let transfer_encoding = raw_header_values(headers, b"transfer-encoding"); + let content_length = raw_header_values(headers, b"content-length"); + + if method == "CONNECT" && (!transfer_encoding.is_empty() || !content_length.is_empty()) { + return Err(FramingError::BodyOnConnect); + } + if raw_header_values(headers, b"host").len() > 1 { + return Err(FramingError::DuplicateHost); + } + if !transfer_encoding.is_empty() && !content_length.is_empty() { + return Err(FramingError::ConflictingLength); + } + + if !transfer_encoding.is_empty() { + if version == 0 { + return Err(FramingError::TransferEncodingOnHttp10); + } + validate_raw_chunked_only(&transfer_encoding)?; + } + if !content_length.is_empty() { + validate_raw_single_content_length(&content_length)?; + } + + Ok(()) +} + +/// Validates the framing headers of an upstream response. +pub fn validate_response_framing( + headers: &HeaderMap, + version: Version, +) -> Result<(), FramingError> { + validate_message_framing(headers, version) +} + +/// Validates framing shared by requests and responses. +fn validate_message_framing(headers: &HeaderMap, version: Version) -> Result<(), FramingError> { + let has_transfer_encoding = headers.contains_key(header::TRANSFER_ENCODING); + let has_content_length = headers.contains_key(header::CONTENT_LENGTH); + + if has_transfer_encoding && has_content_length { + return Err(FramingError::ConflictingLength); + } + + if has_transfer_encoding { + if version == Version::HTTP_10 { + return Err(FramingError::TransferEncodingOnHttp10); + } + validate_chunked_only(headers)?; + } + + if has_content_length { + validate_single_content_length(headers)?; + } + + Ok(()) +} + +/// Validates that a CONNECT request carries no body framing at all. +pub fn validate_connect_framing(headers: &HeaderMap) -> Result<(), FramingError> { + if headers.contains_key(header::TRANSFER_ENCODING) + || headers.contains_key(header::CONTENT_LENGTH) + { + return Err(FramingError::BodyOnConnect); + } + if headers.get_all(header::HOST).iter().count() > 1 { + return Err(FramingError::DuplicateHost); + } + Ok(()) +} + +/// Rejects anything but exactly one `chunked` transfer coding. +fn validate_chunked_only(headers: &HeaderMap) -> Result<(), FramingError> { + let mut codings = 0_usize; + for value in headers.get_all(header::TRANSFER_ENCODING) { + let text = value + .to_str() + .map_err(|_| FramingError::NonAsciiHeaderValue)?; + for coding in text.split(',') { + let coding = coding.trim(); + if coding.is_empty() { + return Err(FramingError::UnsupportedTransferCoding); + } + if !coding.eq_ignore_ascii_case("chunked") { + return Err(FramingError::UnsupportedTransferCoding); + } + codings += 1; + } + } + if codings == 1 { + Ok(()) + } else { + Err(FramingError::UnsupportedTransferCoding) + } +} + +/// Rejects duplicate or conflicting `Content-Length` values. +fn validate_single_content_length(headers: &HeaderMap) -> Result<(), FramingError> { + let mut seen: Option = None; + for value in headers.get_all(header::CONTENT_LENGTH) { + let text = value + .to_str() + .map_err(|_| FramingError::NonAsciiHeaderValue)?; + for entry in text.split(',') { + let entry = entry.trim(); + if entry.is_empty() || !entry.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(FramingError::InvalidContentLength); + } + let length: u64 = entry + .parse() + .map_err(|_| FramingError::InvalidContentLength)?; + if seen.is_some() { + return Err(FramingError::ConflictingContentLength); + } + seen = Some(length); + } + } + Ok(()) +} + +fn raw_header_values<'a>(headers: &'a [httparse::Header<'a>], name: &[u8]) -> Vec<&'a [u8]> { + headers + .iter() + .filter(|header| header.name.as_bytes().eq_ignore_ascii_case(name)) + .map(|header| header.value) + .collect() +} + +fn validate_raw_chunked_only(values: &[&[u8]]) -> Result<(), FramingError> { + let mut codings = 0_usize; + for value in values { + let text = core::str::from_utf8(value).map_err(|_| FramingError::NonAsciiHeaderValue)?; + for coding in text.split(',') { + let coding = coding.trim(); + if coding.is_empty() || !coding.eq_ignore_ascii_case("chunked") { + return Err(FramingError::UnsupportedTransferCoding); + } + codings += 1; + } + } + if codings == 1 { + Ok(()) + } else { + Err(FramingError::UnsupportedTransferCoding) + } +} + +fn validate_raw_single_content_length(values: &[&[u8]]) -> Result<(), FramingError> { + let mut seen = false; + for value in values { + let text = core::str::from_utf8(value).map_err(|_| FramingError::NonAsciiHeaderValue)?; + for entry in text.split(',') { + let entry = entry.trim(); + if entry.is_empty() || !entry.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(FramingError::InvalidContentLength); + } + let _: u64 = entry + .parse() + .map_err(|_| FramingError::InvalidContentLength)?; + if seen { + return Err(FramingError::ConflictingContentLength); + } + seen = true; + } + } + Ok(()) +} + +/// Removes hop-by-hop headers, including every header named by `Connection`. +pub fn strip_hop_by_hop(headers: &mut HeaderMap) { + let mut connection_named: Vec = Vec::new(); + for value in headers.get_all(header::CONNECTION) { + let Ok(text) = value.to_str() else { + continue; + }; + for token in text.split(',') { + let token = token.trim(); + if token.is_empty() { + continue; + } + if let Ok(name) = HeaderName::from_bytes(token.as_bytes()) { + connection_named.push(name); + } + } + } + + for name in connection_named { + headers.remove(&name); + } + for name in HOP_BY_HOP_HEADERS { + headers.remove(name); + } +} + +/// Removes the framing headers so that the outgoing message is framed from the +/// forwarded body itself rather than from a claimed length. +pub fn remove_framing_headers(headers: &mut HeaderMap) { + headers.remove(header::CONTENT_LENGTH); + headers.remove(header::TRANSFER_ENCODING); +} + +#[cfg(test)] +mod tests { + use super::*; + + use hyper::header::HeaderValue; + + fn headers(pairs: &[(&str, &str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (name, value) in pairs { + map.append( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + map + } + + #[test] + fn accepts_well_framed_requests() { + assert!( + validate_request_framing(&headers(&[("content-length", "12")]), Version::HTTP_11) + .is_ok() + ); + assert!( + validate_request_framing( + &headers(&[("transfer-encoding", "chunked")]), + Version::HTTP_11 + ) + .is_ok() + ); + assert!(validate_request_framing(&HeaderMap::new(), Version::HTTP_10).is_ok()); + } + + #[test] + fn rejects_framing_ambiguity() { + assert_eq!( + validate_request_framing( + &headers(&[("content-length", "1"), ("transfer-encoding", "chunked")]), + Version::HTTP_11 + ), + Err(FramingError::ConflictingLength) + ); + assert_eq!( + validate_request_framing( + &headers(&[("content-length", "1"), ("content-length", "2")]), + Version::HTTP_11 + ), + Err(FramingError::ConflictingContentLength) + ); + assert_eq!( + validate_request_framing( + &headers(&[("content-length", "1"), ("content-length", "1")]), + Version::HTTP_11 + ), + Err(FramingError::ConflictingContentLength) + ); + assert_eq!( + validate_request_framing(&headers(&[("content-length", "1, 2")]), Version::HTTP_11), + Err(FramingError::ConflictingContentLength) + ); + assert_eq!( + validate_request_framing(&headers(&[("content-length", "abc")]), Version::HTTP_11), + Err(FramingError::InvalidContentLength) + ); + assert_eq!( + validate_request_framing( + &headers(&[("transfer-encoding", "gzip, chunked")]), + Version::HTTP_11 + ), + Err(FramingError::UnsupportedTransferCoding) + ); + assert_eq!( + validate_request_framing( + &headers(&[ + ("transfer-encoding", "chunked"), + ("transfer-encoding", "chunked") + ]), + Version::HTTP_11 + ), + Err(FramingError::UnsupportedTransferCoding) + ); + assert_eq!( + validate_request_framing( + &headers(&[("transfer-encoding", "chunked")]), + Version::HTTP_10 + ), + Err(FramingError::TransferEncodingOnHttp10) + ); + assert_eq!( + validate_request_framing( + &headers(&[("host", "a.example"), ("host", "b.example")]), + Version::HTTP_11 + ), + Err(FramingError::DuplicateHost) + ); + } + + #[test] + fn response_framing_is_validated_without_request_headers() { + assert!( + validate_response_framing(&headers(&[("content-length", "12")]), Version::HTTP_11) + .is_ok() + ); + assert_eq!( + validate_response_framing( + &headers(&[("content-length", "1"), ("transfer-encoding", "chunked")]), + Version::HTTP_11 + ), + Err(FramingError::ConflictingLength) + ); + } + + #[test] + fn connect_requests_carry_no_body() { + assert!(validate_connect_framing(&headers(&[("host", "a.example:443")])).is_ok()); + assert_eq!( + validate_connect_framing(&headers(&[("content-length", "0")])), + Err(FramingError::BodyOnConnect) + ); + assert_eq!( + validate_connect_framing(&headers(&[("transfer-encoding", "chunked")])), + Err(FramingError::BodyOnConnect) + ); + } + + #[test] + fn hop_by_hop_headers_are_removed() { + let mut map = headers(&[ + ("connection", "keep-alive, X-Custom"), + ("keep-alive", "timeout=5"), + ("proxy-connection", "keep-alive"), + ("x-custom", "secret"), + ("te", "trailers"), + ("upgrade", "websocket"), + ("x-kept", "value"), + ]); + + strip_hop_by_hop(&mut map); + + assert_eq!(map.len(), 1); + assert_eq!(map.get("x-kept").unwrap(), "value"); + } +} diff --git a/litebox_egress_proxy/src/lib.rs b/litebox_egress_proxy/src/lib.rs new file mode 100644 index 000000000..20a30f823 --- /dev/null +++ b/litebox_egress_proxy/src/lib.rs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! A standalone, hostname-filtering HTTP and HTTPS egress proxy for LiteBox +//! sandboxes. +//! +//! # Model +//! +//! The proxy is a separate trusted process. It authorizes each request against +//! an immutable, exact-hostname policy and connects only to addresses that were +//! resolved through one explicitly configured DNS server before the listener +//! was announced as ready. It never consults the host resolver configuration, +//! never re-resolves a hostname, and never grants direct access to a resolved +//! address: a hostname rule authorizes an endpoint reached through this proxy +//! and nothing else. +//! +//! Two request forms are supported: +//! +//! * plain HTTP/1 forward-proxy requests with an absolute-form `http` target, +//! which are rewritten to origin form and relayed over a dedicated upstream +//! connection per request, and +//! * `CONNECT` tunnels, which relay bytes to an allowed hostname and port +//! without inspecting or terminating TLS. +//! +//! Each plain HTTP client connection carries exactly one request. This keeps +//! raw request framing validation authoritative without duplicating a +//! streaming HTTP parser, while upstream connections are already dedicated per +//! request. A successful `CONNECT` instead upgrades that one request into its +//! bounded tunnel. +//! +//! Everything else -- protocol upgrades, `https` absolute URIs, IP-literal +//! authorities, ambiguous framing, and any hostname or port that policy does +//! not name -- is rejected. +//! +//! # Executable contract +//! +//! ```text +//! litebox_egress_proxy --listen 127.0.0.1:0 --dns-server IPV4[:PORT] \ +//! --allow-host HOST:PORT[-PORT] ... +//! litebox_egress_proxy --listener-fd FD --dns-server IPV4[:PORT] \ +//! --allow-host HOST:PORT[-PORT] ... +//! ``` +//! +//! After the listener is acquired, every policy hostname is resolved, and all +//! startup validation has passed, exactly one line is written to standard +//! output: +//! +//! ```text +//! READY 127.0.0.1:PORT +//! ``` +//! +//! Diagnostics go to standard error only, and no readiness line is written on +//! failure. Startup as a whole is bounded by [`limits::STARTUP_BUDGET`]. +//! +//! # Testing +//! +//! [`dns::HostResolver`] and [`upstream::UpstreamConnector`] are injected +//! abstractions, so the request path can be driven hermetically over loopback. +//! The shared pinning path applies the same destination policy to production +//! and injected DNS answers. + +pub mod authority; +pub mod config; +pub mod dns; +pub mod headers; +pub mod limits; +pub mod listener; +pub mod policy; +pub mod proxy; +pub mod stream; +pub mod upstream; + +mod request_head; + +use std::io::{self, Write}; +use std::net::{SocketAddr, SocketAddrV4}; +use std::sync::Arc; + +use thiserror::Error; +use tokio::net::TcpListener; +use tokio::time::timeout; + +use crate::config::ProxyConfig; +use crate::dns::{ConfiguredDnsResolver, PinError, PinnedTable, ResolveError}; +use crate::limits::STARTUP_BUDGET; +use crate::listener::ListenerError; +use crate::proxy::ProxyState; +use crate::upstream::TcpUpstreamConnector; + +/// Reason the proxy could not start. +#[derive(Debug, Error)] +pub enum StartupError { + /// The listener could not be acquired or validated. + #[error(transparent)] + Listener(#[from] ListenerError), + /// The resolver could not be constructed. + #[error("failed to configure the DNS resolver: {0}")] + Resolver(#[from] ResolveError), + /// A policy hostname could not be pinned. + #[error(transparent)] + Pin(#[from] PinError), + /// Startup exceeded its total budget. + #[error("startup exceeded its {}s budget", STARTUP_BUDGET.as_secs())] + Budget, + /// An I/O operation failed during startup or while serving. + #[error(transparent)] + Io(#[from] io::Error), +} + +/// A proxy that has completed startup and is ready to serve. +pub struct StartedProxy { + listener: TcpListener, + state: Arc, + local_address: SocketAddrV4, +} + +impl StartedProxy { + /// Returns the loopback address the proxy listens on. + pub fn local_address(&self) -> SocketAddrV4 { + self.local_address + } + + /// Serves client connections until the listener fails. + pub async fn serve(self) -> io::Result<()> { + proxy::serve(self.listener, self.state).await + } +} + +/// Acquires the listener, pins every policy hostname, and prepares the shared +/// state. +/// +/// No client connection is served and no readiness line is written until this +/// has succeeded, so a partially configured proxy is never observable. +pub async fn start(config: &ProxyConfig) -> Result { + let listener = listener::acquire(config.listener)?; + let listener = TcpListener::from_std(listener)?; + let SocketAddr::V4(local_address) = listener.local_addr()? else { + return Err(StartupError::Listener(ListenerError::NotLoopback( + listener.local_addr()?, + ))); + }; + + let resolver = ConfiguredDnsResolver::new(config.dns_server)?; + let pinned = PinnedTable::resolve(&config.policy, &config.targets, Arc::new(resolver)).await?; + + let state = Arc::new(ProxyState::new( + config.policy.clone(), + pinned, + Arc::new(TcpUpstreamConnector), + )); + + Ok(StartedProxy { + listener, + state, + local_address, + }) +} + +/// Writes the single readiness line. +/// +/// The launcher treats malformed output, extra output before readiness, or a +/// missing line as a startup failure, so this is the only thing the proxy ever +/// writes to standard output. +pub fn write_readiness(writer: &mut impl Write, address: SocketAddrV4) -> io::Result<()> { + writeln!(writer, "READY {address}")?; + writer.flush() +} + +/// Runs the proxy: bounded startup, readiness announcement, then serving. +pub async fn run(config: &ProxyConfig) -> Result<(), StartupError> { + let started = timeout(STARTUP_BUDGET, start(config)) + .await + .map_err(|_elapsed| StartupError::Budget)??; + + let mut stdout = io::stdout().lock(); + write_readiness(&mut stdout, started.local_address())?; + drop(stdout); + + started.serve().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::net::Ipv4Addr; + + #[test] + fn readiness_line_is_exactly_one_line() { + let mut output = Vec::new(); + write_readiness(&mut output, SocketAddrV4::new(Ipv4Addr::LOCALHOST, 34567)).unwrap(); + assert_eq!(output, b"READY 127.0.0.1:34567\n"); + } +} diff --git a/litebox_egress_proxy/src/limits.rs b/litebox_egress_proxy/src/limits.rs new file mode 100644 index 000000000..04a85cb38 --- /dev/null +++ b/litebox_egress_proxy/src/limits.rs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Fixed resource limits for the egress proxy. +//! +//! None of these limits is caller-configurable: the proxy is a trusted +//! component whose behaviour must be identical for every sandbox. + +use core::time::Duration; + +/// Maximum number of distinct canonical hostnames in the policy. +pub const MAX_HOST_RULES: usize = 64; + +/// Maximum number of pinned IPv4 addresses retained per hostname. +pub const MAX_PINNED_ADDRESSES_PER_HOST: usize = 16; + +/// Maximum number of additional proxy-only resolved-destination CIDRs. +pub const MAX_RESOLVED_DESTINATION_RULES: usize = 64; + +/// Maximum number of client connections served concurrently. +/// +/// Additional connections stay in the listener backlog until a slot frees up. +pub const MAX_CONCURRENT_CLIENT_CONNECTIONS: usize = 256; + +/// Maximum number of bytes buffered for a client request head. +pub const MAX_REQUEST_HEADER_BYTES: usize = 16 * 1024; + +/// Maximum number of bytes buffered for an upstream response head. +pub const MAX_RESPONSE_HEADER_BYTES: usize = 16 * 1024; + +/// Maximum number of individual header fields parsed per message. +pub const MAX_HEADER_FIELDS: usize = 100; + +/// EDNS payload size advertised for UDP DNS queries. +/// +/// Larger responses are truncated by the server, which makes the resolver fall +/// back to TCP. +pub const MAX_UDP_DNS_RESPONSE_BYTES: u16 = 1232; + +/// Maximum number of hostname resolutions performed concurrently at startup. +pub const MAX_CONCURRENT_STARTUP_RESOLUTIONS: usize = 16; + +/// Per-hostname DNS resolution timeout. +pub const DNS_QUERY_TIMEOUT: Duration = Duration::from_secs(5); + +/// Timeout for one DNS transport attempt within a hostname lookup. +/// +/// This is shorter than [`DNS_QUERY_TIMEOUT`] so the resolver has time to +/// fall back from UDP to TCP before the whole hostname lookup expires. +pub const DNS_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); + +/// Total startup budget, covering listener acquisition and every resolution. +pub const STARTUP_BUDGET: Duration = Duration::from_secs(30); + +const _: () = assert!( + MAX_HOST_RULES.div_ceil(MAX_CONCURRENT_STARTUP_RESOLUTIONS) <= 5 + && DNS_QUERY_TIMEOUT.as_secs() * 5 < STARTUP_BUDGET.as_secs() +); + +/// Total timeout shared by all pinned-address connection attempts. +pub const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Idle timeout applied to HTTP bodies and CONNECT tunnels. +/// +/// A stream that makes no read or write progress for this long is torn down. +pub const IDLE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Total lifetime of a single forwarded HTTP request, measured from the moment +/// its dedicated upstream connection is established. +/// +/// The design requires a total request time limit but does not fix its value; +/// ten minutes is long enough for large bounded transfers while still keeping +/// every upstream connection bounded. +pub const TOTAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(600); + +/// Maximum time a client may take to send a complete request head. +pub const REQUEST_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(30); + +/// Maximum time spent draining client input after a non-upgraded response. +pub const CLIENT_CLOSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(1); + +/// Maximum client input discarded while closing a non-upgraded connection. +pub const MAX_CLIENT_CLOSE_DRAIN_BYTES: usize = 64 * 1024; diff --git a/litebox_egress_proxy/src/listener.rs b/litebox_egress_proxy/src/listener.rs new file mode 100644 index 000000000..ab7994592 --- /dev/null +++ b/litebox_egress_proxy/src/listener.rs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Listener acquisition for the standalone and broker modes. +//! +//! Both modes end with the same invariant: the proxy only ever serves a bound, +//! listening IPv4 loopback TCP socket. The standalone mode binds it itself; the +//! broker mode adopts a listener that a launcher bound and inherited to this +//! process. + +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener}; + +use thiserror::Error; + +/// Where the proxy's listener comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ListenerSource { + /// Bind a fresh loopback listener. Port zero requests an ephemeral port. + Bind(SocketAddrV4), + /// Adopt an inherited, already-bound listener by descriptor number. + Inherited(i32), +} + +/// Reason a listener could not be acquired. +#[derive(Debug, Error)] +pub enum ListenerError { + /// Binding the requested address failed. + #[error("failed to bind {address}: {source}")] + Bind { + /// The address that could not be bound. + address: SocketAddrV4, + /// The underlying failure. + source: std::io::Error, + }, + /// Reading the listener's local address failed. + #[error("failed to read the listener address: {0}")] + LocalAddress(#[source] std::io::Error), + /// The listener was not bound to canonical IPv4 loopback. + #[error("listener is bound to {0}, which is not 127.0.0.1")] + NotLoopback(SocketAddr), + /// The listener was bound to port zero, which an inherited listener never + /// is once it has been bound. + #[error("inherited listener is not bound to a concrete port")] + UnboundPort, + /// The descriptor number was negative. + #[error("inherited listener descriptor is not a valid descriptor number")] + InvalidDescriptor, + /// The descriptor did not refer to an open file. + #[error("inherited listener descriptor is not open")] + DescriptorNotOpen, + /// Inspecting the socket failed. + #[error("failed to inspect the inherited listener: {0}")] + Inspect(#[source] std::io::Error), + /// The descriptor was not an IPv4 stream socket in the listening state. + #[error("inherited descriptor is not a listening IPv4 TCP socket")] + NotAnIpv4Listener, + /// The platform has no inherited-listener contract. + #[error("--listener-fd is only supported on Linux")] + InheritanceUnsupported, + /// Configuring the listener for asynchronous use failed. + #[error("failed to configure the listener: {0}")] + Configure(#[source] std::io::Error), +} + +/// Acquires the listener described by `source`. +/// +/// The returned listener is non-blocking and validated to be bound to +/// canonical IPv4 loopback. +pub fn acquire(source: ListenerSource) -> Result { + let listener = match source { + ListenerSource::Bind(address) => { + TcpListener::bind(address).map_err(|source| ListenerError::Bind { address, source })? + } + ListenerSource::Inherited(descriptor) => adopt_inherited(descriptor)?, + }; + + let local = listener.local_addr().map_err(ListenerError::LocalAddress)?; + let SocketAddr::V4(local) = local else { + return Err(ListenerError::NotLoopback(local)); + }; + if *local.ip() != Ipv4Addr::LOCALHOST { + return Err(ListenerError::NotLoopback(SocketAddr::V4(local))); + } + if local.port() == 0 { + return Err(ListenerError::UnboundPort); + } + + listener + .set_nonblocking(true) + .map_err(ListenerError::Configure)?; + Ok(listener) +} + +/// Adopts an inherited descriptor after validating that it really is a bound, +/// listening IPv4 TCP socket. +#[cfg(target_os = "linux")] +fn adopt_inherited(descriptor: i32) -> Result { + use std::os::fd::FromRawFd; + + if descriptor < 0 { + return Err(ListenerError::InvalidDescriptor); + } + + // SAFETY: `fcntl(F_GETFD)` only reads the descriptor flags of `descriptor`. + // It neither takes ownership nor mutates process state, and it reports an + // invalid descriptor as `-1` instead of causing undefined behaviour. + let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFD) }; + if flags < 0 { + return Err(ListenerError::DescriptorNotOpen); + } + + if socket_option(descriptor, libc::SO_DOMAIN)? != libc::AF_INET + || socket_option(descriptor, libc::SO_TYPE)? != libc::SOCK_STREAM + || socket_option(descriptor, libc::SO_ACCEPTCONN)? != 1 + { + return Err(ListenerError::NotAnIpv4Listener); + } + + // SAFETY: the checks above established that `descriptor` is an open, + // listening IPv4 stream socket. The launcher contract for `--listener-fd` + // transfers ownership of that descriptor to this process, and nothing else + // in this process holds or closes it, so wrapping it in a `TcpListener` + // gives a single unique owner. + Ok(unsafe { TcpListener::from_raw_fd(descriptor) }) +} + +/// Reads a `SOL_SOCKET` integer option. +#[cfg(target_os = "linux")] +fn socket_option(descriptor: i32, option: libc::c_int) -> Result { + let mut value: libc::c_int = 0; + let mut length = libc::socklen_t::try_from(size_of::()) + .expect("the size of a C int fits in socklen_t"); + + // SAFETY: `value` and `length` are valid, correctly sized and aligned + // locals that outlive the call. `getsockopt` writes at most `length` bytes + // into `value` and updates `length` accordingly, and reports failure as + // `-1` rather than writing out of bounds. + let result = unsafe { + libc::getsockopt( + descriptor, + libc::SOL_SOCKET, + option, + std::ptr::from_mut(&mut value).cast::(), + &raw mut length, + ) + }; + if result != 0 { + return Err(ListenerError::Inspect(std::io::Error::last_os_error())); + } + Ok(value) +} + +/// Inherited listeners are a Linux-only contract in this milestone. +#[cfg(not(target_os = "linux"))] +fn adopt_inherited(_descriptor: i32) -> Result { + Err(ListenerError::InheritanceUnsupported) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binds_an_ephemeral_loopback_port() { + let listener = acquire(ListenerSource::Bind(SocketAddrV4::new( + Ipv4Addr::LOCALHOST, + 0, + ))) + .unwrap(); + let SocketAddr::V4(address) = listener.local_addr().unwrap() else { + panic!("expected an IPv4 listener"); + }; + assert_eq!(*address.ip(), Ipv4Addr::LOCALHOST); + assert_ne!(address.port(), 0); + } + + #[test] + fn rejects_non_loopback_binds() { + let error = acquire(ListenerSource::Bind(SocketAddrV4::new( + Ipv4Addr::UNSPECIFIED, + 0, + ))) + .unwrap_err(); + assert!(matches!(error, ListenerError::NotLoopback(_))); + } + + #[test] + fn rejects_a_negative_descriptor() { + let error = acquire(ListenerSource::Inherited(-1)).unwrap_err(); + assert!(matches!( + error, + ListenerError::InvalidDescriptor | ListenerError::InheritanceUnsupported + )); + } + + #[cfg(target_os = "linux")] + #[test] + fn adopts_an_inherited_loopback_listener() { + use std::os::fd::IntoRawFd; + + let bound = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap(); + let expected = bound.local_addr().unwrap(); + let descriptor = bound.into_raw_fd(); + + let adopted = acquire(ListenerSource::Inherited(descriptor)).unwrap(); + assert_eq!(adopted.local_addr().unwrap(), expected); + } + + #[cfg(target_os = "linux")] + #[test] + fn rejects_a_connected_socket() { + use std::os::fd::IntoRawFd; + + let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap(); + let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let error = acquire(ListenerSource::Inherited(client.into_raw_fd())).unwrap_err(); + assert!(matches!(error, ListenerError::NotAnIpv4Listener)); + } + + #[cfg(target_os = "linux")] + #[test] + fn rejects_a_datagram_socket() { + use std::os::fd::IntoRawFd; + + let socket = std::net::UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap(); + let error = acquire(ListenerSource::Inherited(socket.into_raw_fd())).unwrap_err(); + assert!(matches!(error, ListenerError::NotAnIpv4Listener)); + } +} diff --git a/litebox_egress_proxy/src/main.rs b/litebox_egress_proxy/src/main.rs new file mode 100644 index 000000000..a79ae5abd --- /dev/null +++ b/litebox_egress_proxy/src/main.rs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! The `litebox_egress_proxy` executable. +//! +//! Startup failures are reported on standard error and produce a nonzero exit +//! status; the readiness line on standard output is written only once the proxy +//! is fully configured and listening. + +use std::process::ExitCode; + +use clap::Parser; +use litebox_egress_proxy::config::Cli; +use litebox_egress_proxy::run; + +fn main() -> ExitCode { + let config = match Cli::parse().into_config() { + Ok(config) => config, + Err(error) => return fail(&error), + }; + + // A single-threaded runtime keeps the standalone TCB deterministic; all + // request and tunnel work is asynchronous and explicitly bounded. + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + { + Ok(runtime) => runtime, + Err(error) => return fail(&error), + }; + + match runtime.block_on(run(&config)) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => fail(&error), + } +} + +/// Reports a fatal error on standard error, including its causes. +fn fail(error: &dyn core::error::Error) -> ExitCode { + eprint!("litebox_egress_proxy: {error}"); + let mut source = error.source(); + while let Some(cause) = source { + eprint!(": {cause}"); + source = cause.source(); + } + eprintln!(); + ExitCode::FAILURE +} diff --git a/litebox_egress_proxy/src/policy.rs b/litebox_egress_proxy/src/policy.rs new file mode 100644 index 000000000..8fe0a6682 --- /dev/null +++ b/litebox_egress_proxy/src/policy.rs @@ -0,0 +1,549 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Typed, immutable hostname and destination-port policy. +//! +//! The policy is parsed once, before any listener is announced, and is never +//! mutated afterwards. Only canonical values reach the request path: a +//! [`Hostname`] is always lowercase, dot-normalised and syntactically valid, +//! and a [`PortRange`] never spans a reserved DNS port. + +use core::fmt; +use core::str::FromStr; +use std::collections::BTreeMap; + +use thiserror::Error; + +use crate::limits::MAX_HOST_RULES; + +/// Maximum total length of a canonical DNS name, in bytes. +const MAX_HOSTNAME_BYTES: usize = 253; + +/// Maximum length of a single DNS label, in bytes. +const MAX_LABEL_BYTES: usize = 63; + +/// Well-known DNS ports that a proxy rule may never authorize. +/// +/// Port 53 is plain DNS and port 853 is DNS-over-TLS. Both remain reserved for +/// the configured resolver path, so that a proxy rule can never be used to +/// reach an arbitrary resolver. +pub const RESERVED_DNS_PORTS: [u16; 2] = [53, 853]; + +/// Reason a hostname was rejected. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum HostnameError { + /// The name, or the name after removing one trailing dot, was empty. + #[error("hostname is empty")] + Empty, + /// The name contained non-ASCII bytes. Internationalized names must be + /// supplied in A-label (punycode) form. + #[error("hostname is not ASCII")] + NotAscii, + /// The canonical name was longer than 253 bytes. + #[error("hostname is longer than {MAX_HOSTNAME_BYTES} bytes")] + TooLong, + /// A label was empty or longer than 63 bytes. + #[error("hostname label is empty or longer than {MAX_LABEL_BYTES} bytes")] + LabelLength, + /// A label contained something other than an ASCII letter, digit or + /// hyphen. + #[error("hostname label contains an unsupported character")] + LabelCharacter, + /// A label started or ended with a hyphen. + #[error("hostname label starts or ends with a hyphen")] + LabelHyphen, + /// The name was an IP literal or otherwise numeric. Addresses belong to + /// the direct IP/CIDR policy, never to the hostname policy. + #[error("hostname is an IP literal or numeric form")] + NumericForm, + /// The name was `localhost` or one of its descendants. + #[error("`localhost` and its descendants are not proxy hostnames")] + Localhost, +} + +/// A canonical, exact DNS hostname that a proxy rule or request may name. +/// +/// Canonicalization lowercases the name and removes at most one trailing dot. +/// The same constructor is used for policy rules and for request authorities, +/// so a request can only ever match a rule byte-for-byte after +/// canonicalization. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Hostname(String); + +impl Hostname { + /// Parses and canonicalizes `input`. + pub fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(HostnameError::Empty); + } + if !input.is_ascii() { + return Err(HostnameError::NotAscii); + } + + // Accept and remove exactly one trailing dot; a second trailing dot + // leaves an empty label and is rejected below. + let trimmed = input.strip_suffix('.').unwrap_or(input); + if trimmed.is_empty() { + return Err(HostnameError::Empty); + } + if trimmed.len() > MAX_HOSTNAME_BYTES { + return Err(HostnameError::TooLong); + } + + let canonical = trimmed.to_ascii_lowercase(); + let mut last_label = ""; + for label in canonical.split('.') { + if label.is_empty() || label.len() > MAX_LABEL_BYTES { + return Err(HostnameError::LabelLength); + } + if !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(HostnameError::LabelCharacter); + } + if label.starts_with('-') || label.ends_with('-') { + return Err(HostnameError::LabelHyphen); + } + last_label = label; + } + + // An all-digit rightmost label covers dotted-quad IPv4 literals and + // every other numeric-looking form. IPv6 literals and their brackets + // are already rejected by the label character check. + if last_label.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(HostnameError::NumericForm); + } + + if canonical == "localhost" || canonical.ends_with(".localhost") { + return Err(HostnameError::Localhost); + } + + Ok(Self(canonical)) + } + + /// Returns the canonical name. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Hostname { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl FromStr for Hostname { + type Err = HostnameError; + + fn from_str(input: &str) -> Result { + Self::parse(input) + } +} + +/// Reason a port range was rejected. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum PortRangeError { + /// The range was empty or contained a non-digit. + #[error("port is not a decimal number in 1..=65535")] + NotANumber, + /// Port zero is never a destination. + #[error("port 0 is not a valid destination port")] + ZeroPort, + /// The range end was smaller than its start. + #[error("port range end is smaller than its start")] + Inverted, + /// The range contained port 53 or port 853. + #[error("port range contains reserved DNS port 53 or 853")] + ReservedDnsPort, +} + +/// An inclusive range of destination ports. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct PortRange { + start: u16, + end: u16, +} + +impl PortRange { + /// Creates an inclusive range, rejecting port zero, inverted ranges, and + /// any range containing a reserved DNS port. + pub fn new(start: u16, end: u16) -> Result { + if start == 0 || end == 0 { + return Err(PortRangeError::ZeroPort); + } + if start > end { + return Err(PortRangeError::Inverted); + } + if RESERVED_DNS_PORTS + .iter() + .any(|reserved| (start..=end).contains(reserved)) + { + return Err(PortRangeError::ReservedDnsPort); + } + Ok(Self { start, end }) + } + + /// Returns the first port of the range. + pub fn start(self) -> u16 { + self.start + } + + /// Returns the last port of the range. + pub fn end(self) -> u16 { + self.end + } + + /// Returns whether `port` is inside the range. + pub fn contains(self, port: u16) -> bool { + (self.start..=self.end).contains(&port) + } +} + +impl fmt::Display for PortRange { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.start == self.end { + write!(formatter, "{}", self.start) + } else { + write!(formatter, "{}-{}", self.start, self.end) + } + } +} + +impl FromStr for PortRange { + type Err = PortRangeError; + + /// Parses `PORT` or `PORT-PORT`. + fn from_str(input: &str) -> Result { + if let Some((start, end)) = input.split_once('-') { + Self::new(parse_port(start)?, parse_port(end)?) + } else { + let port = parse_port(input)?; + Self::new(port, port) + } + } +} + +/// Parses a strict decimal port number. +/// +/// Unlike [`u16::from_str`] this rejects a leading sign and any surrounding +/// whitespace, so that no two textual forms map to one port. +pub(crate) fn parse_port(input: &str) -> Result { + if input.is_empty() || input.len() > 5 || !input.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(PortRangeError::NotANumber); + } + let port: u16 = input.parse().map_err(|_| PortRangeError::NotANumber)?; + if port == 0 { + return Err(PortRangeError::ZeroPort); + } + Ok(port) +} + +/// Reason a `HOST:PORT[-PORT]` rule was rejected. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum HostRuleError { + /// The rule did not have the `HOST:PORT[-PORT]` shape. + #[error("rule is not of the form HOST:PORT[-PORT]")] + Shape, + /// The hostname part was invalid. + #[error("invalid hostname: {0}")] + Hostname(#[from] HostnameError), + /// The port part was invalid. + #[error("invalid port range: {0}")] + PortRange(#[from] PortRangeError), +} + +/// One `HOST:PORT[-PORT]` policy rule. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostRule { + /// Canonical hostname the rule authorizes. + pub host: Hostname, + /// Destination ports the rule authorizes. + pub ports: PortRange, +} + +impl FromStr for HostRule { + type Err = HostRuleError; + + fn from_str(input: &str) -> Result { + let (host, ports) = input.split_once(':').ok_or(HostRuleError::Shape)?; + if host.is_empty() || ports.is_empty() { + return Err(HostRuleError::Shape); + } + Ok(Self { + host: Hostname::parse(host)?, + ports: ports.parse()?, + }) + } +} + +/// Reason a set of rules could not become a policy. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum PolicyError { + /// More than [`MAX_HOST_RULES`] distinct canonical hostnames were given. + #[error("policy contains more than {MAX_HOST_RULES} canonical hostnames")] + TooManyHosts, + /// Merging overlapping ranges produced a range spanning a reserved DNS + /// port. This cannot happen for validated inputs and is checked anyway so + /// that the reserved-port invariant holds for the stored ranges. + #[error("merged port range is invalid: {0}")] + MergedRange(#[from] PortRangeError), +} + +/// The immutable proxy policy: exact hostnames mapped to allowed destination +/// port ranges. +/// +/// The policy default is deny: a hostname without a rule, or a port outside +/// every range of a rule, is never authorized. +#[derive(Clone, Debug, Default)] +pub struct HostPolicy { + entries: BTreeMap>, +} + +impl HostPolicy { + /// Builds a policy from rules, merging overlapping and adjacent ranges of + /// the same canonical hostname. + /// + /// Merging is deterministic: ranges are sorted and folded in ascending + /// order, so the same rule set always yields the same policy regardless of + /// argument order. + pub fn from_rules(rules: impl IntoIterator) -> Result { + let mut entries: BTreeMap> = BTreeMap::new(); + for rule in rules { + entries.entry(rule.host).or_default().push(rule.ports); + } + if entries.len() > MAX_HOST_RULES { + return Err(PolicyError::TooManyHosts); + } + for ranges in entries.values_mut() { + *ranges = merge_ranges(ranges)?; + } + Ok(Self { entries }) + } + + /// Returns whether the exact canonical `host` is authorized for `port`. + pub fn allows(&self, host: &Hostname, port: u16) -> bool { + self.entries + .get(host) + .is_some_and(|ranges| ranges.iter().any(|range| range.contains(port))) + } + + /// Returns the canonical hostnames of the policy, in a stable order. + pub fn hostnames(&self) -> impl ExactSizeIterator { + self.entries.keys() + } + + /// Returns the merged port ranges authorized for `host`. + pub fn port_ranges(&self, host: &Hostname) -> &[PortRange] { + self.entries.get(host).map_or(&[], Vec::as_slice) + } + + /// Returns the number of canonical hostnames in the policy. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns whether the policy authorizes nothing at all. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// Sorts and folds overlapping or adjacent ranges. +fn merge_ranges(ranges: &[PortRange]) -> Result, PortRangeError> { + let mut sorted = ranges.to_vec(); + sorted.sort_unstable(); + + let mut merged: Vec = Vec::with_capacity(sorted.len()); + for range in sorted { + match merged.last_mut() { + // `saturating_add` keeps adjacency well-defined at 65535. + Some(previous) if range.start() <= previous.end().saturating_add(1) => { + let end = previous.end().max(range.end()); + // Re-validate: a merged range must still exclude reserved DNS + // ports. + *previous = PortRange::new(previous.start(), end)?; + } + _ => merged.push(range), + } + } + Ok(merged) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostname_is_canonicalized() { + assert_eq!( + Hostname::parse("Example.COM.").unwrap().as_str(), + "example.com" + ); + assert_eq!( + Hostname::parse("a-b.example").unwrap().as_str(), + "a-b.example" + ); + } + + #[test] + fn hostname_rejects_invalid_forms() { + assert_eq!(Hostname::parse(""), Err(HostnameError::Empty)); + assert_eq!(Hostname::parse("."), Err(HostnameError::Empty)); + assert_eq!(Hostname::parse("a..b"), Err(HostnameError::LabelLength)); + assert_eq!(Hostname::parse("a.b.."), Err(HostnameError::LabelLength)); + assert_eq!(Hostname::parse("exämple.com"), Err(HostnameError::NotAscii)); + assert_eq!( + Hostname::parse("-a.example"), + Err(HostnameError::LabelHyphen) + ); + assert_eq!( + Hostname::parse("a-.example"), + Err(HostnameError::LabelHyphen) + ); + assert_eq!( + Hostname::parse("a_b.example"), + Err(HostnameError::LabelCharacter) + ); + assert_eq!( + Hostname::parse("a b.example"), + Err(HostnameError::LabelCharacter) + ); + assert_eq!( + Hostname::parse("host:80"), + Err(HostnameError::LabelCharacter) + ); + assert_eq!( + Hostname::parse("192.0.2.1"), + Err(HostnameError::NumericForm) + ); + assert_eq!(Hostname::parse("12345"), Err(HostnameError::NumericForm)); + assert_eq!(Hostname::parse("[::1]"), Err(HostnameError::LabelCharacter)); + assert_eq!(Hostname::parse("localhost"), Err(HostnameError::Localhost)); + assert_eq!(Hostname::parse("LOCALHOST."), Err(HostnameError::Localhost)); + assert_eq!( + Hostname::parse("a.localhost"), + Err(HostnameError::Localhost) + ); + } + + #[test] + fn hostname_enforces_length_bounds() { + let long_label = "a".repeat(64); + assert_eq!( + Hostname::parse(&format!("{long_label}.example")), + Err(HostnameError::LabelLength) + ); + + let long_name = format!("{}.example", "a".repeat(250)); + assert_eq!(Hostname::parse(&long_name), Err(HostnameError::TooLong)); + + let at_limit = format!( + "{label}.{label}.{label}.{tail}", + label = "a".repeat(63), + tail = "a".repeat(61) + ); + assert_eq!(at_limit.len(), 253); + assert!(Hostname::parse(&at_limit).is_ok()); + } + + #[test] + fn port_ranges_reject_reserved_dns_ports() { + assert_eq!( + "53".parse::(), + Err(PortRangeError::ReservedDnsPort) + ); + assert_eq!( + "853".parse::(), + Err(PortRangeError::ReservedDnsPort) + ); + assert_eq!( + "50-60".parse::(), + Err(PortRangeError::ReservedDnsPort) + ); + assert_eq!( + "1-65535".parse::(), + Err(PortRangeError::ReservedDnsPort) + ); + assert!("54-852".parse::().is_ok()); + } + + #[test] + fn port_ranges_reject_malformed_input() { + assert_eq!("".parse::(), Err(PortRangeError::NotANumber)); + assert_eq!("+80".parse::(), Err(PortRangeError::NotANumber)); + assert_eq!("8o".parse::(), Err(PortRangeError::NotANumber)); + assert_eq!( + "65536".parse::(), + Err(PortRangeError::NotANumber) + ); + assert_eq!("0".parse::(), Err(PortRangeError::ZeroPort)); + assert_eq!("90-80".parse::(), Err(PortRangeError::Inverted)); + } + + #[test] + fn host_rule_parsing() { + let rule: HostRule = "Example.com:443".parse().unwrap(); + assert_eq!(rule.host.as_str(), "example.com"); + assert!(rule.ports.contains(443)); + + assert_eq!("example.com".parse::(), Err(HostRuleError::Shape)); + assert_eq!(":443".parse::(), Err(HostRuleError::Shape)); + assert_eq!( + "example.com:".parse::(), + Err(HostRuleError::Shape) + ); + assert!(matches!( + "example.com:80:443".parse::(), + Err(HostRuleError::PortRange(_)) + )); + assert!(matches!( + "192.0.2.1:443".parse::(), + Err(HostRuleError::Hostname(_)) + )); + } + + #[test] + fn policy_merges_and_denies_by_default() { + let rules = ["a.example:80", "a.example:81-90", "A.EXAMPLE:8000-8100"] + .into_iter() + .map(|rule| rule.parse::().unwrap()); + let policy = HostPolicy::from_rules(rules).unwrap(); + + let host = Hostname::parse("a.example").unwrap(); + assert_eq!(policy.len(), 1); + assert_eq!(policy.port_ranges(&host).len(), 2); + assert!(policy.allows(&host, 80)); + assert!(policy.allows(&host, 90)); + assert!(policy.allows(&host, 8100)); + assert!(!policy.allows(&host, 91)); + assert!(!policy.allows(&host, 443)); + + let other = Hostname::parse("b.example").unwrap(); + assert!(!policy.allows(&other, 80)); + assert!(policy.port_ranges(&other).is_empty()); + } + + #[test] + fn policy_merge_never_spans_a_reserved_port() { + let rules = ["a.example:40-52", "a.example:54-60"] + .into_iter() + .map(|rule| rule.parse::().unwrap()); + let policy = HostPolicy::from_rules(rules).unwrap(); + let host = Hostname::parse("a.example").unwrap(); + + assert_eq!(policy.port_ranges(&host).len(), 2); + assert!(!policy.allows(&host, 53)); + } + + #[test] + fn policy_rejects_too_many_hosts() { + let rules = (0..=MAX_HOST_RULES) + .map(|index| format!("h{index}.example:443").parse::().unwrap()); + assert!(matches!( + HostPolicy::from_rules(rules), + Err(PolicyError::TooManyHosts) + )); + } +} diff --git a/litebox_egress_proxy/src/proxy.rs b/litebox_egress_proxy/src/proxy.rs new file mode 100644 index 000000000..970d32086 --- /dev/null +++ b/litebox_egress_proxy/src/proxy.rs @@ -0,0 +1,592 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! The proxy itself: connection acceptance, authorization, HTTP forwarding and +//! CONNECT tunnelling. +//! +//! Every raw-validated request is authorized before any DNS or upstream +//! activity against the immutable policy and pinned address table. Plain HTTP +//! responses close the client connection, so no pipelined second request can +//! bypass raw validation. A successful CONNECT consumes its connection by +//! upgrading it to a tunnel. No upstream connection is ever reused. + +use core::convert::Infallible; +use core::error::Error as StdError; +use std::io; +use std::net::SocketAddrV4; +use std::sync::Arc; + +use bytes::Bytes; +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyExt, Empty}; +use hyper::body::Incoming; +use hyper::client::conn::http1 as client_http1; +use hyper::header::{self, HeaderValue}; +use hyper::http::uri::Authority; +use hyper::server::conn::http1 as server_http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode, Uri, Version}; +use hyper_util::rt::{TokioIo, TokioTimer}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore}; +use tokio::time::timeout; + +use crate::authority::{DEFAULT_HTTP_PORT, RequestAuthority, host_header_matches, parse_authority}; +use crate::dns::PinnedTable; +use crate::headers::{ + remove_framing_headers, strip_hop_by_hop, validate_connect_framing, validate_request_framing, + validate_response_framing, +}; +use crate::limits::{ + CLIENT_CLOSE_DRAIN_TIMEOUT, IDLE_TIMEOUT, MAX_CLIENT_CLOSE_DRAIN_BYTES, + MAX_CONCURRENT_CLIENT_CONNECTIONS, MAX_HEADER_FIELDS, MAX_REQUEST_HEADER_BYTES, + MAX_RESPONSE_HEADER_BYTES, REQUEST_HEADER_READ_TIMEOUT, TOTAL_REQUEST_TIMEOUT, + UPSTREAM_CONNECT_TIMEOUT, +}; +use crate::policy::HostPolicy; +use crate::request_head::read_validated_request_prefix; +use crate::stream::{LimitedStream, PrefixedStream, share_tcp_read}; +use crate::upstream::{BoxedUpstreamStream, UpstreamConnector}; + +/// Boxed error type used by the proxied response bodies. +type BoxError = Box; + +/// The response body type produced by the proxy. +type ProxyBody = BoxBody; + +/// Immutable state shared by every connection. +/// +/// The policy and the pinned table are fixed at startup; the connector is an +/// injected abstraction so that the request path can be exercised without a +/// real network. +pub struct ProxyState { + policy: HostPolicy, + pinned: PinnedTable, + connector: Arc, +} + +impl ProxyState { + /// Builds the shared state from an already validated policy and table. + pub fn new( + policy: HostPolicy, + pinned: PinnedTable, + connector: Arc, + ) -> Self { + Self { + policy, + pinned, + connector, + } + } + + /// Returns the policy in force. + pub fn policy(&self) -> &HostPolicy { + &self.policy + } + + /// Returns the immutable startup resolution table. + pub fn pinned(&self) -> &PinnedTable { + &self.pinned + } +} + +/// Serves client connections until `listener` fails. +/// +/// At most [`MAX_CONCURRENT_CLIENT_CONNECTIONS`] connections are served at a +/// time; further connections wait in the listener backlog. Accept errors that +/// concern a single connection are ignored, every other accept error fails +/// closed. +pub async fn serve(listener: TcpListener, state: Arc) -> io::Result<()> { + let slots = Arc::new(Semaphore::new(MAX_CONCURRENT_CLIENT_CONNECTIONS)); + + loop { + let Ok(permit) = Arc::clone(&slots).acquire_owned().await else { + return Err(io::Error::other("connection slot semaphore closed")); + }; + + let stream = match listener.accept().await { + Ok((stream, _peer)) => stream, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionAborted | io::ErrorKind::Interrupted + ) => + { + continue; + } + Err(error) => return Err(error), + }; + + let state = Arc::clone(&state); + tokio::spawn(async move { + serve_connection(state, stream, permit).await; + }); + } +} + +/// Serves exactly one request or CONNECT tunnel on a client connection. +async fn serve_connection(state: Arc, stream: TcpStream, permit: OwnedSemaphorePermit) { + // `set_nodelay` failing means the connection is already unusable. + if stream.set_nodelay(true).is_err() { + return; + } + + let (stream, mut drain_handle) = share_tcp_read(stream); + let mut stream = LimitedStream::new(stream, IDLE_TIMEOUT); + let prefix = match read_validated_request_prefix(&mut stream).await { + Ok(prefix) => prefix.into_bytes(), + Err(error) => { + if let Some(response) = error.response() { + if let Err(write_error) = stream.write_all(response).await { + diagnostic(format_args!( + "failed to write request rejection after {error}: {write_error}" + )); + } else { + let _ = stream.shutdown().await; + drain_client_input(&mut stream).await; + } + } + return; + } + }; + + let io = TokioIo::new(PrefixedStream::new(prefix, stream)); + let connection_slot = Arc::new(Mutex::new(Some(permit))); + let service_connection_slot = Arc::clone(&connection_slot); + let service = service_fn(move |request: Request| { + let state = Arc::clone(&state); + let connection_slot = Arc::clone(&service_connection_slot); + async move { Ok::<_, Infallible>(handle_request(state, connection_slot, request).await) } + }); + + let mut builder = server_http1::Builder::new(); + builder + .timer(TokioTimer::new()) + .header_read_timeout(Some(REQUEST_HEADER_READ_TIMEOUT)) + .max_buf_size(MAX_REQUEST_HEADER_BYTES) + .max_headers(MAX_HEADER_FIELDS) + .keep_alive(true) + .half_close(true); + + let result = builder.serve_connection(io, service).with_upgrades().await; + let upgraded = connection_slot.lock().await.is_none(); + if !upgraded { + drain_client_input(&mut drain_handle).await; + } + if let Err(error) = result { + diagnostic(format_args!("client connection ended: {error}")); + } +} + +/// Drains bounded client input so unread bytes cannot replace the response +/// with a connection reset during close. +async fn drain_client_input(stream: &mut S) +where + S: tokio::io::AsyncRead + Unpin, +{ + let drain = async { + let mut remaining = MAX_CLIENT_CLOSE_DRAIN_BYTES; + let mut buffer = [0_u8; 1024]; + while remaining != 0 { + let capacity = remaining.min(buffer.len()); + let read = stream.read(&mut buffer[..capacity]).await?; + if read == 0 { + break; + } + remaining -= read; + } + Ok::<(), io::Error>(()) + }; + let _ = timeout(CLIENT_CLOSE_DRAIN_TIMEOUT, drain).await; +} + +/// Dispatches one client request. +async fn handle_request( + state: Arc, + connection_slot: Arc>>, + request: Request, +) -> Response { + let is_connect = request.method() == Method::CONNECT; + let mut response = if is_connect { + handle_connect(&state, connection_slot, request).await + } else { + handle_forward(&state, request).await + }; + + // Plain HTTP and rejected CONNECT requests close after this response, so + // Hyper cannot dispatch a second head that bypassed raw prevalidation. + // Successful CONNECT responses omit this header because the connection is + // consumed by the upgraded tunnel. + if !is_connect || !response.status().is_success() { + response + .headers_mut() + .insert(header::CONNECTION, HeaderValue::from_static("close")); + } + response +} + +/// Handles a plain HTTP/1 forward-proxy request. +async fn handle_forward(state: &ProxyState, request: Request) -> Response { + if !matches!(request.version(), Version::HTTP_10 | Version::HTTP_11) { + return status_response(StatusCode::HTTP_VERSION_NOT_SUPPORTED); + } + + // Protocol upgrades other than CONNECT are not supported. + if request.headers().contains_key(header::UPGRADE) { + return status_response(StatusCode::NOT_IMPLEMENTED); + } + + if validate_request_framing(request.headers(), request.version()).is_err() { + return status_response(StatusCode::BAD_REQUEST); + } + + let Some(authority) = forward_authority(request.uri()) else { + return status_response(StatusCode::BAD_REQUEST); + }; + + let Some(origin_target) = origin_form_target(request.uri()) else { + return status_response(StatusCode::BAD_REQUEST); + }; + + if !host_header_is_consistent(&request, &authority, Some(DEFAULT_HTTP_PORT)) { + return status_response(StatusCode::BAD_REQUEST); + } + + // Authorization happens before any DNS or upstream activity. + if !state.policy.allows(authority.host(), authority.port()) { + return status_response(StatusCode::FORBIDDEN); + } + + let upstream = match connect_upstream(state, &authority).await { + Ok(stream) => LimitedStream::with_deadline(stream, IDLE_TIMEOUT, TOTAL_REQUEST_TIMEOUT), + Err(failure) => return status_response(failure.status()), + }; + + forward_to_upstream(request, &authority, origin_target, upstream).await +} + +/// Rewrites and relays a request over its own upstream connection. +async fn forward_to_upstream( + request: Request, + authority: &RequestAuthority, + origin_target: Uri, + upstream: LimitedStream, +) -> Response { + let (mut parts, body) = request.into_parts(); + + // Absolute-form has already been reduced to origin form for the upstream + // server. + parts.uri = origin_target; + parts.version = Version::HTTP_11; + + strip_hop_by_hop(&mut parts.headers); + // The outgoing message is framed from the forwarded body, never from a + // length the client claimed. + remove_framing_headers(&mut parts.headers); + parts.headers.remove(header::HOST); + let Ok(host_value) = HeaderValue::from_str(&authority.host_header_value()) else { + return status_response(StatusCode::BAD_REQUEST); + }; + parts.headers.insert(header::HOST, host_value); + + let handshake = client_http1::Builder::new() + .max_buf_size(MAX_RESPONSE_HEADER_BYTES) + .max_headers(MAX_HEADER_FIELDS) + .handshake(TokioIo::new(upstream)) + .await; + + let (mut sender, connection) = match handshake { + Ok(pair) => pair, + Err(error) => { + diagnostic(format_args!("upstream handshake failed: {error}")); + return status_response(StatusCode::BAD_GATEWAY); + } + }; + + // The connection task drives the request and response bodies; it ends when + // the response body is dropped or the upstream closes. + tokio::spawn(async move { + if let Err(error) = connection.await { + diagnostic(format_args!("upstream connection ended: {error}")); + } + }); + + let upstream_response = match sender.send_request(Request::from_parts(parts, body)).await { + Ok(response) => response, + Err(error) => { + diagnostic(format_args!("upstream request failed: {error}")); + return status_response(StatusCode::BAD_GATEWAY); + } + }; + + let (mut response_parts, response_body) = upstream_response.into_parts(); + if validate_response_framing(&response_parts.headers, response_parts.version).is_err() { + return status_response(StatusCode::BAD_GATEWAY); + } + strip_hop_by_hop(&mut response_parts.headers); + remove_framing_headers(&mut response_parts.headers); + Response::from_parts( + response_parts, + response_body.map_err(BoxError::from).boxed(), + ) +} + +/// Handles a CONNECT tunnel request. +async fn handle_connect( + state: &ProxyState, + connection_slot: Arc>>, + mut request: Request, +) -> Response { + if request.headers().contains_key(header::UPGRADE) { + return status_response(StatusCode::NOT_IMPLEMENTED); + } + if validate_connect_framing(request.headers()).is_err() { + return status_response(StatusCode::BAD_REQUEST); + } + + let Some(authority) = connect_authority(request.uri()) else { + return status_response(StatusCode::BAD_REQUEST); + }; + + // A `Host` header accompanying CONNECT has to state the port explicitly; + // there is no scheme from which a default could be derived. + if !host_header_is_consistent(&request, &authority, None) { + return status_response(StatusCode::BAD_REQUEST); + } + + if !state.policy.allows(authority.host(), authority.port()) { + return status_response(StatusCode::FORBIDDEN); + } + + // Success is reported only after the upstream connection exists. + let upstream = match connect_upstream(state, &authority).await { + Ok(stream) => LimitedStream::new(stream, IDLE_TIMEOUT), + Err(failure) => return status_response(failure.status()), + }; + + let Some(permit) = connection_slot.lock().await.take() else { + return status_response(StatusCode::SERVICE_UNAVAILABLE); + }; + let upgrade = hyper::upgrade::on(&mut request); + tokio::spawn(async move { + let _permit = permit; + match upgrade.await { + Ok(upgraded) => { + let mut client = TokioIo::new(upgraded); + let mut upstream = upstream; + if let Err(error) = tokio::io::copy_bidirectional(&mut client, &mut upstream).await + { + diagnostic(format_args!("tunnel ended: {error}")); + } + } + Err(error) => diagnostic(format_args!("tunnel upgrade failed: {error}")), + } + }); + + let mut response = Response::new(empty_body()); + *response.status_mut() = StatusCode::OK; + response +} + +/// Canonicalizes the authority of an absolute-form `http` request target. +fn forward_authority(uri: &Uri) -> Option { + // Only absolute-form targets are accepted; origin-form and asterisk-form + // requests are not proxy requests. + let scheme = uri.scheme_str()?; + if !scheme.eq_ignore_ascii_case("http") { + // `https` absolute URIs are rejected: HTTPS uses CONNECT. + return None; + } + let raw = uri.authority().map(Authority::as_str)?; + parse_authority(raw, Some(DEFAULT_HTTP_PORT)).ok() +} + +/// Reduces an absolute-form target to the origin form sent upstream. +/// +/// Control bytes, whitespace and non-ASCII bytes never belong in a request +/// target: each could make the proxy and an upstream server disagree about the +/// resource being requested. Fragments are rejected from the raw target before +/// `hyper` can discard them. +fn origin_form_target(uri: &Uri) -> Option { + let path = uri.path(); + let target = match uri.query() { + Some(query) => format!("{path}?{query}"), + None => path.to_owned(), + }; + if target.is_empty() || !target.is_ascii() { + return None; + } + if target + .bytes() + .any(|byte| byte <= 0x20 || byte == 0x7f || byte == b'#') + { + return None; + } + target.parse::().ok() +} + +/// Canonicalizes the authority-form target of a CONNECT request. +fn connect_authority(uri: &Uri) -> Option { + if uri.scheme_str().is_some() || !uri.path().is_empty() || uri.query().is_some() { + return None; + } + let raw = uri.authority().map(Authority::as_str)?; + // CONNECT requires an explicit, nonzero port. + parse_authority(raw, None).ok() +} + +/// Returns whether the `Host` header, if any, matches the request target. +fn host_header_is_consistent( + request: &Request, + authority: &RequestAuthority, + default_port: Option, +) -> bool { + let Some(value) = request.headers().get(header::HOST) else { + return true; + }; + value + .to_str() + .is_ok_and(|raw| host_header_matches(raw, authority, default_port)) +} + +/// Reason no upstream connection could be established. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UpstreamFailure { + /// The hostname had no pinned address. Authorized hostnames always have + /// one, so this can only be an internal inconsistency. + NoPinnedAddress, + /// Every pinned address refused the connection or failed. + AllAttemptsFailed, + /// At least one attempt exceeded the connect timeout and none succeeded. + TimedOut, +} + +impl UpstreamFailure { + /// Maps the failure onto the status reported to the client. + fn status(self) -> StatusCode { + match self { + Self::NoPinnedAddress | Self::AllAttemptsFailed => StatusCode::BAD_GATEWAY, + Self::TimedOut => StatusCode::GATEWAY_TIMEOUT, + } + } +} + +/// Attempts pinned addresses in their stable startup order. +async fn connect_upstream( + state: &ProxyState, + authority: &RequestAuthority, +) -> Result { + let addresses = state.pinned.addresses(authority.host()); + if addresses.is_empty() { + return Err(UpstreamFailure::NoPinnedAddress); + } + + let attempts = async { + for address in addresses { + let target = SocketAddrV4::new(*address, authority.port()); + if let Ok(stream) = state.connector.connect(target).await { + return Ok(stream); + } + } + Err(UpstreamFailure::AllAttemptsFailed) + }; + timeout(UPSTREAM_CONNECT_TIMEOUT, attempts) + .await + .unwrap_or(Err(UpstreamFailure::TimedOut)) +} + +/// Builds an empty proxied body. +fn empty_body() -> ProxyBody { + Empty::::new() + .map_err(|never| match never {}) + .boxed() +} + +/// Builds a bodiless response that also closes the client connection. +fn status_response(status: StatusCode) -> Response { + let mut response = Response::new(empty_body()); + *response.status_mut() = status; + response + .headers_mut() + .insert(header::CONNECTION, HeaderValue::from_static("close")); + response +} + +/// Writes one diagnostic line to standard error. +/// +/// Diagnostics never contain request bytes: only proxy-generated text and +/// library error messages are logged. +fn diagnostic(message: core::fmt::Arguments<'_>) { + eprintln!("litebox_egress_proxy: {message}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uri(raw: &str) -> Uri { + raw.parse().unwrap() + } + + #[test] + fn forward_targets_must_be_absolute_http() { + let authority = forward_authority(&uri("http://Example.com/path?q=1")).unwrap(); + assert_eq!(authority.host().as_str(), "example.com"); + assert_eq!(authority.port(), 80); + + assert!(forward_authority(&uri("https://example.com/")).is_none()); + assert!(forward_authority(&uri("/relative")).is_none()); + assert!(forward_authority(&uri("http://user@example.com/")).is_none()); + assert!(forward_authority(&uri("http://192.0.2.5/")).is_none()); + } + + #[test] + fn origin_form_targets_are_canonical() { + assert_eq!( + origin_form_target(&uri("http://example.com/path?q=1")) + .unwrap() + .to_string(), + "/path?q=1" + ); + assert_eq!( + origin_form_target(&uri("http://example.com")) + .unwrap() + .to_string(), + "/" + ); + assert_eq!( + origin_form_target(&uri("http://example.com?q=1")) + .unwrap() + .to_string(), + "/?q=1" + ); + } + + #[test] + fn connect_targets_must_be_authority_form() { + let authority = connect_authority(&uri("example.com:443")).unwrap(); + assert_eq!(authority.host().as_str(), "example.com"); + assert_eq!(authority.port(), 443); + + assert!(connect_authority(&uri("http://example.com:443")).is_none()); + assert!(connect_authority(&uri("example.com")).is_none()); + assert!(connect_authority(&uri("example.com:0")).is_none()); + } + + #[test] + fn upstream_failures_map_to_statuses() { + assert_eq!( + UpstreamFailure::AllAttemptsFailed.status(), + StatusCode::BAD_GATEWAY + ); + assert_eq!( + UpstreamFailure::NoPinnedAddress.status(), + StatusCode::BAD_GATEWAY + ); + assert_eq!( + UpstreamFailure::TimedOut.status(), + StatusCode::GATEWAY_TIMEOUT + ); + } +} diff --git a/litebox_egress_proxy/src/request_head.rs b/litebox_egress_proxy/src/request_head.rs new file mode 100644 index 000000000..44a311a20 --- /dev/null +++ b/litebox_egress_proxy/src/request_head.rs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Raw request-head validation before HTTP framing normalization. + +use std::io; + +use bytes::Bytes; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::time::timeout; + +use crate::headers::validate_raw_request_framing; +use crate::limits::{MAX_HEADER_FIELDS, MAX_REQUEST_HEADER_BYTES, REQUEST_HEADER_READ_TIMEOUT}; + +/// A complete, framing-validated request prefix, including bytes read ahead. +pub(crate) struct ValidatedRequestPrefix(Bytes); + +impl ValidatedRequestPrefix { + pub(crate) fn into_bytes(self) -> Bytes { + self.0 + } +} + +/// Reason the first request head could not be accepted. +#[derive(Debug, Error)] +pub(crate) enum RequestHeadError { + #[error("client closed before sending a complete request head")] + Closed, + #[error("request head exceeded the read timeout")] + TimedOut, + #[error("request head exceeded a configured limit")] + TooLarge, + #[error("request head is malformed or framing-ambiguous")] + Malformed, + #[error("request-head read failed: {0}")] + Io(#[from] io::Error), +} + +impl RequestHeadError { + /// A complete HTTP rejection for errors caused by client input. + pub(crate) fn response(&self) -> Option<&'static [u8]> { + match self { + Self::Closed | Self::Io(_) => None, + Self::TimedOut => Some( + b"HTTP/1.1 408 Request Timeout\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ), + Self::TooLarge => Some( + b"HTTP/1.1 431 Request Header Fields Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ), + Self::Malformed => Some( + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ), + } + } +} + +/// Reads and validates exactly the first raw HTTP/1 request head. +pub(crate) async fn read_validated_request_prefix( + stream: &mut S, +) -> Result +where + S: AsyncRead + Unpin, +{ + timeout(REQUEST_HEADER_READ_TIMEOUT, read_request_prefix(stream)) + .await + .map_err(|_| RequestHeadError::TimedOut)? +} + +async fn read_request_prefix(stream: &mut S) -> Result +where + S: AsyncRead + Unpin, +{ + let mut prefix = Vec::with_capacity(1024); + let mut chunk = [0_u8; 1024]; + + loop { + if prefix.len() == MAX_REQUEST_HEADER_BYTES { + return Err(RequestHeadError::TooLarge); + } + let remaining = MAX_REQUEST_HEADER_BYTES - prefix.len(); + let read_capacity = remaining.min(chunk.len()); + let read = stream.read(&mut chunk[..read_capacity]).await?; + if read == 0 { + return Err(RequestHeadError::Closed); + } + prefix.extend_from_slice(&chunk[..read]); + + let mut headers = [httparse::EMPTY_HEADER; MAX_HEADER_FIELDS]; + let mut request = httparse::Request::new(&mut headers); + match request.parse(&prefix) { + Ok(httparse::Status::Partial) => {} + Ok(httparse::Status::Complete(_)) => { + let method = request.method.ok_or(RequestHeadError::Malformed)?; + let target = request.path.ok_or(RequestHeadError::Malformed)?; + let version = request.version.ok_or(RequestHeadError::Malformed)?; + if target.as_bytes().contains(&b'#') { + return Err(RequestHeadError::Malformed); + } + validate_raw_request_framing(method, version, request.headers) + .map_err(|_| RequestHeadError::Malformed)?; + return Ok(ValidatedRequestPrefix(Bytes::from(prefix))); + } + Err(httparse::Error::TooManyHeaders) => return Err(RequestHeadError::TooLarge), + Err(_) => return Err(RequestHeadError::Malformed), + } + } +} diff --git a/litebox_egress_proxy/src/stream.rs b/litebox_egress_proxy/src/stream.rs new file mode 100644 index 000000000..6c62acae0 --- /dev/null +++ b/litebox_egress_proxy/src/stream.rs @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Time-bounded stream wrapper. +//! +//! [`LimitedStream`] enforces two independent bounds on a byte stream: +//! +//! * an idle timeout, refreshed whenever the stream makes read or write +//! progress, and +//! * an optional total deadline for the whole stream lifetime. +//! +//! Both are reported as [`io::ErrorKind::TimedOut`], which tears the affected +//! connection down. + +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; +use core::time::Duration; +use std::io; +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::time::{Instant, Sleep, sleep_until}; + +/// A clonable handle to the read half of a TCP stream. +/// +/// The proxy retains one handle so it can perform a bounded drain after Hyper +/// flushes a non-upgraded response and releases its stream. +#[derive(Clone)] +pub(crate) struct SharedTcpRead { + inner: Arc>, +} + +impl AsyncRead for SharedTcpRead { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let Ok(mut inner) = self.inner.lock() else { + return Poll::Ready(Err(io::Error::other("TCP read half mutex poisoned"))); + }; + Pin::new(&mut *inner).poll_read(cx, buf) + } +} + +/// A split TCP stream whose read half can be retained for bounded closing. +pub(crate) struct SharedTcpStream { + read: SharedTcpRead, + write: OwnedWriteHalf, +} + +/// Splits a stream while retaining a clonable handle to its read half. +pub(crate) fn share_tcp_read(stream: TcpStream) -> (SharedTcpStream, SharedTcpRead) { + let (read, write) = stream.into_split(); + let read = SharedTcpRead { + inner: Arc::new(Mutex::new(read)), + }; + ( + SharedTcpStream { + read: read.clone(), + write, + }, + read, + ) +} + +impl AsyncRead for SharedTcpStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.get_mut().read).poll_read(cx, buf) + } +} + +impl AsyncWrite for SharedTcpStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().write).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().write).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().write).poll_shutdown(cx) + } +} + +/// A stream that replays a prefix before reading from its inner stream. +pub struct PrefixedStream { + prefix: Bytes, + inner: S, +} + +impl PrefixedStream { + /// Creates a stream that yields `prefix` before bytes from `inner`. + pub fn new(prefix: Bytes, inner: S) -> Self { + Self { prefix, inner } + } +} + +impl AsyncRead for PrefixedStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if !this.prefix.is_empty() && buf.remaining() != 0 { + let length = this.prefix.len().min(buf.remaining()); + let bytes = this.prefix.split_to(length); + buf.put_slice(&bytes); + return Poll::Ready(Ok(())); + } + Pin::new(&mut this.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for PrefixedStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} + +/// A stream that fails once it stalls for too long, or outlives its deadline. +pub struct LimitedStream { + inner: S, + idle: Duration, + idle_timer: Pin>, + deadline: Option>>, +} + +impl LimitedStream { + /// Wraps `inner` with an idle timeout only. + pub fn new(inner: S, idle: Duration) -> Self { + Self { + inner, + idle, + idle_timer: Box::pin(sleep_until(Instant::now() + idle)), + deadline: None, + } + } + + /// Wraps `inner` with an idle timeout and a total lifetime bound. + pub fn with_deadline(inner: S, idle: Duration, total: Duration) -> Self { + let mut stream = Self::new(inner, idle); + stream.deadline = Some(Box::pin(sleep_until(Instant::now() + total))); + stream + } + + /// Restarts the idle timeout after observable progress. + fn touch(&mut self) { + let deadline = Instant::now() + self.idle; + self.idle_timer.as_mut().reset(deadline); + } + + /// Returns `true` when the total deadline has already elapsed. + fn deadline_expired(&mut self, cx: &mut Context<'_>) -> bool { + self.deadline + .as_mut() + .is_some_and(|deadline| deadline.as_mut().poll(cx).is_ready()) + } + + /// Returns `true` when the stream has been idle for too long. + fn idle_expired(&mut self, cx: &mut Context<'_>) -> bool { + self.idle_timer.as_mut().poll(cx).is_ready() + } +} + +fn timed_out(reason: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::TimedOut, reason) +} + +impl AsyncRead for LimitedStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if this.deadline_expired(cx) { + return Poll::Ready(Err(timed_out("stream deadline exceeded"))); + } + match Pin::new(&mut this.inner).poll_read(cx, buf) { + Poll::Ready(result) => { + this.touch(); + Poll::Ready(result) + } + Poll::Pending if this.idle_expired(cx) => { + Poll::Ready(Err(timed_out("stream idle timeout exceeded"))) + } + Poll::Pending => Poll::Pending, + } + } +} + +impl AsyncWrite for LimitedStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + if this.deadline_expired(cx) { + return Poll::Ready(Err(timed_out("stream deadline exceeded"))); + } + match Pin::new(&mut this.inner).poll_write(cx, buf) { + Poll::Ready(result) => { + this.touch(); + Poll::Ready(result) + } + Poll::Pending if this.idle_expired(cx) => { + Poll::Ready(Err(timed_out("stream idle timeout exceeded"))) + } + Poll::Pending => Poll::Pending, + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if this.deadline_expired(cx) { + return Poll::Ready(Err(timed_out("stream deadline exceeded"))); + } + match Pin::new(&mut this.inner).poll_flush(cx) { + Poll::Ready(result) => { + this.touch(); + Poll::Ready(result) + } + Poll::Pending if this.idle_expired(cx) => { + Poll::Ready(Err(timed_out("stream idle timeout exceeded"))) + } + Poll::Pending => Poll::Pending, + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match Pin::new(&mut this.inner).poll_shutdown(cx) { + Poll::Ready(result) => { + this.touch(); + Poll::Ready(result) + } + Poll::Pending if this.idle_expired(cx) => { + Poll::Ready(Err(timed_out("stream idle timeout exceeded"))) + } + Poll::Pending => Poll::Pending, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex}; + + fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + #[test] + fn idle_stream_times_out() { + runtime().block_on(async { + tokio::time::pause(); + let (client, _server) = duplex(64); + let mut limited = LimitedStream::new(client, Duration::from_secs(60)); + let mut buffer = [0_u8; 8]; + let error = limited.read(&mut buffer).await.unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + }); + } + + #[test] + fn deadline_fails_even_when_active() { + runtime().block_on(async { + tokio::time::pause(); + let (client, mut server) = duplex(64); + let mut limited = LimitedStream::with_deadline( + client, + Duration::from_secs(60), + Duration::from_secs(10), + ); + + tokio::spawn(async move { + loop { + if server.write_all(&[1]).await.is_err() { + break; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }); + + let mut buffer = [0_u8; 1]; + let mut reads = 0; + let error = loop { + reads += 1; + assert!(reads <= 20, "total deadline was not enforced"); + match limited.read_exact(&mut buffer).await { + Ok(_) => {} + Err(error) => break error, + } + }; + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + }); + } +} diff --git a/litebox_egress_proxy/src/upstream.rs b/litebox_egress_proxy/src/upstream.rs new file mode 100644 index 000000000..59e366fab --- /dev/null +++ b/litebox_egress_proxy/src/upstream.rs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Upstream connection abstraction. +//! +//! Request handling only ever dials an address that came from the immutable +//! startup resolution table. The connector is an injected abstraction so that +//! tests can drive the proxy against loopback services without relaxing the +//! address validation that the production resolver performs. + +use core::future::Future; +use core::pin::Pin; +use std::io; +use std::net::SocketAddrV4; + +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::TcpStream; + +/// A bidirectional upstream byte stream. +pub trait UpstreamStream: AsyncRead + AsyncWrite + Send + Unpin {} + +impl UpstreamStream for T {} + +/// An owned upstream byte stream. +pub type BoxedUpstreamStream = Box; + +/// A future returned by an [`UpstreamConnector`]. +pub type ConnectFuture<'a> = + Pin> + Send + 'a>>; + +/// Opens upstream TCP connections to pinned addresses. +pub trait UpstreamConnector: Send + Sync + 'static { + /// Connects to `target`, which is always a pinned address combined with an + /// authorized destination port. + fn connect(&self, target: SocketAddrV4) -> ConnectFuture<'_>; +} + +/// The production connector: a plain TCP connection with `TCP_NODELAY` set. +#[derive(Clone, Copy, Debug, Default)] +pub struct TcpUpstreamConnector; + +impl UpstreamConnector for TcpUpstreamConnector { + fn connect(&self, target: SocketAddrV4) -> ConnectFuture<'_> { + Box::pin(async move { + let stream = TcpStream::connect(target).await?; + // Proxied requests are latency sensitive and already batched by + // hyper's writer. + stream.set_nodelay(true)?; + Ok(Box::new(stream) as BoxedUpstreamStream) + }) + } +} diff --git a/litebox_egress_proxy/tests/loopback.rs b/litebox_egress_proxy/tests/loopback.rs new file mode 100644 index 000000000..68ad0ab66 --- /dev/null +++ b/litebox_egress_proxy/tests/loopback.rs @@ -0,0 +1,845 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Hermetic loopback tests for the proxy request path. +//! +//! The tests inject a [`HostResolver`] and an [`UpstreamConnector`] instead of +//! touching DNS or the network. The injected resolver answers with ordinary +//! globally routable addresses, so the pinned table is built under exactly the +//! production address rules, and only the injected connector maps those pinned +//! addresses onto loopback test servers. No production validation is relaxed +//! for these tests. + +use std::collections::HashMap; +use std::io; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use litebox_egress_proxy::dns::{ + HostResolver, PinnedTable, ResolveError, ResolveFuture, TargetPolicy, +}; +use litebox_egress_proxy::listener::{ListenerSource, acquire}; +use litebox_egress_proxy::policy::{HostPolicy, HostRule, Hostname}; +use litebox_egress_proxy::proxy::{ProxyState, serve}; +use litebox_egress_proxy::upstream::{BoxedUpstreamStream, ConnectFuture, UpstreamConnector}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; +use tokio::time::timeout; + +/// Bound on every test-side network wait. +const TEST_TIMEOUT: Duration = Duration::from_secs(5); + +/// A resolver with a fixed, hermetic answer table. +struct StaticResolver { + answers: HashMap>, +} + +impl HostResolver for StaticResolver { + fn resolve(&self, host: Hostname) -> ResolveFuture<'_> { + let answer = self + .answers + .get(&host) + .cloned() + .ok_or(ResolveError::NoAddresses); + Box::pin(async move { answer }) + } +} + +/// A connector that routes pinned addresses to loopback test servers. +struct LoopbackConnector { + routes: HashMap, + attempts: Arc, +} + +impl UpstreamConnector for LoopbackConnector { + fn connect(&self, target: SocketAddrV4) -> ConnectFuture<'_> { + self.attempts.fetch_add(1, Ordering::SeqCst); + let route = self.routes.get(&target).copied(); + Box::pin(async move { + let Some(route) = route else { + return Err(io::Error::from(io::ErrorKind::ConnectionRefused)); + }; + let stream = TcpStream::connect(route).await?; + Ok(Box::new(stream) as BoxedUpstreamStream) + }) + } +} + +/// A running proxy under test. +struct TestProxy { + address: SocketAddrV4, + attempts: Arc, +} + +impl TestProxy { + /// Starts a proxy with `rules` in force, routing `routes` to loopback. + /// + /// Every policy hostname resolves to a distinct globally routable address + /// that satisfies the production address rules, and only the listed + /// `(host, port)` pairs have a working upstream. + async fn start(rules: &[&str], routes: &[(&str, u16, SocketAddr)]) -> Self { + let policy = HostPolicy::from_rules( + rules + .iter() + .map(|rule| rule.parse::().expect("valid rule")) + .collect::>(), + ) + .expect("valid policy"); + + let mut answers = HashMap::new(); + for (index, host) in policy.hostnames().enumerate() { + let last = u8::try_from(index + 1).expect("few test hosts"); + answers.insert(host.clone(), vec![Ipv4Addr::new(93, 184, 216, last)]); + } + + let mut mapped = HashMap::new(); + for (host, port, address) in routes { + let host = Hostname::parse(host).expect("valid hostname"); + let pinned = answers.get(&host).expect("routed host is in policy")[0]; + mapped.insert(SocketAddrV4::new(pinned, *port), *address); + } + + let resolver = StaticResolver { answers }; + let pinned = + PinnedTable::resolve(&policy, &TargetPolicy::public_only(), Arc::new(resolver)) + .await + .expect("hermetic resolution succeeds"); + + let attempts = Arc::new(AtomicUsize::new(0)); + let connector = LoopbackConnector { + routes: mapped, + attempts: Arc::clone(&attempts), + }; + let state = Arc::new(ProxyState::new(policy, pinned, Arc::new(connector))); + + let listener = acquire(ListenerSource::Bind(SocketAddrV4::new( + Ipv4Addr::LOCALHOST, + 0, + ))) + .expect("loopback listener"); + let listener = TcpListener::from_std(listener).expect("async listener"); + let SocketAddr::V4(address) = listener.local_addr().expect("listener address") else { + panic!("expected an IPv4 listener"); + }; + + tokio::spawn(async move { + let _ = serve(listener, state).await; + }); + + Self { address, attempts } + } + + /// Number of upstream connection attempts made so far. + fn upstream_attempts(&self) -> usize { + self.attempts.load(Ordering::SeqCst) + } + + /// Opens a client connection to the proxy. + async fn connect(&self) -> ProxyClient { + let stream = timeout(TEST_TIMEOUT, TcpStream::connect(self.address)) + .await + .expect("connect did not time out") + .expect("client connects to the proxy"); + ProxyClient { + stream, + buffer: Vec::new(), + } + } + + /// Sends one request on a fresh connection and reads one response. + async fn request(&self, raw: &str) -> HttpResponse { + let mut client = self.connect().await; + client.send(raw.as_bytes()).await; + client.read_response().await + } +} + +/// A raw HTTP client, so that malformed requests can be sent verbatim. +struct ProxyClient { + stream: TcpStream, + buffer: Vec, +} + +impl ProxyClient { + async fn send(&mut self, bytes: &[u8]) { + timeout(TEST_TIMEOUT, self.stream.write_all(bytes)) + .await + .expect("write did not time out") + .expect("write succeeds"); + } + + /// Reads more bytes into the buffer, returning `false` at end of stream. + async fn fill(&mut self) -> bool { + let mut chunk = [0_u8; 4096]; + let read = timeout(TEST_TIMEOUT, self.stream.read(&mut chunk)) + .await + .expect("read did not time out") + .expect("read succeeds"); + if read == 0 { + return false; + } + self.buffer.extend_from_slice(&chunk[..read]); + true + } + + /// Reads one complete HTTP response. + async fn read_response(&mut self) -> HttpResponse { + let head_end = loop { + if let Some(index) = find_subslice(&self.buffer, b"\r\n\r\n") { + break index + 4; + } + assert!( + self.fill().await, + "connection closed before a response head" + ); + }; + + let head = String::from_utf8(self.buffer[..head_end].to_vec()).expect("ASCII head"); + self.buffer.drain(..head_end); + + let status = head + .split_whitespace() + .nth(1) + .and_then(|code| code.parse::().ok()) + .expect("status code"); + + let length = header_value(&head, "content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + while self.buffer.len() < length { + assert!(self.fill().await, "connection closed before the body ended"); + } + let body = self.buffer.drain(..length).collect::>(); + + HttpResponse { status, head, body } + } + + /// Reads exactly `length` raw bytes, used for tunnelled traffic. + async fn read_exact(&mut self, length: usize) -> Vec { + while self.buffer.len() < length { + assert!( + self.fill().await, + "connection closed before the tunnel data" + ); + } + self.buffer.drain(..length).collect() + } +} + +/// A parsed response. +struct HttpResponse { + status: u16, + head: String, + body: Vec, +} + +/// Finds `needle` in `haystack`. +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +/// Returns the first value of `name` in a message head. +fn header_value(head: &str, name: &str) -> Option { + head.lines().skip(1).find_map(|line| { + let (key, value) = line.split_once(':')?; + key.trim() + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_owned()) + }) +} + +/// Starts a loopback server that records one request per connection and replies +/// with `response`. +async fn recording_upstream( + response: &'static str, +) -> (SocketAddr, mpsc::UnboundedReceiver) { + let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .await + .expect("upstream listener"); + let address = listener.local_addr().expect("upstream address"); + let (sender, receiver) = mpsc::unbounded_channel(); + + tokio::spawn(async move { + while let Ok((mut stream, _peer)) = listener.accept().await { + let sender = sender.clone(); + tokio::spawn(async move { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + + let head_end = loop { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(read) => buffer.extend_from_slice(&chunk[..read]), + } + if let Some(index) = find_subslice(&buffer, b"\r\n\r\n") { + break index + 4; + } + }; + + let head = String::from_utf8_lossy(&buffer[..head_end]).into_owned(); + let body_length = header_value(&head, "content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let chunked = header_value(&head, "transfer-encoding") + .is_some_and(|value| value.eq_ignore_ascii_case("chunked")); + + while buffer.len() < head_end + body_length + || (chunked && find_subslice(&buffer, b"0\r\n\r\n").is_none()) + { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(read) => buffer.extend_from_slice(&chunk[..read]), + } + } + + let _ = sender.send(String::from_utf8_lossy(&buffer).into_owned()); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; + }); + } + }); + + (address, receiver) +} + +/// Starts a loopback echo server for tunnel tests. +async fn echo_upstream() -> SocketAddr { + let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .await + .expect("echo listener"); + let address = listener.local_addr().expect("echo address"); + + tokio::spawn(async move { + while let Ok((mut stream, _peer)) = listener.accept().await { + tokio::spawn(async move { + let (mut reader, mut writer) = stream.split(); + let _ = tokio::io::copy(&mut reader, &mut writer).await; + }); + } + }); + + address +} + +const OK_RESPONSE: &str = + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nhi"; + +#[tokio::test] +async fn forward_request_is_rewritten_and_relayed() { + let (upstream, mut requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let response = proxy + .request(concat!( + "GET http://Allowed.Example/path?q=1 HTTP/1.1\r\n", + "Host: allowed.example\r\n", + "Proxy-Connection: keep-alive\r\n", + "Connection: X-Secret\r\n", + "X-Secret: value\r\n", + "X-Kept: value\r\n", + "\r\n" + )) + .await; + + assert_eq!(response.status, 200); + assert_eq!(response.body, b"hi"); + + let forwarded = timeout(TEST_TIMEOUT, requests.recv()) + .await + .expect("upstream received a request") + .expect("request text"); + + assert!( + forwarded.starts_with("GET /path?q=1 HTTP/1.1\r\n"), + "unexpected request line in {forwarded:?}" + ); + assert_eq!( + header_value(&forwarded, "host").as_deref(), + Some("allowed.example") + ); + assert!(header_value(&forwarded, "proxy-connection").is_none()); + assert!(header_value(&forwarded, "connection").is_none()); + assert!(header_value(&forwarded, "x-secret").is_none()); + assert_eq!(header_value(&forwarded, "x-kept").as_deref(), Some("value")); +} + +#[tokio::test] +async fn empty_absolute_path_with_query_is_normalized() { + let (upstream, mut requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let response = proxy + .request("GET http://allowed.example?query=1 HTTP/1.1\r\nHost: allowed.example\r\n\r\n") + .await; + assert_eq!(response.status, 200); + + let forwarded = timeout(TEST_TIMEOUT, requests.recv()) + .await + .expect("upstream received the request") + .expect("request text"); + assert!(forwarded.starts_with("GET /?query=1 HTTP/1.1\r\n")); +} + +#[tokio::test] +async fn forward_request_body_is_relayed() { + let (upstream, mut requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:8080"], + &[("allowed.example", 8080, upstream)], + ) + .await; + + let response = proxy + .request(concat!( + "POST http://allowed.example:8080/submit HTTP/1.1\r\n", + "Host: allowed.example:8080\r\n", + "Content-Length: 5\r\n", + "\r\n", + "hello" + )) + .await; + + assert_eq!(response.status, 200); + + let forwarded = timeout(TEST_TIMEOUT, requests.recv()) + .await + .expect("upstream received a request") + .expect("request text"); + + assert!(forwarded.starts_with("POST /submit HTTP/1.1\r\n")); + assert_eq!( + header_value(&forwarded, "host").as_deref(), + Some("allowed.example:8080") + ); + assert!( + forwarded.ends_with("hello"), + "body missing in {forwarded:?}" + ); +} + +#[tokio::test] +async fn disallowed_host_is_denied_without_upstream_activity() { + let (upstream, _requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let response = proxy + .request("GET http://denied.example/ HTTP/1.1\r\nHost: denied.example\r\n\r\n") + .await; + + assert_eq!(response.status, 403); + assert_eq!(proxy.upstream_attempts(), 0); +} + +#[tokio::test] +async fn denied_request_body_is_drained_before_close() { + let (upstream, _requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let body = "x".repeat(32 * 1024); + let request = format!( + "POST http://denied.example/upload HTTP/1.1\r\n\ + Host: denied.example\r\n\ + Content-Length: {}\r\n\ + \r\n\ + {body}", + body.len() + ); + + let mut client = proxy.connect().await; + client.send(request.as_bytes()).await; + let response = client.read_response().await; + assert_eq!(response.status, 403); + assert!(!client.fill().await); + assert_eq!(proxy.upstream_attempts(), 0); +} + +#[tokio::test] +async fn disallowed_port_is_denied() { + let (upstream, _requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let response = proxy + .request("GET http://allowed.example:8443/ HTTP/1.1\r\nHost: allowed.example:8443\r\n\r\n") + .await; + + assert_eq!(response.status, 403); + assert_eq!(proxy.upstream_attempts(), 0); +} + +#[tokio::test] +async fn reserved_dns_port_is_denied() { + let (upstream, _requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let response = proxy + .request("CONNECT allowed.example:53 HTTP/1.1\r\nHost: allowed.example:53\r\n\r\n") + .await; + + assert_eq!(response.status, 403); + assert_eq!(proxy.upstream_attempts(), 0); +} + +#[tokio::test] +async fn malformed_and_unsupported_requests_are_rejected() { + let (upstream, _requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + // Host header disagreeing with the request target. + let mismatched = proxy + .request("GET http://allowed.example/ HTTP/1.1\r\nHost: other.example\r\n\r\n") + .await; + assert_eq!(mismatched.status, 400); + + // HTTPS must use CONNECT. + let https = proxy + .request("GET https://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\n\r\n") + .await; + assert_eq!(https.status, 400); + + // Origin-form targets are not proxy requests. + let origin_form = proxy + .request("GET /path HTTP/1.1\r\nHost: allowed.example\r\n\r\n") + .await; + assert_eq!(origin_form.status, 400); + + // IP-literal authorities belong to direct policy, not hostname policy. + let ip_literal = proxy + .request("GET http://93.184.216.1/ HTTP/1.1\r\nHost: 93.184.216.1\r\n\r\n") + .await; + assert_eq!(ip_literal.status, 400); + + // Userinfo could make the proxy and an origin server disagree. + let userinfo = proxy + .request("GET http://user@allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\n\r\n") + .await; + assert_eq!(userinfo.status, 400); + + // Hyper discards fragments, so they must be rejected from the raw target. + let fragment = proxy + .request( + "GET http://allowed.example/path#fragment HTTP/1.1\r\nHost: allowed.example\r\n\r\n", + ) + .await; + assert_eq!(fragment.status, 400); + + // Simultaneous Content-Length and Transfer-Encoding is ambiguous framing. + let ambiguous = proxy + .request(concat!( + "POST http://allowed.example/ HTTP/1.1\r\n", + "Host: allowed.example\r\n", + "Content-Length: 5\r\n", + "Transfer-Encoding: chunked\r\n", + "\r\n" + )) + .await; + assert_eq!(ambiguous.status, 400); + + let reversed_ambiguous = proxy + .request(concat!( + "POST http://allowed.example/ HTTP/1.1\r\n", + "Host: allowed.example\r\n", + "Transfer-Encoding: chunked\r\n", + "Content-Length: 5\r\n", + "\r\n", + "0\r\n\r\n" + )) + .await; + assert_eq!(reversed_ambiguous.status, 400); + + let duplicate_length = proxy + .request(concat!( + "POST http://allowed.example/ HTTP/1.1\r\n", + "Host: allowed.example\r\n", + "Content-Length: 0\r\n", + "Content-Length: 0\r\n", + "\r\n" + )) + .await; + assert_eq!(duplicate_length.status, 400); + + assert_eq!(proxy.upstream_attempts(), 0); +} + +#[tokio::test] +async fn upgrade_request_is_rejected() { + let (upstream, _requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let response = proxy + .request(concat!( + "GET http://allowed.example/ HTTP/1.1\r\n", + "Host: allowed.example\r\n", + "Upgrade: websocket\r\n", + "\r\n" + )) + .await; + + assert_eq!(response.status, 501); + assert_eq!(proxy.upstream_attempts(), 0); +} + +#[tokio::test] +async fn unreachable_upstream_yields_bad_gateway() { + // The hostname is allowed and pinned, but nothing routes its address. + let proxy = TestProxy::start(&["allowed.example:80"], &[]).await; + + let response = proxy + .request("GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\n\r\n") + .await; + + assert_eq!(response.status, 502); + assert_eq!(proxy.upstream_attempts(), 1); +} + +#[tokio::test] +async fn connect_tunnel_relays_bytes() { + let upstream = echo_upstream().await; + let proxy = TestProxy::start( + &["allowed.example:443"], + &[("allowed.example", 443, upstream)], + ) + .await; + + let mut client = proxy.connect().await; + client + .send( + concat!( + "CONNECT allowed.example:443 HTTP/1.1\r\n", + "Host: allowed.example:443\r\n", + "\r\n", + "early" + ) + .as_bytes(), + ) + .await; + + let response = client.read_response().await; + assert_eq!(response.status, 200); + assert!(response.body.is_empty()); + assert!( + !response + .head + .to_ascii_lowercase() + .contains("connection: close") + ); + assert_eq!(client.read_exact(5).await, b"early"); + + client.send(b"tunnelled").await; + assert_eq!(client.read_exact(9).await, b"tunnelled"); +} + +#[tokio::test] +async fn connect_requests_are_validated() { + let upstream = echo_upstream().await; + let proxy = TestProxy::start( + &["allowed.example:443"], + &[("allowed.example", 443, upstream)], + ) + .await; + + let denied = proxy + .request("CONNECT denied.example:443 HTTP/1.1\r\nHost: denied.example:443\r\n\r\n") + .await; + assert_eq!(denied.status, 403); + + let no_port = proxy + .request("CONNECT allowed.example HTTP/1.1\r\nHost: allowed.example\r\n\r\n") + .await; + assert_eq!(no_port.status, 400); + + let ip_literal = proxy + .request("CONNECT 93.184.216.1:443 HTTP/1.1\r\nHost: 93.184.216.1:443\r\n\r\n") + .await; + assert_eq!(ip_literal.status, 400); + + let mismatched_host = proxy + .request("CONNECT allowed.example:443 HTTP/1.1\r\nHost: allowed.example:80\r\n\r\n") + .await; + assert_eq!(mismatched_host.status, 400); + + assert_eq!(proxy.upstream_attempts(), 0); +} + +#[tokio::test] +async fn denied_connect_early_bytes_are_drained_before_close() { + let upstream = echo_upstream().await; + let proxy = TestProxy::start( + &["allowed.example:443"], + &[("allowed.example", 443, upstream)], + ) + .await; + + let early = "x".repeat(32 * 1024); + let request = format!( + "CONNECT denied.example:443 HTTP/1.1\r\n\ + Host: denied.example:443\r\n\ + \r\n\ + {early}" + ); + + let mut client = proxy.connect().await; + client.send(request.as_bytes()).await; + let response = client.read_response().await; + assert_eq!(response.status, 403); + assert!(!client.fill().await); + assert_eq!(proxy.upstream_attempts(), 0); +} + +#[tokio::test] +async fn each_connection_serves_one_independently_authorized_request() { + let (upstream, mut requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let mut client = proxy.connect().await; + client + .send( + concat!( + "GET http://allowed.example/first HTTP/1.1\r\n", + "Host: allowed.example\r\n", + "\r\n", + "GET http://denied.example/second HTTP/1.1\r\n", + "Host: denied.example\r\n", + "\r\n" + ) + .as_bytes(), + ) + .await; + + let first = client.read_response().await; + assert_eq!(first.status, 200); + assert!(first.head.contains("200")); + assert!( + first + .head + .to_ascii_lowercase() + .contains("connection: close") + ); + assert!(!client.fill().await); + + let second = proxy + .request("GET http://denied.example/second HTTP/1.1\r\nHost: denied.example\r\n\r\n") + .await; + assert_eq!(second.status, 403); + + let forwarded = timeout(TEST_TIMEOUT, requests.recv()) + .await + .expect("upstream received the first request") + .expect("request text"); + assert!(forwarded.starts_with("GET /first HTTP/1.1\r\n")); + + // Only the authorized request reached an upstream connection. + assert_eq!(proxy.upstream_attempts(), 1); +} + +#[tokio::test] +async fn malformed_header_syntax_is_rejected() { + let (upstream, _requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + // Whitespace before a header colon. + let spaced = proxy + .request("GET http://allowed.example/ HTTP/1.1\r\nHost : allowed.example\r\n\r\n") + .await; + assert_eq!(spaced.status, 400); + + // Obsolete line folding. + let folded = proxy + .request(concat!( + "GET http://allowed.example/ HTTP/1.1\r\n", + "Host: allowed.example\r\n", + "X-Folded: one\r\n two\r\n", + "\r\n" + )) + .await; + assert_eq!(folded.status, 400); + + assert_eq!(proxy.upstream_attempts(), 0); +} + +#[tokio::test] +async fn oversized_request_head_is_bounded() { + let (upstream, _requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let mut request = + String::from("GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\nX-Big: "); + request.push_str(&"a".repeat(32 * 1024)); + request.push_str("\r\n\r\n"); + + let response = proxy.request(&request).await; + + // Raw prevalidation bounds the head before Hyper parses it. + assert_eq!(response.status, 431); + assert_eq!(proxy.upstream_attempts(), 0); +} + +#[tokio::test] +async fn http_1_0_requests_are_forwarded_as_http_1_1() { + let (upstream, mut requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let response = proxy + .request("GET http://allowed.example/legacy HTTP/1.0\r\n\r\n") + .await; + assert_eq!(response.status, 200); + assert!(response.head.starts_with("HTTP/1.0 200")); + + let forwarded = timeout(TEST_TIMEOUT, requests.recv()) + .await + .expect("upstream received a request") + .expect("request text"); + assert!(forwarded.starts_with("GET /legacy HTTP/1.1\r\n")); +} From f60aed2e1b1e87b2bcf34d60f74701e424bca6b7 Mon Sep 17 00:00:00 2001 From: Weidong Cui Date: Fri, 28 Aug 2026 16:36:48 -0700 Subject: [PATCH 2/2] Simplify egress proxy DNS policy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 239ec5f7-870a-4259-bcae-4ca85fb913a0 --- Cargo.lock | 1 - litebox_egress_proxy/Cargo.toml | 2 - litebox_egress_proxy/src/config.rs | 97 +---- litebox_egress_proxy/src/dns.rs | 490 +------------------------ litebox_egress_proxy/src/lib.rs | 45 +-- litebox_egress_proxy/src/limits.rs | 22 +- litebox_egress_proxy/src/policy.rs | 79 +--- litebox_egress_proxy/src/proxy.rs | 57 ++- litebox_egress_proxy/src/upstream.rs | 11 +- litebox_egress_proxy/tests/loopback.rs | 141 +++---- 10 files changed, 154 insertions(+), 791 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12a6cea2b..6b2f79235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1739,7 +1739,6 @@ dependencies = [ "httparse", "hyper", "hyper-util", - "ipnet", "libc", "thiserror", "tokio", diff --git a/litebox_egress_proxy/Cargo.toml b/litebox_egress_proxy/Cargo.toml index b00a1db8f..be3c81fbc 100644 --- a/litebox_egress_proxy/Cargo.toml +++ b/litebox_egress_proxy/Cargo.toml @@ -37,8 +37,6 @@ hyper-util = { version = "0.1.20", default-features = false, features = [ ] } # Raw request-head validation before `hyper` applies RFC framing normalization. httparse = { version = "1.10.1", default-features = false } -# Canonical IPv4 CIDRs for the proxy-only resolved-destination envelope. -ipnet = { version = "2.11", default-features = false } thiserror = { version = "2.0", default-features = false, features = ["std"] } tokio = { version = "1.50", default-features = false, features = [ "io-util", diff --git a/litebox_egress_proxy/src/config.rs b/litebox_egress_proxy/src/config.rs index 15b0fb7b6..00063be6d 100644 --- a/litebox_egress_proxy/src/config.rs +++ b/litebox_egress_proxy/src/config.rs @@ -12,7 +12,6 @@ use std::net::{Ipv4Addr, SocketAddrV4}; use clap::{ArgGroup, Parser}; use thiserror::Error; -use crate::dns::{TargetPolicy, TargetPolicyError, is_permitted_dns_server_ipv4}; use crate::listener::ListenerSource; use crate::policy::{HostPolicy, HostRule, HostRuleError, PolicyError, parse_port}; @@ -46,13 +45,6 @@ pub struct Cli { /// Allowed hostname and destination ports, repeatable. #[arg(long = "allow-host", value_name = "HOST:PORT[-PORT]")] allow_host: Vec, - - /// Additional proxy-only CIDRs to which policy hostnames may resolve. - /// - /// Public IPv4 targets are permitted by default. This option deliberately - /// does not grant the guest direct access to the CIDR. - #[arg(long = "allow-resolved-destination", value_name = "CIDR")] - allow_resolved_destination: Vec, } /// Reason the arguments were rejected. @@ -67,24 +59,12 @@ pub enum ConfigError { /// `--dns-server` was not an IPv4 address with an optional port. #[error("--dns-server must be an IPv4 address with an optional port")] DnsServerAddress, - /// `--dns-server` was not an externally usable unicast address. - #[error( - "--dns-server must be a non-loopback unicast IPv4 address; unspecified, multicast, \ - broadcast, and reserved addresses are rejected" - )] - DnsServerNotExternal, /// An `--allow-host` rule was invalid. #[error("invalid --allow-host rule: {0}")] Rule(#[from] HostRuleError), /// The rules could not be combined into a policy. #[error("invalid policy: {0}")] Policy(#[from] PolicyError), - /// A proxy-only resolved-destination CIDR was invalid. - #[error("invalid --allow-resolved-destination CIDR: {0}")] - ResolvedDestination(String), - /// Too many resolved-destination CIDRs were configured. - #[error("invalid resolved-destination policy: {0}")] - TargetPolicy(#[from] TargetPolicyError), } /// The validated configuration of one proxy process. @@ -92,12 +72,10 @@ pub enum ConfigError { pub struct ProxyConfig { /// Where the listener comes from. pub listener: ListenerSource, - /// The single DNS server used for startup resolution. + /// The single DNS server used for request resolution. pub dns_server: SocketAddrV4, /// The immutable hostname policy. pub policy: HostPolicy, - /// Permitted resolved upstream addresses. - pub targets: TargetPolicy, } impl Cli { @@ -117,35 +95,15 @@ impl Cli { rules.push(rule.parse::()?); } let policy = HostPolicy::from_rules(rules)?; - let targets = TargetPolicy::new( - self.allow_resolved_destination - .iter() - .map(|cidr| parse_resolved_destination(cidr)) - .collect::, _>>()?, - )?; Ok(ProxyConfig { listener, dns_server, policy, - targets, }) } } -/// Parses one canonical proxy-only resolved-destination CIDR. -fn parse_resolved_destination(raw: &str) -> Result { - let network: ipnet::Ipv4Net = raw - .parse() - .map_err(|error| ConfigError::ResolvedDestination(format!("{raw}: {error}")))?; - if network.addr() != network.network() { - return Err(ConfigError::ResolvedDestination(format!( - "{raw}: network address contains host bits" - ))); - } - Ok(network) -} - /// Parses `--listen`, which is restricted to canonical IPv4 loopback. fn parse_listen_address(raw: &str) -> Result { let address: SocketAddrV4 = raw.parse().map_err(|_| ConfigError::ListenAddress)?; @@ -156,10 +114,6 @@ fn parse_listen_address(raw: &str) -> Result { } /// Parses `--dns-server`, which accepts an optional port. -/// -/// The server must be an externally usable unicast IPv4 address. Private and -/// link-local servers are accepted because this address is selected directly -/// by the trusted operator rather than learned from an untrusted DNS answer. fn parse_dns_server(raw: &str) -> Result { let (address, port) = match raw.split_once(':') { Some((address, port)) => ( @@ -170,9 +124,6 @@ fn parse_dns_server(raw: &str) -> Result { }; let address: Ipv4Addr = address.parse().map_err(|_| ConfigError::DnsServerAddress)?; - if !is_permitted_dns_server_ipv4(address) { - return Err(ConfigError::DnsServerNotExternal); - } Ok(SocketAddrV4::new(address, port)) } @@ -199,8 +150,6 @@ mod tests { "Example.COM:443", "--allow-host", "example.com:8000-8100", - "--allow-resolved-destination", - "10.0.0.0/8", ]) .unwrap(); @@ -218,7 +167,6 @@ mod tests { assert!(config.policy.allows(&host, 443)); assert!(config.policy.allows(&host, 8100)); assert!(!config.policy.allows(&host, 80)); - assert!(config.targets.allows(Ipv4Addr::new(10, 1, 2, 3))); } #[test] @@ -263,52 +211,17 @@ mod tests { } #[test] - fn accepts_explicit_private_dns_servers_but_rejects_invalid_endpoints() { + fn accepts_operator_selected_dns_servers_but_rejects_invalid_endpoints() { assert!(parse(&["--listen", "127.0.0.1:0", "--dns-server", "10.0.0.1"]).is_ok()); assert!(parse(&["--listen", "127.0.0.1:0", "--dns-server", "169.254.169.253",]).is_ok()); - - for server in ["127.0.0.1:5353", "224.0.0.1", "240.0.0.1", "0.0.0.0"] { - assert!( - matches!( - parse(&["--listen", "127.0.0.1:0", "--dns-server", server]), - Err(ConfigError::DnsServerNotExternal) - ), - "{server} must be rejected" - ); - } + assert!(parse(&["--listen", "127.0.0.1:0", "--dns-server", "127.0.0.1:5353"]).is_ok()); assert!(matches!( parse(&["--listen", "127.0.0.1:0", "--dns-server", "not-an-address"]), Err(ConfigError::DnsServerAddress) )); - } - - #[test] - fn rejects_reserved_dns_ports_in_rules() { assert!(matches!( - parse(&[ - "--listen", - "127.0.0.1:0", - "--dns-server", - "9.9.9.9", - "--allow-host", - "example.com:853", - ]), - Err(ConfigError::Rule(_)) - )); - } - - #[test] - fn rejects_invalid_resolved_destination_cidr() { - assert!(matches!( - parse(&[ - "--listen", - "127.0.0.1:0", - "--dns-server", - "9.9.9.9", - "--allow-resolved-destination", - "10.0.0.1/8", - ]), - Err(ConfigError::ResolvedDestination(_)) + parse(&["--listen", "127.0.0.1:0", "--dns-server", "9.9.9.9:0"]), + Err(ConfigError::DnsServerAddress) )); } } diff --git a/litebox_egress_proxy/src/dns.rs b/litebox_egress_proxy/src/dns.rs index 135f80c29..4ba82b1ca 100644 --- a/litebox_egress_proxy/src/dns.rs +++ b/litebox_egress_proxy/src/dns.rs @@ -1,18 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -//! Controlled DNS resolution and immutable startup pinning. +//! On-demand hostname resolution through one configured DNS server. //! -//! Every allowed hostname is resolved exactly once, before the listener is -//! announced ready, and the resulting addresses are pinned for the lifetime of -//! the process. Request handling never performs a lookup, so an upstream -//! connection can only target an address that was validated at startup. +//! The resolver never consults host resolver configuration or a hosts file. +//! Request handling invokes it only after the exact hostname and port have +//! passed policy authorization. use core::future::Future; use core::pin::Pin; -use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddrV4}; -use std::sync::Arc; use hickory_resolver::Resolver; use hickory_resolver::config::{ @@ -21,40 +18,25 @@ use hickory_resolver::config::{ }; use hickory_resolver::net::runtime::TokioRuntimeProvider; use hickory_resolver::proto::rr::{Name, RData}; -use ipnet::Ipv4Net; use thiserror::Error; -use tokio::task::JoinSet; -use tokio::time::timeout; -use crate::limits::{ - DNS_ATTEMPT_TIMEOUT, DNS_QUERY_TIMEOUT, MAX_CONCURRENT_STARTUP_RESOLUTIONS, - MAX_PINNED_ADDRESSES_PER_HOST, MAX_RESOLVED_DESTINATION_RULES, MAX_UDP_DNS_RESPONSE_BYTES, -}; -use crate::policy::{HostPolicy, Hostname}; +use crate::limits::{DNS_ATTEMPT_TIMEOUT, MAX_RESOLVED_ADDRESSES, MAX_UDP_DNS_RESPONSE_BYTES}; +use crate::policy::Hostname; /// A future returned by a [`HostResolver`]. pub type ResolveFuture<'a> = Pin, ResolveError>> + Send + 'a>>; -/// Resolves policy hostnames to upstream IPv4 addresses. -/// -/// # Contract -/// -/// Implementations return DNS data only. [`PinnedTable::resolve`] applies the -/// destination policy before retaining any address, keeping target safety in -/// one path for production and injected resolvers. +/// Resolves a canonical hostname to upstream IPv4 addresses. pub trait HostResolver: Send + Sync + 'static { /// Resolves one canonical hostname. - /// - /// Returning an empty vector is a protocol error; implementations should - /// return [`ResolveError::NoAddresses`] instead. fn resolve(&self, host: Hostname) -> ResolveFuture<'_>; } -/// Reason a hostname could not be resolved into pinned addresses. +/// Reason a hostname could not be resolved. #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum ResolveError { - /// The canonical name could not be expressed as a DNS name. + /// The canonical hostname could not be expressed as a DNS name. #[error("hostname is not a valid DNS name: {0}")] InvalidName(String), /// The configured DNS server did not answer successfully. @@ -63,151 +45,9 @@ pub enum ResolveError { /// The answer contained no IPv4 address. #[error("DNS answer contained no IPv4 address")] NoAddresses, - /// The answer contained an address that is not a permitted upstream - /// target. - #[error("DNS answer contained non-global address {0}")] - UnsafeAddress(Ipv4Addr), -} - -/// Reason startup pinning failed. -#[derive(Clone, Debug, Error)] -pub enum PinError { - /// One hostname failed to resolve. - #[error("failed to resolve `{host}`: {source}")] - Host { - /// The hostname that failed. - host: Hostname, - /// The underlying resolution failure. - source: ResolveError, - }, - /// One hostname exceeded the per-name DNS timeout. - #[error("timed out resolving `{host}`")] - Timeout { - /// The hostname that timed out. - host: Hostname, - }, - /// A resolution task could not be run to completion. - #[error("resolution task failed: {0}")] - Task(String), -} - -/// Returns whether `address` is a public proxy destination. -/// -/// Only globally routable unicast addresses are permitted. This is a -/// proxy-specific safety rule: hostname policy must never be able to reach the -/// host's own networks, link-local metadata services, or any special-purpose -/// range. -pub fn is_public_proxy_target(address: Ipv4Addr) -> bool { - let [a, b, c, _] = address.octets(); - - // 0.0.0.0/8 "this network", including the unspecified address. - if a == 0 { - return false; - } - // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. - if address.is_private() { - return false; - } - // 100.64.0.0/10 shared address space (carrier-grade NAT). - if a == 100 && (64..128).contains(&b) { - return false; - } - // 127.0.0.0/8 loopback. - if address.is_loopback() { - return false; - } - // 169.254.0.0/16 link-local. - if address.is_link_local() { - return false; - } - // 192.0.0.0/24 IETF protocol assignments. - if a == 192 && b == 0 && c == 0 { - return false; - } - // 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 documentation. - if (a == 192 && b == 0 && c == 2) - || (a == 198 && b == 51 && c == 100) - || (a == 203 && b == 0 && c == 113) - { - return false; - } - // 198.18.0.0/15 benchmarking. - if a == 198 && (b == 18 || b == 19) { - return false; - } - // 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, 255.255.255.255 broadcast. - if address.is_multicast() || a >= 240 { - return false; - } - true -} - -/// Returns whether an explicitly configured DNS server is externally usable. -/// -/// Private and link-local unicast addresses are accepted because the trusted -/// operator selects this endpoint directly. Resolved proxy targets use the -/// stricter [`TargetPolicy`] instead. -pub fn is_permitted_dns_server_ipv4(address: Ipv4Addr) -> bool { - let first = address.octets()[0]; - first != 0 && !address.is_loopback() && !address.is_multicast() && first < 240 -} - -/// The immutable envelope for addresses learned from DNS. -/// -/// Public destinations are accepted by default. Additional canonical CIDRs -/// permit private services through the proxy without granting the guest direct -/// access to those ranges. Unspecified, loopback, multicast, broadcast, and -/// reserved addresses remain hard-denied even if an additional CIDR covers -/// them. -#[derive(Clone, Debug, Default)] -pub struct TargetPolicy { - additional: Vec, -} - -/// Reason a resolved-destination policy was rejected. -#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] -pub enum TargetPolicyError { - /// More than the fixed number of additional CIDRs was supplied. - #[error( - "resolved-destination policy contains more than {MAX_RESOLVED_DESTINATION_RULES} CIDRs" - )] - TooManyCidrs, -} - -impl TargetPolicy { - /// Creates a target policy from additional canonical IPv4 CIDRs. - pub fn new(mut additional: Vec) -> Result { - if additional.len() > MAX_RESOLVED_DESTINATION_RULES { - return Err(TargetPolicyError::TooManyCidrs); - } - additional.sort_unstable(); - additional.dedup(); - Ok(Self { additional }) - } - - /// Returns a policy that permits public destinations only. - pub fn public_only() -> Self { - Self::default() - } - - /// Returns whether a resolved address may be pinned. - pub fn allows(&self, address: Ipv4Addr) -> bool { - if !is_permitted_dns_server_ipv4(address) { - return false; - } - is_public_proxy_target(address) - || self - .additional - .iter() - .any(|network| network.contains(&address)) - } } -/// The production resolver: a stub resolver bound to one configured server. -/// -/// The resolver never consults the host's resolver configuration or hosts -/// file, sends queries only to the configured server, and falls back from UDP -/// to TCP when a response is truncated or a UDP exchange fails. +/// A stub resolver bound to one operator-selected DNS server. pub struct ConfiguredDnsResolver { resolver: Resolver, } @@ -223,12 +63,8 @@ impl ConfiguredDnsResolver { let name_server = NameServerConfig::new(IpAddr::V4(*server.ip()), true, vec![udp, tcp]); let config = ResolverConfig::from_parts(None, Vec::new(), vec![name_server]); - // `ResolverOpts` is `#[non_exhaustive]`, so the defaults have to be - // adjusted field by field rather than through a struct literal. #[allow(clippy::field_reassign_with_default)] let mut options = ResolverOpts::default(); - // Query fully qualified names only; there is no search list and no - // host-configured domain. options.ndots = 0; options.timeout = DNS_ATTEMPT_TIMEOUT; options.attempts = 1; @@ -239,8 +75,6 @@ impl ConfiguredDnsResolver { options.use_hosts_file = ResolveHosts::Never; options.num_concurrent_reqs = 1; options.preserve_intermediates = false; - // Pinning happens once; a cache would only add state that must not - // influence later behaviour. options.cache_size = 0; let resolver = Resolver::builder_with_config(config, TokioRuntimeProvider::default()) @@ -254,324 +88,30 @@ impl ConfiguredDnsResolver { impl HostResolver for ConfiguredDnsResolver { fn resolve(&self, host: Hostname) -> ResolveFuture<'_> { Box::pin(async move { - // The trailing dot makes the query fully qualified, so no search - // list can ever be appended. let name = Name::from_ascii(format!("{host}.")) .map_err(|error| ResolveError::InvalidName(error.to_string()))?; - let lookup = self .resolver .ipv4_lookup(name) .await .map_err(|error| ResolveError::Query(error.to_string()))?; - let mut addresses: Vec = Vec::new(); + let mut addresses = Vec::new(); for record in lookup.answers() { - // CNAME chains are followed by the resolver itself; only the - // terminal A records matter here. let RData::A(address) = &record.data else { continue; }; let address = address.0; - if addresses.len() < MAX_PINNED_ADDRESSES_PER_HOST && !addresses.contains(&address) - { + if addresses.len() < MAX_RESOLVED_ADDRESSES && !addresses.contains(&address) { addresses.push(address); } } if addresses.is_empty() { - return Err(ResolveError::NoAddresses); + Err(ResolveError::NoAddresses) + } else { + Ok(addresses) } - Ok(addresses) }) } } - -/// The immutable startup resolution table. -/// -/// Addresses are stored in the order the resolver returned them, which is also -/// the order in which upstream connection attempts are made. -#[derive(Clone, Debug, Default)] -pub struct PinnedTable { - entries: HashMap>, -} - -impl PinnedTable { - /// Resolves every hostname of `policy` and pins the results. - /// - /// At most [`MAX_CONCURRENT_STARTUP_RESOLUTIONS`] lookups run at a time, - /// and each lookup is bounded by [`DNS_QUERY_TIMEOUT`]. A single failure - /// fails the whole table: startup must fail closed. - pub async fn resolve( - policy: &HostPolicy, - targets: &TargetPolicy, - resolver: Arc, - ) -> Result { - let mut pending = policy.hostnames().cloned().collect::>().into_iter(); - let mut tasks: JoinSet<(Hostname, Result, PinError>)> = JoinSet::new(); - let mut entries = HashMap::with_capacity(policy.len()); - - loop { - while tasks.len() < MAX_CONCURRENT_STARTUP_RESOLUTIONS { - let Some(host) = pending.next() else { - break; - }; - let resolver = Arc::clone(&resolver); - tasks.spawn(async move { - let outcome = - match timeout(DNS_QUERY_TIMEOUT, resolver.resolve(host.clone())).await { - Ok(Ok(addresses)) => Ok(addresses), - Ok(Err(source)) => Err(PinError::Host { - host: host.clone(), - source, - }), - Err(_elapsed) => Err(PinError::Timeout { host: host.clone() }), - }; - (host, outcome) - }); - } - - let Some(joined) = tasks.join_next().await else { - break; - }; - let (host, outcome) = joined.map_err(|error| PinError::Task(error.to_string()))?; - let mut addresses = outcome?; - if addresses.is_empty() { - return Err(PinError::Host { - host, - source: ResolveError::NoAddresses, - }); - } - for address in &addresses { - if !targets.allows(*address) { - // Defence in depth: a resolver that violates its contract - // must not be able to pin an unsafe address. - return Err(PinError::Host { - host, - source: ResolveError::UnsafeAddress(*address), - }); - } - } - addresses.truncate(MAX_PINNED_ADDRESSES_PER_HOST); - entries.insert(host, addresses); - } - - Ok(Self { entries }) - } - - /// Returns the pinned addresses for `host`, in startup order. - /// - /// An unknown hostname yields an empty slice; the policy check has already - /// rejected such a request before this point. - pub fn addresses(&self, host: &Hostname) -> &[Ipv4Addr] { - self.entries.get(host).map_or(&[], Vec::as_slice) - } - - /// Returns the number of pinned hostnames. - pub fn len(&self) -> usize { - self.entries.len() - } - - /// Returns whether nothing is pinned. - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::policy::HostRule; - - struct StaticResolver { - answers: HashMap, ResolveError>>, - } - - impl HostResolver for StaticResolver { - fn resolve(&self, host: Hostname) -> ResolveFuture<'_> { - let answer = self - .answers - .get(host.as_str()) - .cloned() - .unwrap_or(Err(ResolveError::NoAddresses)); - Box::pin(async move { answer }) - } - } - - fn policy(rules: &[&str]) -> HostPolicy { - HostPolicy::from_rules( - rules - .iter() - .map(|rule| rule.parse::().unwrap()) - .collect::>(), - ) - .unwrap() - } - - fn runtime() -> tokio::runtime::Runtime { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - } - - #[test] - fn global_unicast_addresses_are_permitted() { - assert!(is_public_proxy_target(Ipv4Addr::new(93, 184, 216, 34))); - assert!(is_public_proxy_target(Ipv4Addr::new(8, 8, 8, 8))); - assert!(is_public_proxy_target(Ipv4Addr::new(1, 1, 1, 1))); - } - - #[test] - fn special_purpose_addresses_are_rejected() { - for address in [ - Ipv4Addr::UNSPECIFIED, - Ipv4Addr::new(0, 1, 2, 3), - Ipv4Addr::new(10, 0, 0, 1), - Ipv4Addr::new(172, 16, 0, 1), - Ipv4Addr::new(192, 168, 1, 1), - Ipv4Addr::new(100, 64, 0, 1), - Ipv4Addr::LOCALHOST, - Ipv4Addr::new(169, 254, 169, 254), - Ipv4Addr::new(192, 0, 0, 1), - Ipv4Addr::new(192, 0, 2, 1), - Ipv4Addr::new(198, 51, 100, 1), - Ipv4Addr::new(203, 0, 113, 1), - Ipv4Addr::new(198, 18, 0, 1), - Ipv4Addr::new(224, 0, 0, 1), - Ipv4Addr::new(240, 0, 0, 1), - Ipv4Addr::BROADCAST, - ] { - assert!( - !is_public_proxy_target(address), - "{address} must not be a permitted upstream target" - ); - } - } - - #[test] - fn pins_every_policy_hostname() { - let resolver = StaticResolver { - answers: [ - ( - "a.example".to_owned(), - Ok(vec![ - Ipv4Addr::new(93, 184, 216, 34), - Ipv4Addr::new(1, 1, 1, 1), - ]), - ), - ("b.example".to_owned(), Ok(vec![Ipv4Addr::new(8, 8, 4, 4)])), - ] - .into_iter() - .collect(), - }; - - let policy = policy(&["a.example:80", "b.example:443"]); - let table = runtime() - .block_on(PinnedTable::resolve( - &policy, - &TargetPolicy::public_only(), - Arc::new(resolver), - )) - .unwrap(); - - assert_eq!(table.len(), 2); - assert_eq!( - table.addresses(&Hostname::parse("a.example").unwrap()), - [Ipv4Addr::new(93, 184, 216, 34), Ipv4Addr::new(1, 1, 1, 1)] - ); - assert!( - table - .addresses(&Hostname::parse("c.example").unwrap()) - .is_empty() - ); - } - - #[test] - fn unresolved_hostname_fails_startup() { - let resolver = StaticResolver { - answers: HashMap::new(), - }; - let policy = policy(&["a.example:80"]); - let error = runtime() - .block_on(PinnedTable::resolve( - &policy, - &TargetPolicy::public_only(), - Arc::new(resolver), - )) - .unwrap_err(); - assert!(matches!( - error, - PinError::Host { - source: ResolveError::NoAddresses, - .. - } - )); - } - - #[test] - fn unsafe_address_fails_startup() { - let resolver = StaticResolver { - answers: [("a.example".to_owned(), Ok(vec![Ipv4Addr::LOCALHOST]))] - .into_iter() - .collect(), - }; - let policy = policy(&["a.example:80"]); - let error = runtime() - .block_on(PinnedTable::resolve( - &policy, - &TargetPolicy::public_only(), - Arc::new(resolver), - )) - .unwrap_err(); - assert!(matches!( - error, - PinError::Host { - source: ResolveError::UnsafeAddress(_), - .. - } - )); - } - - #[test] - fn pinned_addresses_are_bounded_per_host() { - let many = (1..=40) - .map(|index| Ipv4Addr::new(93, 184, 216, index)) - .collect::>(); - let resolver = StaticResolver { - answers: [("a.example".to_owned(), Ok(many))].into_iter().collect(), - }; - let policy = policy(&["a.example:80"]); - let table = runtime() - .block_on(PinnedTable::resolve( - &policy, - &TargetPolicy::public_only(), - Arc::new(resolver), - )) - .unwrap(); - assert_eq!( - table - .addresses(&Hostname::parse("a.example").unwrap()) - .len(), - MAX_PINNED_ADDRESSES_PER_HOST - ); - } - - #[test] - fn additional_target_cidr_allows_private_but_not_loopback() { - let targets = TargetPolicy::new(vec!["10.0.0.0/8".parse().unwrap()]).unwrap(); - assert!(targets.allows(Ipv4Addr::new(10, 1, 2, 3))); - assert!(!targets.allows(Ipv4Addr::LOCALHOST)); - } - - #[test] - fn additional_target_cidrs_are_bounded() { - let networks = (0..=MAX_RESOLVED_DESTINATION_RULES) - .map(|index| format!("10.{index}.0.0/16").parse().unwrap()) - .collect(); - assert_eq!( - TargetPolicy::new(networks).unwrap_err(), - TargetPolicyError::TooManyCidrs - ); - } -} diff --git a/litebox_egress_proxy/src/lib.rs b/litebox_egress_proxy/src/lib.rs index 20a30f823..f1c935622 100644 --- a/litebox_egress_proxy/src/lib.rs +++ b/litebox_egress_proxy/src/lib.rs @@ -7,12 +7,10 @@ //! # Model //! //! The proxy is a separate trusted process. It authorizes each request against -//! an immutable, exact-hostname policy and connects only to addresses that were -//! resolved through one explicitly configured DNS server before the listener -//! was announced as ready. It never consults the host resolver configuration, -//! never re-resolves a hostname, and never grants direct access to a resolved -//! address: a hostname rule authorizes an endpoint reached through this proxy -//! and nothing else. +//! an immutable, exact-hostname policy, resolves an authorized hostname through +//! one explicitly configured DNS server, and connects to a returned numeric +//! address. It never consults the host resolver configuration, and a hostname +//! rule authorizes an endpoint reached through this proxy and nothing else. //! //! Two request forms are supported: //! @@ -41,23 +39,20 @@ //! --allow-host HOST:PORT[-PORT] ... //! ``` //! -//! After the listener is acquired, every policy hostname is resolved, and all -//! startup validation has passed, exactly one line is written to standard -//! output: +//! After the listener and configured DNS resolver are ready, exactly one line +//! is written to standard output: //! //! ```text //! READY 127.0.0.1:PORT //! ``` //! //! Diagnostics go to standard error only, and no readiness line is written on -//! failure. Startup as a whole is bounded by [`limits::STARTUP_BUDGET`]. +//! failure. //! //! # Testing //! //! [`dns::HostResolver`] and [`upstream::UpstreamConnector`] are injected //! abstractions, so the request path can be driven hermetically over loopback. -//! The shared pinning path applies the same destination policy to production -//! and injected DNS answers. pub mod authority; pub mod config; @@ -78,11 +73,9 @@ use std::sync::Arc; use thiserror::Error; use tokio::net::TcpListener; -use tokio::time::timeout; use crate::config::ProxyConfig; -use crate::dns::{ConfiguredDnsResolver, PinError, PinnedTable, ResolveError}; -use crate::limits::STARTUP_BUDGET; +use crate::dns::{ConfiguredDnsResolver, ResolveError}; use crate::listener::ListenerError; use crate::proxy::ProxyState; use crate::upstream::TcpUpstreamConnector; @@ -96,12 +89,6 @@ pub enum StartupError { /// The resolver could not be constructed. #[error("failed to configure the DNS resolver: {0}")] Resolver(#[from] ResolveError), - /// A policy hostname could not be pinned. - #[error(transparent)] - Pin(#[from] PinError), - /// Startup exceeded its total budget. - #[error("startup exceeded its {}s budget", STARTUP_BUDGET.as_secs())] - Budget, /// An I/O operation failed during startup or while serving. #[error(transparent)] Io(#[from] io::Error), @@ -126,12 +113,11 @@ impl StartedProxy { } } -/// Acquires the listener, pins every policy hostname, and prepares the shared -/// state. +/// Acquires the listener and prepares the shared state. /// /// No client connection is served and no readiness line is written until this /// has succeeded, so a partially configured proxy is never observable. -pub async fn start(config: &ProxyConfig) -> Result { +pub fn start(config: &ProxyConfig) -> Result { let listener = listener::acquire(config.listener)?; let listener = TcpListener::from_std(listener)?; let SocketAddr::V4(local_address) = listener.local_addr()? else { @@ -140,12 +126,9 @@ pub async fn start(config: &ProxyConfig) -> Result { ))); }; - let resolver = ConfiguredDnsResolver::new(config.dns_server)?; - let pinned = PinnedTable::resolve(&config.policy, &config.targets, Arc::new(resolver)).await?; - let state = Arc::new(ProxyState::new( config.policy.clone(), - pinned, + Arc::new(ConfiguredDnsResolver::new(config.dns_server)?), Arc::new(TcpUpstreamConnector), )); @@ -166,11 +149,9 @@ pub fn write_readiness(writer: &mut impl Write, address: SocketAddrV4) -> io::Re writer.flush() } -/// Runs the proxy: bounded startup, readiness announcement, then serving. +/// Runs the proxy: startup, readiness announcement, then serving. pub async fn run(config: &ProxyConfig) -> Result<(), StartupError> { - let started = timeout(STARTUP_BUDGET, start(config)) - .await - .map_err(|_elapsed| StartupError::Budget)??; + let started = start(config)?; let mut stdout = io::stdout().lock(); write_readiness(&mut stdout, started.local_address())?; diff --git a/litebox_egress_proxy/src/limits.rs b/litebox_egress_proxy/src/limits.rs index 04a85cb38..2e58c5f15 100644 --- a/litebox_egress_proxy/src/limits.rs +++ b/litebox_egress_proxy/src/limits.rs @@ -11,11 +11,8 @@ use core::time::Duration; /// Maximum number of distinct canonical hostnames in the policy. pub const MAX_HOST_RULES: usize = 64; -/// Maximum number of pinned IPv4 addresses retained per hostname. -pub const MAX_PINNED_ADDRESSES_PER_HOST: usize = 16; - -/// Maximum number of additional proxy-only resolved-destination CIDRs. -pub const MAX_RESOLVED_DESTINATION_RULES: usize = 64; +/// Maximum number of IPv4 addresses used from one DNS answer. +pub const MAX_RESOLVED_ADDRESSES: usize = 16; /// Maximum number of client connections served concurrently. /// @@ -37,10 +34,7 @@ pub const MAX_HEADER_FIELDS: usize = 100; /// back to TCP. pub const MAX_UDP_DNS_RESPONSE_BYTES: u16 = 1232; -/// Maximum number of hostname resolutions performed concurrently at startup. -pub const MAX_CONCURRENT_STARTUP_RESOLUTIONS: usize = 16; - -/// Per-hostname DNS resolution timeout. +/// Total timeout for one on-demand hostname resolution. pub const DNS_QUERY_TIMEOUT: Duration = Duration::from_secs(5); /// Timeout for one DNS transport attempt within a hostname lookup. @@ -49,15 +43,7 @@ pub const DNS_QUERY_TIMEOUT: Duration = Duration::from_secs(5); /// fall back from UDP to TCP before the whole hostname lookup expires. pub const DNS_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); -/// Total startup budget, covering listener acquisition and every resolution. -pub const STARTUP_BUDGET: Duration = Duration::from_secs(30); - -const _: () = assert!( - MAX_HOST_RULES.div_ceil(MAX_CONCURRENT_STARTUP_RESOLUTIONS) <= 5 - && DNS_QUERY_TIMEOUT.as_secs() * 5 < STARTUP_BUDGET.as_secs() -); - -/// Total timeout shared by all pinned-address connection attempts. +/// Total timeout shared by all resolved-address connection attempts. pub const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// Idle timeout applied to HTTP bodies and CONNECT tunnels. diff --git a/litebox_egress_proxy/src/policy.rs b/litebox_egress_proxy/src/policy.rs index 8fe0a6682..9b435ca6b 100644 --- a/litebox_egress_proxy/src/policy.rs +++ b/litebox_egress_proxy/src/policy.rs @@ -6,7 +6,7 @@ //! The policy is parsed once, before any listener is announced, and is never //! mutated afterwards. Only canonical values reach the request path: a //! [`Hostname`] is always lowercase, dot-normalised and syntactically valid, -//! and a [`PortRange`] never spans a reserved DNS port. +//! and a [`PortRange`] always contains valid nonzero destination ports. use core::fmt; use core::str::FromStr; @@ -22,13 +22,6 @@ const MAX_HOSTNAME_BYTES: usize = 253; /// Maximum length of a single DNS label, in bytes. const MAX_LABEL_BYTES: usize = 63; -/// Well-known DNS ports that a proxy rule may never authorize. -/// -/// Port 53 is plain DNS and port 853 is DNS-over-TLS. Both remain reserved for -/// the configured resolver path, so that a proxy rule can never be used to -/// reach an arbitrary resolver. -pub const RESERVED_DNS_PORTS: [u16; 2] = [53, 853]; - /// Reason a hostname was rejected. #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] pub enum HostnameError { @@ -56,9 +49,6 @@ pub enum HostnameError { /// the direct IP/CIDR policy, never to the hostname policy. #[error("hostname is an IP literal or numeric form")] NumericForm, - /// The name was `localhost` or one of its descendants. - #[error("`localhost` and its descendants are not proxy hostnames")] - Localhost, } /// A canonical, exact DNS hostname that a proxy rule or request may name. @@ -115,10 +105,6 @@ impl Hostname { return Err(HostnameError::NumericForm); } - if canonical == "localhost" || canonical.ends_with(".localhost") { - return Err(HostnameError::Localhost); - } - Ok(Self(canonical)) } @@ -154,9 +140,6 @@ pub enum PortRangeError { /// The range end was smaller than its start. #[error("port range end is smaller than its start")] Inverted, - /// The range contained port 53 or port 853. - #[error("port range contains reserved DNS port 53 or 853")] - ReservedDnsPort, } /// An inclusive range of destination ports. @@ -167,8 +150,7 @@ pub struct PortRange { } impl PortRange { - /// Creates an inclusive range, rejecting port zero, inverted ranges, and - /// any range containing a reserved DNS port. + /// Creates an inclusive range, rejecting port zero and inverted ranges. pub fn new(start: u16, end: u16) -> Result { if start == 0 || end == 0 { return Err(PortRangeError::ZeroPort); @@ -176,12 +158,6 @@ impl PortRange { if start > end { return Err(PortRangeError::Inverted); } - if RESERVED_DNS_PORTS - .iter() - .any(|reserved| (start..=end).contains(reserved)) - { - return Err(PortRangeError::ReservedDnsPort); - } Ok(Self { start, end }) } @@ -284,11 +260,6 @@ pub enum PolicyError { /// More than [`MAX_HOST_RULES`] distinct canonical hostnames were given. #[error("policy contains more than {MAX_HOST_RULES} canonical hostnames")] TooManyHosts, - /// Merging overlapping ranges produced a range spanning a reserved DNS - /// port. This cannot happen for validated inputs and is checked anyway so - /// that the reserved-port invariant holds for the stored ranges. - #[error("merged port range is invalid: {0}")] - MergedRange(#[from] PortRangeError), } /// The immutable proxy policy: exact hostnames mapped to allowed destination @@ -317,7 +288,7 @@ impl HostPolicy { return Err(PolicyError::TooManyHosts); } for ranges in entries.values_mut() { - *ranges = merge_ranges(ranges)?; + *ranges = merge_ranges(ranges); } Ok(Self { entries }) } @@ -351,7 +322,7 @@ impl HostPolicy { } /// Sorts and folds overlapping or adjacent ranges. -fn merge_ranges(ranges: &[PortRange]) -> Result, PortRangeError> { +fn merge_ranges(ranges: &[PortRange]) -> Vec { let mut sorted = ranges.to_vec(); sorted.sort_unstable(); @@ -360,15 +331,12 @@ fn merge_ranges(ranges: &[PortRange]) -> Result, PortRangeError> match merged.last_mut() { // `saturating_add` keeps adjacency well-defined at 65535. Some(previous) if range.start() <= previous.end().saturating_add(1) => { - let end = previous.end().max(range.end()); - // Re-validate: a merged range must still exclude reserved DNS - // ports. - *previous = PortRange::new(previous.start(), end)?; + previous.end = previous.end().max(range.end()); } _ => merged.push(range), } } - Ok(merged) + merged } #[cfg(test)] @@ -420,12 +388,6 @@ mod tests { ); assert_eq!(Hostname::parse("12345"), Err(HostnameError::NumericForm)); assert_eq!(Hostname::parse("[::1]"), Err(HostnameError::LabelCharacter)); - assert_eq!(Hostname::parse("localhost"), Err(HostnameError::Localhost)); - assert_eq!(Hostname::parse("LOCALHOST."), Err(HostnameError::Localhost)); - assert_eq!( - Hostname::parse("a.localhost"), - Err(HostnameError::Localhost) - ); } #[test] @@ -448,27 +410,6 @@ mod tests { assert!(Hostname::parse(&at_limit).is_ok()); } - #[test] - fn port_ranges_reject_reserved_dns_ports() { - assert_eq!( - "53".parse::(), - Err(PortRangeError::ReservedDnsPort) - ); - assert_eq!( - "853".parse::(), - Err(PortRangeError::ReservedDnsPort) - ); - assert_eq!( - "50-60".parse::(), - Err(PortRangeError::ReservedDnsPort) - ); - assert_eq!( - "1-65535".parse::(), - Err(PortRangeError::ReservedDnsPort) - ); - assert!("54-852".parse::().is_ok()); - } - #[test] fn port_ranges_reject_malformed_input() { assert_eq!("".parse::(), Err(PortRangeError::NotANumber)); @@ -526,15 +467,15 @@ mod tests { } #[test] - fn policy_merge_never_spans_a_reserved_port() { - let rules = ["a.example:40-52", "a.example:54-60"] + fn policy_merges_adjacent_ranges() { + let rules = ["a.example:40-52", "a.example:53-60"] .into_iter() .map(|rule| rule.parse::().unwrap()); let policy = HostPolicy::from_rules(rules).unwrap(); let host = Hostname::parse("a.example").unwrap(); - assert_eq!(policy.port_ranges(&host).len(), 2); - assert!(!policy.allows(&host, 53)); + assert_eq!(policy.port_ranges(&host).len(), 1); + assert!(policy.allows(&host, 53)); } #[test] diff --git a/litebox_egress_proxy/src/proxy.rs b/litebox_egress_proxy/src/proxy.rs index 970d32086..d8aec373b 100644 --- a/litebox_egress_proxy/src/proxy.rs +++ b/litebox_egress_proxy/src/proxy.rs @@ -5,7 +5,7 @@ //! CONNECT tunnelling. //! //! Every raw-validated request is authorized before any DNS or upstream -//! activity against the immutable policy and pinned address table. Plain HTTP +//! activity against the immutable policy. Plain HTTP //! responses close the client connection, so no pipelined second request can //! bypass raw validation. A successful CONNECT consumes its connection by //! upgrading it to a tunnel. No upstream connection is ever reused. @@ -33,13 +33,13 @@ use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore}; use tokio::time::timeout; use crate::authority::{DEFAULT_HTTP_PORT, RequestAuthority, host_header_matches, parse_authority}; -use crate::dns::PinnedTable; +use crate::dns::HostResolver; use crate::headers::{ remove_framing_headers, strip_hop_by_hop, validate_connect_framing, validate_request_framing, validate_response_framing, }; use crate::limits::{ - CLIENT_CLOSE_DRAIN_TIMEOUT, IDLE_TIMEOUT, MAX_CLIENT_CLOSE_DRAIN_BYTES, + CLIENT_CLOSE_DRAIN_TIMEOUT, DNS_QUERY_TIMEOUT, IDLE_TIMEOUT, MAX_CLIENT_CLOSE_DRAIN_BYTES, MAX_CONCURRENT_CLIENT_CONNECTIONS, MAX_HEADER_FIELDS, MAX_REQUEST_HEADER_BYTES, MAX_RESPONSE_HEADER_BYTES, REQUEST_HEADER_READ_TIMEOUT, TOTAL_REQUEST_TIMEOUT, UPSTREAM_CONNECT_TIMEOUT, @@ -57,38 +57,27 @@ type ProxyBody = BoxBody; /// Immutable state shared by every connection. /// -/// The policy and the pinned table are fixed at startup; the connector is an -/// injected abstraction so that the request path can be exercised without a -/// real network. +/// The policy is fixed at startup; the resolver and connector are injected so +/// that the request path can be exercised without a real network. pub struct ProxyState { policy: HostPolicy, - pinned: PinnedTable, + resolver: Arc, connector: Arc, } impl ProxyState { - /// Builds the shared state from an already validated policy and table. + /// Builds the shared state from validated policy and network components. pub fn new( policy: HostPolicy, - pinned: PinnedTable, + resolver: Arc, connector: Arc, ) -> Self { Self { policy, - pinned, + resolver, connector, } } - - /// Returns the policy in force. - pub fn policy(&self) -> &HostPolicy { - &self.policy - } - - /// Returns the immutable startup resolution table. - pub fn pinned(&self) -> &PinnedTable { - &self.pinned - } } /// Serves client connections until `listener` fails. @@ -453,12 +442,11 @@ fn host_header_is_consistent( /// Reason no upstream connection could be established. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum UpstreamFailure { - /// The hostname had no pinned address. Authorized hostnames always have - /// one, so this can only be an internal inconsistency. - NoPinnedAddress, - /// Every pinned address refused the connection or failed. + /// The configured resolver did not return usable IPv4 addresses. + ResolutionFailed, + /// Every resolved address refused the connection or failed. AllAttemptsFailed, - /// At least one attempt exceeded the connect timeout and none succeeded. + /// DNS resolution or the shared connection budget timed out. TimedOut, } @@ -466,25 +454,28 @@ impl UpstreamFailure { /// Maps the failure onto the status reported to the client. fn status(self) -> StatusCode { match self { - Self::NoPinnedAddress | Self::AllAttemptsFailed => StatusCode::BAD_GATEWAY, + Self::ResolutionFailed | Self::AllAttemptsFailed => StatusCode::BAD_GATEWAY, Self::TimedOut => StatusCode::GATEWAY_TIMEOUT, } } } -/// Attempts pinned addresses in their stable startup order. +/// Resolves an authorized hostname and attempts the returned addresses in order. async fn connect_upstream( state: &ProxyState, authority: &RequestAuthority, ) -> Result { - let addresses = state.pinned.addresses(authority.host()); - if addresses.is_empty() { - return Err(UpstreamFailure::NoPinnedAddress); - } + let addresses = timeout( + DNS_QUERY_TIMEOUT, + state.resolver.resolve(authority.host().clone()), + ) + .await + .map_err(|_elapsed| UpstreamFailure::TimedOut)? + .map_err(|_error| UpstreamFailure::ResolutionFailed)?; let attempts = async { for address in addresses { - let target = SocketAddrV4::new(*address, authority.port()); + let target = SocketAddrV4::new(address, authority.port()); if let Ok(stream) = state.connector.connect(target).await { return Ok(stream); } @@ -581,7 +572,7 @@ mod tests { StatusCode::BAD_GATEWAY ); assert_eq!( - UpstreamFailure::NoPinnedAddress.status(), + UpstreamFailure::ResolutionFailed.status(), StatusCode::BAD_GATEWAY ); assert_eq!( diff --git a/litebox_egress_proxy/src/upstream.rs b/litebox_egress_proxy/src/upstream.rs index 59e366fab..8f6d55921 100644 --- a/litebox_egress_proxy/src/upstream.rs +++ b/litebox_egress_proxy/src/upstream.rs @@ -3,10 +3,9 @@ //! Upstream connection abstraction. //! -//! Request handling only ever dials an address that came from the immutable -//! startup resolution table. The connector is an injected abstraction so that -//! tests can drive the proxy against loopback services without relaxing the -//! address validation that the production resolver performs. +//! Request handling dials numeric addresses returned by the configured +//! resolver. The connector is an injected abstraction so tests can drive the +//! proxy against loopback services without external network access. use core::future::Future; use core::pin::Pin; @@ -28,9 +27,9 @@ pub type BoxedUpstreamStream = Box; pub type ConnectFuture<'a> = Pin> + Send + 'a>>; -/// Opens upstream TCP connections to pinned addresses. +/// Opens upstream TCP connections to resolved addresses. pub trait UpstreamConnector: Send + Sync + 'static { - /// Connects to `target`, which is always a pinned address combined with an + /// Connects to `target`, which combines a resolved address with an /// authorized destination port. fn connect(&self, target: SocketAddrV4) -> ConnectFuture<'_>; } diff --git a/litebox_egress_proxy/tests/loopback.rs b/litebox_egress_proxy/tests/loopback.rs index 68ad0ab66..b6689ec83 100644 --- a/litebox_egress_proxy/tests/loopback.rs +++ b/litebox_egress_proxy/tests/loopback.rs @@ -4,11 +4,8 @@ //! Hermetic loopback tests for the proxy request path. //! //! The tests inject a [`HostResolver`] and an [`UpstreamConnector`] instead of -//! touching DNS or the network. The injected resolver answers with ordinary -//! globally routable addresses, so the pinned table is built under exactly the -//! production address rules, and only the injected connector maps those pinned -//! addresses onto loopback test servers. No production validation is relaxed -//! for these tests. +//! touching DNS or external networks. Only the injected connector maps +//! resolved addresses onto loopback test servers. use std::collections::HashMap; use std::io; @@ -17,9 +14,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; -use litebox_egress_proxy::dns::{ - HostResolver, PinnedTable, ResolveError, ResolveFuture, TargetPolicy, -}; +use litebox_egress_proxy::dns::{HostResolver, ResolveError, ResolveFuture}; use litebox_egress_proxy::listener::{ListenerSource, acquire}; use litebox_egress_proxy::policy::{HostPolicy, HostRule, Hostname}; use litebox_egress_proxy::proxy::{ProxyState, serve}; @@ -35,10 +30,12 @@ const TEST_TIMEOUT: Duration = Duration::from_secs(5); /// A resolver with a fixed, hermetic answer table. struct StaticResolver { answers: HashMap>, + resolutions: Arc, } impl HostResolver for StaticResolver { fn resolve(&self, host: Hostname) -> ResolveFuture<'_> { + self.resolutions.fetch_add(1, Ordering::SeqCst); let answer = self .answers .get(&host) @@ -48,7 +45,7 @@ impl HostResolver for StaticResolver { } } -/// A connector that routes pinned addresses to loopback test servers. +/// A connector that routes resolved addresses to loopback test servers. struct LoopbackConnector { routes: HashMap, attempts: Arc, @@ -72,15 +69,15 @@ impl UpstreamConnector for LoopbackConnector { struct TestProxy { address: SocketAddrV4, attempts: Arc, + resolutions: Arc, } impl TestProxy { /// Starts a proxy with `rules` in force, routing `routes` to loopback. /// - /// Every policy hostname resolves to a distinct globally routable address - /// that satisfies the production address rules, and only the listed - /// `(host, port)` pairs have a working upstream. - async fn start(rules: &[&str], routes: &[(&str, u16, SocketAddr)]) -> Self { + /// Every policy hostname resolves to a distinct address, and only the + /// listed `(host, port)` pairs have a working upstream. + fn start(rules: &[&str], routes: &[(&str, u16, SocketAddr)]) -> Self { let policy = HostPolicy::from_rules( rules .iter() @@ -98,22 +95,25 @@ impl TestProxy { let mut mapped = HashMap::new(); for (host, port, address) in routes { let host = Hostname::parse(host).expect("valid hostname"); - let pinned = answers.get(&host).expect("routed host is in policy")[0]; - mapped.insert(SocketAddrV4::new(pinned, *port), *address); + let resolved = answers.get(&host).expect("routed host is in policy")[0]; + mapped.insert(SocketAddrV4::new(resolved, *port), *address); } - let resolver = StaticResolver { answers }; - let pinned = - PinnedTable::resolve(&policy, &TargetPolicy::public_only(), Arc::new(resolver)) - .await - .expect("hermetic resolution succeeds"); - + let resolutions = Arc::new(AtomicUsize::new(0)); + let resolver = StaticResolver { + answers, + resolutions: Arc::clone(&resolutions), + }; let attempts = Arc::new(AtomicUsize::new(0)); let connector = LoopbackConnector { routes: mapped, attempts: Arc::clone(&attempts), }; - let state = Arc::new(ProxyState::new(policy, pinned, Arc::new(connector))); + let state = Arc::new(ProxyState::new( + policy, + Arc::new(resolver), + Arc::new(connector), + )); let listener = acquire(ListenerSource::Bind(SocketAddrV4::new( Ipv4Addr::LOCALHOST, @@ -129,7 +129,11 @@ impl TestProxy { let _ = serve(listener, state).await; }); - Self { address, attempts } + Self { + address, + attempts, + resolutions, + } } /// Number of upstream connection attempts made so far. @@ -137,6 +141,11 @@ impl TestProxy { self.attempts.load(Ordering::SeqCst) } + /// Number of hostname resolutions made so far. + fn resolutions(&self) -> usize { + self.resolutions.load(Ordering::SeqCst) + } + /// Opens a client connection to the proxy. async fn connect(&self) -> ProxyClient { let stream = timeout(TEST_TIMEOUT, TcpStream::connect(self.address)) @@ -335,8 +344,7 @@ async fn forward_request_is_rewritten_and_relayed() { let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); let response = proxy .request(concat!( @@ -372,14 +380,32 @@ async fn forward_request_is_rewritten_and_relayed() { assert_eq!(header_value(&forwarded, "x-kept").as_deref(), Some("value")); } +#[tokio::test] +async fn allowed_hostname_is_resolved_for_each_request() { + let (upstream, _requests) = recording_upstream(OK_RESPONSE).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ); + + for _ in 0..2 { + let response = proxy + .request("GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\n\r\n") + .await; + assert_eq!(response.status, 200); + } + + assert_eq!(proxy.resolutions(), 2); + assert_eq!(proxy.upstream_attempts(), 2); +} + #[tokio::test] async fn empty_absolute_path_with_query_is_normalized() { let (upstream, mut requests) = recording_upstream(OK_RESPONSE).await; let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); let response = proxy .request("GET http://allowed.example?query=1 HTTP/1.1\r\nHost: allowed.example\r\n\r\n") @@ -399,8 +425,7 @@ async fn forward_request_body_is_relayed() { let proxy = TestProxy::start( &["allowed.example:8080"], &[("allowed.example", 8080, upstream)], - ) - .await; + ); let response = proxy .request(concat!( @@ -436,14 +461,14 @@ async fn disallowed_host_is_denied_without_upstream_activity() { let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); let response = proxy .request("GET http://denied.example/ HTTP/1.1\r\nHost: denied.example\r\n\r\n") .await; assert_eq!(response.status, 403); + assert_eq!(proxy.resolutions(), 0); assert_eq!(proxy.upstream_attempts(), 0); } @@ -453,8 +478,7 @@ async fn denied_request_body_is_drained_before_close() { let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); let body = "x".repeat(32 * 1024); let request = format!( @@ -480,32 +504,32 @@ async fn disallowed_port_is_denied() { let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); let response = proxy .request("GET http://allowed.example:8443/ HTTP/1.1\r\nHost: allowed.example:8443\r\n\r\n") .await; assert_eq!(response.status, 403); + assert_eq!(proxy.resolutions(), 0); assert_eq!(proxy.upstream_attempts(), 0); } #[tokio::test] -async fn reserved_dns_port_is_denied() { +async fn explicitly_allowed_dns_port_is_forwarded() { let (upstream, _requests) = recording_upstream(OK_RESPONSE).await; let proxy = TestProxy::start( - &["allowed.example:80"], - &[("allowed.example", 80, upstream)], - ) - .await; + &["allowed.example:53"], + &[("allowed.example", 53, upstream)], + ); let response = proxy - .request("CONNECT allowed.example:53 HTTP/1.1\r\nHost: allowed.example:53\r\n\r\n") + .request("GET http://allowed.example:53/ HTTP/1.1\r\nHost: allowed.example:53\r\n\r\n") .await; - assert_eq!(response.status, 403); - assert_eq!(proxy.upstream_attempts(), 0); + assert_eq!(response.status, 200); + assert_eq!(proxy.resolutions(), 1); + assert_eq!(proxy.upstream_attempts(), 1); } #[tokio::test] @@ -514,8 +538,7 @@ async fn malformed_and_unsupported_requests_are_rejected() { let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); // Host header disagreeing with the request target. let mismatched = proxy @@ -599,8 +622,7 @@ async fn upgrade_request_is_rejected() { let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); let response = proxy .request(concat!( @@ -617,8 +639,8 @@ async fn upgrade_request_is_rejected() { #[tokio::test] async fn unreachable_upstream_yields_bad_gateway() { - // The hostname is allowed and pinned, but nothing routes its address. - let proxy = TestProxy::start(&["allowed.example:80"], &[]).await; + // The hostname is allowed and resolved, but nothing routes its address. + let proxy = TestProxy::start(&["allowed.example:80"], &[]); let response = proxy .request("GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\n\r\n") @@ -634,8 +656,7 @@ async fn connect_tunnel_relays_bytes() { let proxy = TestProxy::start( &["allowed.example:443"], &[("allowed.example", 443, upstream)], - ) - .await; + ); let mut client = proxy.connect().await; client @@ -671,8 +692,7 @@ async fn connect_requests_are_validated() { let proxy = TestProxy::start( &["allowed.example:443"], &[("allowed.example", 443, upstream)], - ) - .await; + ); let denied = proxy .request("CONNECT denied.example:443 HTTP/1.1\r\nHost: denied.example:443\r\n\r\n") @@ -703,8 +723,7 @@ async fn denied_connect_early_bytes_are_drained_before_close() { let proxy = TestProxy::start( &["allowed.example:443"], &[("allowed.example", 443, upstream)], - ) - .await; + ); let early = "x".repeat(32 * 1024); let request = format!( @@ -728,8 +747,7 @@ async fn each_connection_serves_one_independently_authorized_request() { let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); let mut client = proxy.connect().await; client @@ -778,8 +796,7 @@ async fn malformed_header_syntax_is_rejected() { let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); // Whitespace before a header colon. let spaced = proxy @@ -807,8 +824,7 @@ async fn oversized_request_head_is_bounded() { let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); let mut request = String::from("GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\nX-Big: "); @@ -828,8 +844,7 @@ async fn http_1_0_requests_are_forwarded_as_http_1_1() { let proxy = TestProxy::start( &["allowed.example:80"], &[("allowed.example", 80, upstream)], - ) - .await; + ); let response = proxy .request("GET http://allowed.example/legacy HTTP/1.0\r\n\r\n")