diff --git a/Cargo.lock b/Cargo.lock index 942d5f64..84f8eba4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -750,7 +750,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "anyhow", "async-stream", @@ -771,7 +771,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "anyhow", "async-compression", @@ -799,7 +799,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "log", "proc-macro2", @@ -1576,9 +1576,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "log-fastly" @@ -2264,9 +2264,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 9f2f4c67..05a0eaf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,10 +56,10 @@ config = "0.15.19" cookie = "0.18.1" derive_more = { version = "2.0", features = ["display", "error"] } ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } error-stack = "0.6" fastly = "0.11.12" fern = "0.7.1" @@ -83,7 +83,7 @@ sha2 = "0.10.9" subtle = "2.6" temp-env = "0.3.6" tokio = { version = "1.49", features = ["sync", "macros", "io-util", "rt", "time"] } -toml = "1.0" +toml = "1.1" trusted-server-core = { path = "crates/trusted-server-core" } url = "2.5.8" urlencoding = "2.1" diff --git a/crates/integration-tests/Cargo.lock b/crates/integration-tests/Cargo.lock index 9f80a0ef..7200f480 100644 --- a/crates/integration-tests/Cargo.lock +++ b/crates/integration-tests/Cargo.lock @@ -201,9 +201,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -274,9 +274,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -316,7 +316,7 @@ checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" dependencies = [ "async-stream", "base64", - "bitflags 2.11.1", + "bitflags 2.13.0", "bollard-buildkit-proto", "bollard-stubs", "bytes", @@ -387,9 +387,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -398,9 +398,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -423,9 +423,9 @@ checksum = "d8e6738dfb11354886f890621b4a34c0b177f75538023f7100b608ab9adbd66b" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -441,9 +441,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -481,9 +481,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -530,9 +530,9 @@ checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "config" -version = "0.15.22" +version = "0.15.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" dependencies = [ "async-trait", "convert_case 0.6.0", @@ -903,9 +903,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -923,9 +923,9 @@ dependencies = [ [[package]] name = "docker_credential" -version = "1.3.3" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4564c274ebf369f501de192b02a0b81a5c4bda375abfe526aa70fc702fa6fa0" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ "base64", "serde", @@ -996,7 +996,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "anyhow", "async-compression", @@ -1024,7 +1024,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "log", "proc-macro2", @@ -1043,9 +1043,9 @@ checksum = "7c6ba7d4eec39eaa9ab24d44a0e73a7949a1095a8b3f3abb11eddf27dbb56a53" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -1520,6 +1520,15 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1533,11 +1542,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1584,9 +1593,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1629,9 +1638,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2005,9 +2014,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -2018,9 +2027,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -2065,9 +2074,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -2142,9 +2151,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lol_html" @@ -2152,7 +2161,7 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "cssparser 0.36.0", "encoding_rs", @@ -2210,9 +2219,9 @@ checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mime" @@ -2232,9 +2241,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2324,9 +2333,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2400,11 +2409,11 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -2431,9 +2440,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2840,9 +2849,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -2850,9 +2859,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", @@ -2863,9 +2872,9 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] @@ -2972,7 +2981,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -3088,7 +3097,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "once_cell", "serde", "serde_derive", @@ -3147,7 +3156,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -3171,9 +3180,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3305,7 +3314,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3328,7 +3337,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser 0.34.0", "derive_more 0.99.20", "fxhash", @@ -3347,7 +3356,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser 0.36.0", "derive_more 2.1.1", "log", @@ -3410,9 +3419,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3455,9 +3464,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64", "bs58", @@ -3475,9 +3484,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -3520,9 +3529,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signature" @@ -3560,9 +3569,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3710,7 +3719,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4053,11 +4062,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -4189,9 +4198,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -4217,9 +4226,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -4321,9 +4330,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4417,9 +4426,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -4430,9 +4439,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -4440,9 +4449,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4450,9 +4459,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -4463,9 +4472,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -4498,7 +4507,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -4506,9 +4515,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -4526,9 +4535,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ "libc", ] @@ -4740,7 +4749,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -4758,7 +4767,7 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -4810,7 +4819,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -4858,9 +4867,9 @@ dependencies = [ [[package]] name = "yaml-rust2" -version = "0.10.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", @@ -4869,9 +4878,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4892,18 +4901,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs new file mode 100644 index 00000000..60c1aede --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -0,0 +1,699 @@ +//! Full `EdgeZero` application wiring for Trusted Server. +//! +//! Registers all routes from the legacy [`crate::route_request`] into a +//! [`RouterService`]. On successful startup, attaches [`FinalizeResponseMiddleware`] +//! (outermost) and [`AuthMiddleware`] (inner). When startup fails, +//! [`startup_error_router`] returns a bare router without middleware. +//! Builds the [`AppState`] once per Wasm instance. +//! +//! `EdgeZero`'s current Fastly request context exposes client IP but not TLS +//! protocol or cipher metadata. `edgezero_main` injects a trusted `fastly-ssl` +//! header after stripping client-spoofable headers, so [`detect_request_scheme`] +//! in `http_util` can still derive the correct scheme for HTTPS traffic. +//! +//! # Route inventory +//! +//! | Method | Path pattern | Handler | +//! |--------|-------------|---------| +//! | GET | `/.well-known/trusted-server.json` | [`handle_trusted_server_discovery`] | +//! | POST | `/verify-signature` | [`handle_verify_signature`] | +//! | POST | `/_ts/admin/keys/rotate` | [`handle_rotate_key`] | +//! | POST | `/_ts/admin/keys/deactivate` | [`handle_deactivate_key`] | +//! | POST | `/auction` | [`handle_auction`] | +//! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] | +//! | GET | `/first-party/click` | [`handle_first_party_click`] | +//! | GET | `/first-party/sign` | [`handle_first_party_proxy_sign`] | +//! | POST | `/first-party/sign` | [`handle_first_party_proxy_sign`] | +//! | POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | +//! | GET | `/` and `/{*rest}` | tsjs (if `/static/tsjs=` prefix), integration proxy, or publisher fallback | +//! | POST, HEAD, OPTIONS, PUT, PATCH, DELETE | `/` and `/{*rest}` | integration proxy or publisher fallback | +//! | POST, HEAD, OPTIONS, PUT, PATCH, DELETE | named paths above | publisher fallback (legacy parity for non-primary methods) | +//! +//! > **Note:** Methods not in the list above (e.g. `TRACE`, `CONNECT`, WebDAV verbs) return a +//! > router-level 405. Legacy routing proxied *every* method through to the publisher origin. +//! > This is a known intentional restriction of the EdgeZero router; the entry-point +//! > `apply_finalize_headers` call in `main.rs` still adds TS headers to those 405 responses. +//! +//! # Startup error handling +//! +//! When [`build_state`] fails, [`startup_error_router`] returns a minimal router +//! that responds to all routes with the startup error. This router does **not** +//! attach middleware. Startup-error responses may still receive entry-point +//! finalization (geo and TS headers) when settings can be reloaded via +//! [`trusted_server_core::settings_data::get_settings`]; if settings loading itself +//! fails, they are returned without geo or TS headers. + +use core::future::Future; +use std::sync::Arc; + +use edgezero_adapter_fastly::FastlyRequestContext; +use edgezero_core::app::Hooks; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{header, HandlerFuture, HeaderValue, Method, Request, Response}; +use edgezero_core::router::RouterService; +use error_stack::Report; +use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; +use trusted_server_core::ec::EcContext; +use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; +use trusted_server_core::platform::{ClientInfo, PlatformKvStore, RuntimeServices}; +use trusted_server_core::proxy::{ + handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, + handle_first_party_proxy_sign, +}; +use trusted_server_core::publisher::{handle_publisher_request, handle_tsjs_dynamic}; +use trusted_server_core::request_signing::{ + handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, + handle_verify_signature, +}; +use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::get_settings; + +use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::platform::{ + FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformHttpClient, + FastlyPlatformSecretStore, UnavailableKvStore, +}; + +// --------------------------------------------------------------------------- +// AppState +// --------------------------------------------------------------------------- + +/// Application state built once per Wasm instance and shared for its lifetime. +/// +/// In Fastly Compute each request spawns a new Wasm instance, so this struct is +/// effectively per-request. It holds pre-parsed settings and all service handles. +pub(crate) struct AppState { + pub(crate) settings: Arc, + pub(crate) orchestrator: Arc, + pub(crate) registry: Arc, + pub(crate) kv_store: Arc, +} + +/// Build the application state, loading settings and constructing all per-application components. +/// +/// # Errors +/// +/// Returns an error when settings, the auction orchestrator, or the integration +/// registry fail to initialise. +pub(crate) fn build_state() -> Result, Report> { + build_state_from_settings(get_settings()?) +} + +pub(crate) fn build_state_from_settings( + settings: Settings, +) -> Result, Report> { + let orchestrator = build_orchestrator(&settings)?; + let registry = IntegrationRegistry::new(&settings)?; + let kv_store = Arc::new(UnavailableKvStore) as Arc; + Ok(Arc::new(AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + kv_store, + })) +} + +// --------------------------------------------------------------------------- +// Per-request RuntimeServices +// --------------------------------------------------------------------------- + +/// Construct per-request [`RuntimeServices`] from the `EdgeZero` request context. +/// +/// Extracts the client IP address from the [`FastlyRequestContext`] extension +/// inserted by `edgezero_adapter_fastly::dispatch`. TLS metadata is not +/// available through the `EdgeZero` context; scheme detection relies on the +/// trusted `fastly-ssl` header injected by `edgezero_main` after sanitization. +fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { + let client_ip = FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip); + + RuntimeServices::builder() + .config_store(Arc::new(FastlyPlatformConfigStore)) + .secret_store(Arc::new(FastlyPlatformSecretStore)) + .kv_store(Arc::clone(&state.kv_store)) + .backend(Arc::new(FastlyPlatformBackend)) + .http_client(Arc::new(FastlyPlatformHttpClient)) + .geo(Arc::new(FastlyPlatformGeo)) + .client_info(ClientInfo { + client_ip, + tls_protocol: None, + tls_cipher: None, + }) + .build() +} + +fn publisher_fallback_methods() -> [Method; 7] { + [ + Method::GET, + Method::POST, + Method::HEAD, + Method::OPTIONS, + Method::PUT, + Method::PATCH, + Method::DELETE, + ] +} + +fn uses_dynamic_tsjs_fallback(method: &Method, path: &str) -> bool { + *method == Method::GET && path.starts_with("/static/tsjs=") +} + +async fn execute_handler( + state: Arc, + ctx: RequestContext, + handler: F, +) -> Result +where + F: FnOnce(Arc, RuntimeServices, Request) -> Fut, + Fut: Future>>, +{ + let services = build_per_request_services(&state, &ctx); + let req = ctx.into_request(); + + Ok(handler(state, services, req) + .await + .unwrap_or_else(|e| http_error(&e))) +} + +async fn dispatch_fallback( + state: &AppState, + services: &RuntimeServices, + req: Request, +) -> Result> { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + if uses_dynamic_tsjs_fallback(&method, &path) { + return handle_tsjs_dynamic(&req, &state.registry); + } + + if state.registry.has_route(&method, &path) { + // Integration-proxy responses are not bounded by publisher.max_buffered_body_bytes. + // Only the handle_publisher_request branch below routes through + // resolve_publisher_response_buffered. Integration responses are small in practice + // and the EdgeZero flag is off by default; extend the cap here if that changes. + let mut ec_context = EcContext::default(); + return state + .registry + .handle_proxy(ProxyDispatchInput { + method: &method, + path: &path, + settings: &state.settings, + kv: None, + ec_context: &mut ec_context, + services, + req, + }) + .await + .unwrap_or_else(|| { + Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown integration route: {path}"), + })) + }); + } + + handle_publisher_request(&state.settings, &state.registry, services, req) + .await + .and_then(|pub_response| { + crate::resolve_publisher_response_buffered( + pub_response, + &state.settings, + &state.registry, + ) + }) +} + +// --------------------------------------------------------------------------- +// Error helper +// --------------------------------------------------------------------------- + +/// Convert a [`Report`] into an HTTP [`Response`], +/// mirroring [`crate::http_error_response`] exactly. +/// +/// The near-identical function in `main.rs` is intentional: the legacy path +/// uses fastly HTTP types while this path uses `edgezero_core` types. The +/// duplication will be removed when `legacy_main` is deleted in PR 15. +pub(crate) fn http_error(report: &Report) -> Response { + let root_error = report.current_context(); + log::error!("Error occurred: {:?}", report); + + let body = edgezero_core::body::Body::from(format!("{}\n", root_error.user_message())); + let mut response = Response::new(body); + *response.status_mut() = root_error.status_code(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +// --------------------------------------------------------------------------- +// Startup error fallback +// --------------------------------------------------------------------------- + +/// Returns a [`RouterService`] that responds to every registered route with the startup error. +/// +/// Called when [`build_state`] fails so that request handling degrades to a +/// structured HTTP error response rather than an unrecoverable panic. +fn startup_error_router(e: &Report) -> RouterService { + let message = Arc::new(format!("{}\n", e.current_context().user_message())); + let status = e.current_context().status_code(); + + let make = move |msg: Arc| { + move |_ctx: RequestContext| { + let body = edgezero_core::body::Body::from((*msg).clone()); + let mut resp = Response::new(body); + *resp.status_mut() = status; + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + async move { Ok::(resp) } + } + }; + + let mut router = RouterService::builder(); + for method in publisher_fallback_methods() { + router = router.route("/", method.clone(), make(Arc::clone(&message))); + router = router.route("/{*rest}", method, make(Arc::clone(&message))); + } + router.build() +} + +// --------------------------------------------------------------------------- +// Route registration +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +enum NamedRouteHandler { + TrustedServerDiscovery, + VerifySignature, + RotateKey, + DeactivateKey, + Auction, + FirstPartyProxy, + FirstPartyClick, + FirstPartySign, + FirstPartyProxyRebuild, +} + +struct NamedRoute { + path: &'static str, + primary_methods: &'static [Method], + handler: NamedRouteHandler, +} + +const NAMED_ROUTES: &[NamedRoute] = &[ + NamedRoute { + path: "/.well-known/trusted-server.json", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TrustedServerDiscovery, + }, + NamedRoute { + path: "/verify-signature", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::VerifySignature, + }, + NamedRoute { + path: "/_ts/admin/keys/rotate", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::RotateKey, + }, + NamedRoute { + path: "/_ts/admin/keys/deactivate", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::DeactivateKey, + }, + NamedRoute { + path: "/auction", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::Auction, + }, + NamedRoute { + path: "/first-party/proxy", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::FirstPartyProxy, + }, + NamedRoute { + path: "/first-party/click", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::FirstPartyClick, + }, + NamedRoute { + path: "/first-party/sign", + primary_methods: &[Method::GET, Method::POST], + handler: NamedRouteHandler::FirstPartySign, + }, + NamedRoute { + path: "/first-party/proxy-rebuild", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::FirstPartyProxyRebuild, + }, +]; + +fn named_route_handler( + state: Arc, + handler: NamedRouteHandler, +) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { + move |ctx: RequestContext| { + let state = Arc::clone(&state); + Box::pin(execute_handler( + state, + ctx, + move |state, services, req| async move { + match handler { + NamedRouteHandler::TrustedServerDiscovery => { + handle_trusted_server_discovery(&state.settings, &services, req) + } + NamedRouteHandler::VerifySignature => { + handle_verify_signature(&state.settings, &services, req) + } + NamedRouteHandler::RotateKey => { + handle_rotate_key(&state.settings, &services, req) + } + NamedRouteHandler::DeactivateKey => { + handle_deactivate_key(&state.settings, &services, req) + } + NamedRouteHandler::Auction => { + let ec_context = EcContext::default(); + handle_auction( + &state.settings, + &state.orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await + } + NamedRouteHandler::FirstPartyProxy => { + handle_first_party_proxy(&state.settings, &services, req).await + } + NamedRouteHandler::FirstPartyClick => { + handle_first_party_click(&state.settings, &services, req).await + } + NamedRouteHandler::FirstPartySign => { + handle_first_party_proxy_sign(&state.settings, &services, req).await + } + NamedRouteHandler::FirstPartyProxyRebuild => { + handle_first_party_proxy_rebuild(&state.settings, &services, req).await + } + } + }, + )) + } +} + +fn fallback_route_handler( + state: Arc, +) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { + move |ctx: RequestContext| { + let state = Arc::clone(&state); + Box::pin(execute_handler( + state, + ctx, + |state, services, req| async move { dispatch_fallback(&state, &services, req).await }, + )) + } +} + +// --------------------------------------------------------------------------- +// TrustedServerApp +// --------------------------------------------------------------------------- + +/// `EdgeZero` [`Hooks`] implementation for the Trusted Server application. +pub struct TrustedServerApp; + +impl TrustedServerApp { + fn routes_for_state(state: &Arc) -> RouterService { + let mut router = RouterService::builder() + .middleware(FinalizeResponseMiddleware::new( + Arc::clone(&state.settings), + Arc::new(FastlyPlatformGeo), + )) + .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); + + let fallback_handler = fallback_route_handler(Arc::clone(state)); + + // matchit prefers exact path+method over a wildcard catch-all. Each + // named route is registered from this single table, then every + // non-primary publisher fallback method is registered from the same + // row. Adding a named route now requires editing only this table. + for route in NAMED_ROUTES { + for method in route.primary_methods { + router = router.route( + route.path, + method.clone(), + named_route_handler(Arc::clone(state), route.handler), + ); + } + + for method in publisher_fallback_methods() { + if !route.primary_methods.contains(&method) { + router = router.route(route.path, method, fallback_handler.clone()); + } + } + } + + // matchit's `/{*rest}` does not match the bare root `/` — register + // explicit root routes so `/` reaches the publisher fallback too. + for method in publisher_fallback_methods() { + router = router.route("/", method.clone(), fallback_handler.clone()); + router = router.route("/{*rest}", method, fallback_handler.clone()); + } + + router.build() + } +} + +impl Hooks for TrustedServerApp { + fn name() -> &'static str { + "TrustedServer" + } + + fn routes() -> RouterService { + let state = match build_state() { + Ok(s) => s, + Err(ref e) => { + log::error!("failed to build application state: {:?}", e); + return startup_error_router(e); + } + }; + + Self::routes_for_state(&state) + } +} + +#[cfg(test)] +mod tests { + use super::{build_state_from_settings, startup_error_router, TrustedServerApp}; + + use edgezero_core::body::Body; + use edgezero_core::http::{header, request_builder, Method, StatusCode}; + use edgezero_core::router::RouterService; + use error_stack::Report; + use futures::executor::block_on; + use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::settings::Settings; + + fn empty_request(method: Method, uri: &str) -> edgezero_core::http::Request { + request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("should build request") + } + + fn test_router() -> RouterService { + let settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + + [integrations.prebid] + enabled = true + server_url = "https://test-prebid.com/openrtb2/auction" + + [auction] + enabled = true + providers = ["prebid"] + timeout_ms = 2000 + "#, + ) + .expect("should parse test settings"); + let state = build_state_from_settings(settings).expect("should build test state"); + TrustedServerApp::routes_for_state(&state) + } + + #[test] + fn startup_error_router_handles_head_and_options() { + let report = Report::new(TrustedServerError::BadRequest { + message: "startup failed".to_string(), + }); + let router = startup_error_router(&report); + + let head_response = block_on(router.oneshot(empty_request(Method::HEAD, "/"))); + let options_response = block_on(router.oneshot(empty_request(Method::OPTIONS, "/any"))); + + assert_eq!( + head_response.status(), + StatusCode::BAD_REQUEST, + "HEAD should use the degraded startup-error response" + ); + assert_eq!( + options_response.status(), + StatusCode::BAD_REQUEST, + "OPTIONS should use the degraded startup-error response" + ); + assert_eq!( + head_response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/plain; charset=utf-8"), + "startup errors should stay plain-text for HEAD requests" + ); + assert_eq!( + options_response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/plain; charset=utf-8"), + "startup errors should stay plain-text for OPTIONS requests" + ); + } + + #[test] + fn dynamic_tsjs_fallback_is_get_only() { + assert!( + super::uses_dynamic_tsjs_fallback(&Method::GET, "/static/tsjs=tsjs-unified.js"), + "GET should use the dynamic tsjs shortcut" + ); + assert!( + !super::uses_dynamic_tsjs_fallback(&Method::HEAD, "/static/tsjs=tsjs-unified.js"), + "HEAD should fall through to the publisher/integration fallback" + ); + assert!( + !super::uses_dynamic_tsjs_fallback(&Method::OPTIONS, "/static/tsjs=tsjs-unified.js"), + "OPTIONS should fall through to the publisher/integration fallback" + ); + } + + // --------------------------------------------------------------------------- + // Full EdgeZero dispatch-path tests + // --------------------------------------------------------------------------- + + #[test] + fn dispatch_auth_rejected_401_carries_finalize_headers() { + // Verifies FinalizeResponseMiddleware is outermost: an auth-rejected 401 + // must still carry standard TS headers before reaching the client. + // + // The test settings protects `^/_ts/admin` with basic-auth. + // Sending the request without an Authorization header causes AuthMiddleware + // to short-circuit with a 401, which then bubbles through + // FinalizeResponseMiddleware for header injection. + // + // This is safe to run without Viceroy: enforce_basic_auth is pure Rust + // (reads settings + request headers only) and FastlyPlatformGeo.lookup(None) + // short-circuits without calling any Fastly ABI. + let router = test_router(); + let req = request_builder() + .method(Method::POST) + .uri("/_ts/admin/keys/rotate") + .body(Body::empty()) + .expect("should build test request"); + + let response = block_on(router.oneshot(req)); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "request without credentials should be rejected" + ); + assert_eq!( + response + .headers() + .get(HEADER_X_GEO_INFO_AVAILABLE) + .and_then(|v| v.to_str().ok()), + Some("false"), + "FinalizeResponseMiddleware must run even for auth-rejected responses" + ); + } + + #[test] + fn dispatch_head_on_named_get_route_falls_through_to_publisher_fallback() { + // Regression guard: HEAD /first-party/proxy must reach the publisher + // fallback, not return a router-level 405. Legacy route_request proxies + // every (method, path) combination not matched by a specific arm through + // to the publisher origin. + // + // Without a live backend the publisher proxy errors (502/503), but the + // important invariant is that the status is NOT 405. + let router = test_router(); + let req = request_builder() + .method(Method::HEAD) + .uri("/first-party/proxy") + .body(Body::empty()) + .expect("should build HEAD request"); + + let response = block_on(router.oneshot(req)); + + assert_ne!( + response.status(), + StatusCode::METHOD_NOT_ALLOWED, + "HEAD on a named GET path should reach the publisher fallback, not return 405" + ); + } + + #[test] + fn dispatch_unregistered_method_returns_405_at_router_level() { + // Documents the known router-level behavior for verbs outside the + // publisher_fallback_methods() list (e.g. TRACE, CONNECT): the RouterService + // returns 405 before the middleware chain runs, so FinalizeResponseMiddleware + // does not inject TS headers at this layer. + // + // The full-system guarantee (TS headers on ALL responses including these 405s) + // is maintained by the entry-point apply_finalize_headers call in main.rs. + let router = test_router(); + let req = request_builder() + .method(Method::from_bytes(b"TRACE").expect("should parse TRACE")) + .uri("/") + .body(Body::empty()) + .expect("should build TRACE request"); + + let response = block_on(router.oneshot(req)); + + assert_eq!( + response.status(), + StatusCode::METHOD_NOT_ALLOWED, + "unregistered method should return 405 from the router layer" + ); + assert!( + response + .headers() + .get(HEADER_X_GEO_INFO_AVAILABLE) + .is_none(), + "router-level 405 bypasses FinalizeResponseMiddleware; main.rs entry-point covers this" + ); + } +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index c1acf4a7..a4152c85 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,19 +1,23 @@ +use std::io::Write; + +use std::sync::Arc; + +use edgezero_adapter_fastly::{into_core_request, FastlyConfigStore}; +use edgezero_core::app::Hooks as _; use edgezero_core::body::Body as EdgeBody; +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::http::{ - header, HeaderName, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, + header, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, }; use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; +use trusted_server_core::auction::AuctionOrchestrator; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::compat; -use trusted_server_core::constants::{ - COOKIE_SHAREDID, COOKIE_TS_EIDS, ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, - HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_VERSION, -}; +use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::batch_sync::handle_batch_sync; use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; @@ -30,6 +34,7 @@ use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; use trusted_server_core::geo::GeoInfo; use trusted_server_core::http_util::is_navigation_request; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; +use trusted_server_core::platform::PlatformGeo as _; use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, @@ -46,21 +51,27 @@ use trusted_server_core::request_signing::{ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::get_settings; +mod app; mod error; mod logging; mod management_api; +mod middleware; mod platform; #[cfg(test)] mod route_tests; +use crate::app::{build_state, TrustedServerApp}; use crate::error::to_error_response; -use crate::logging::init_logger; -use crate::platform::{build_runtime_services, UnavailableKvStore}; +use crate::middleware::{apply_finalize_headers, resolve_geo_for_response, HEADER_X_TS_FINALIZED}; +use crate::platform::{build_runtime_services, FastlyPlatformGeo}; + +const TRUSTED_SERVER_CONFIG_STORE: &str = "trusted_server_config"; +const EDGEZERO_ENABLED_KEY: &str = "edgezero_enabled"; /// Result of routing a request, distinguishing buffered from streaming publisher responses. /// /// The streaming arm keeps the publisher body out of WASM heap until it is written directly -/// to the client via [`fastly::Response::stream_to_client`]. All other routes are buffered. +/// to the client via [`fastly::Response::stream_to_client`]. All other legacy routes are buffered. /// /// [`AuthChallenge`](HandlerOutcome::AuthChallenge) marks responses produced by this server's /// own `enforce_basic_auth` so the geo-lookup gate can distinguish them from origin-forwarded @@ -85,6 +96,51 @@ impl HandlerOutcome { } } +/// Returns `true` if the raw config-store value represents an enabled flag. +/// +/// Accepted values (after whitespace trimming): `"1"` or `"true"` in any ASCII case. +/// All other values, including the empty string, are treated as disabled. +fn parse_edgezero_flag(value: &str) -> bool { + let v = value.trim(); + v.eq_ignore_ascii_case("true") || v == "1" +} + +/// Opens the shared Fastly Config Store used by both the `EdgeZero` flag read and +/// `EdgeZero` dispatch metadata. +/// +/// # Errors +/// +/// Returns [`fastly::Error`] if the config store cannot be opened. +fn open_trusted_server_config_store() -> Result { + let store = FastlyConfigStore::try_open(TRUSTED_SERVER_CONFIG_STORE) + .map_err(|e| fastly::Error::msg(format!("failed to open config store: {e}")))?; + Ok(ConfigStoreHandle::new(Arc::new(store))) +} + +/// Reads the `edgezero_enabled` key from the prepared Fastly Config Store +/// handle. +/// +/// Returns `Err` on any key-read failure, so callers should use the legacy path +/// as the safe default. +/// +/// # Errors +/// +/// - [`fastly::Error`] if the key cannot be read. +fn is_edgezero_enabled(config_store: &ConfigStoreHandle) -> Result { + let value = config_store + .get(EDGEZERO_ENABLED_KEY) + .map_err(|e| fastly::Error::msg(format!("failed to read edgezero_enabled: {e}")))?; + Ok(value.as_deref().is_some_and(parse_edgezero_flag)) +} + +fn health_response(req: &FastlyRequest) -> Option { + if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { + return Some(FastlyResponse::from_status(200).with_body_text_plain("ok")); + } + + None +} + /// Combined result from `route_request`, bundling the handler outcome with the /// EC context and cookies needed for post-send finalization and pull sync. struct RouteResult { @@ -99,64 +155,173 @@ struct RouteResult { /// Entry point for the Fastly Compute program. /// /// Uses an undecorated `main()` with `FastlyRequest::from_client()` instead of -/// `#[fastly::main]` so we can call `send_to_client()` explicitly when needed. +/// `#[fastly::main]` so the legacy streaming publisher path can call +/// [`fastly::Response::stream_to_client`] explicitly. fn main() { - init_logger(); - - let mut req = FastlyRequest::from_client(); + let req = FastlyRequest::from_client(); - // Keep the health probe independent from settings loading and routing so - // readiness checks still get a cheap liveness response during startup. - if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { - FastlyResponse::from_status(200) - .with_body_text_plain("ok") - .send_to_client(); + // Health probe bypasses logging, settings, and app construction as a cheap liveness signal. + if let Some(response) = health_response(&req) { + response.send_to_client(); return; } - let settings = match get_settings() { - Ok(s) => s, + logging::init_logger(); + + let edgezero_config_store = match open_trusted_server_config_store() { + Ok(config_store) => config_store, Err(e) => { - log::error!("Failed to load settings: {:?}", e); - to_error_response(&e).send_to_client(); + log::warn!("failed to open EdgeZero config store, falling back to legacy path: {e}"); + legacy_main(req); return; } }; - // lgtm[rust/cleartext-logging] - // `Settings` uses `Redacted` for secrets, so this debug dump is redacted. - log::debug!("Settings {settings:?}"); - // Short-circuit the ja4 debug probe before finalize_response so that - // Cache-Control: no-store, private cannot be replaced by operator [response_headers]. + if is_edgezero_enabled(&edgezero_config_store).unwrap_or_else(|e| { + log::warn!("failed to read edgezero_enabled flag, falling back to legacy path: {e}"); + false + }) { + log::debug!("routing request through EdgeZero path"); + edgezero_main(req, edgezero_config_store); + } else { + log::debug!("routing request through legacy path"); + legacy_main(req); + } +} + +/// Handles a request through the `EdgeZero` router path. +fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { + // Short-circuit the JA4 debug probe before app construction, mirroring + // legacy_main. Must run here because TLS/JA4 accessors are only available + // on FastlyRequest before conversion to edgezero types. if req.get_method() == FastlyMethod::GET && req.get_path() == "/_ts/debug/ja4" { - if settings.debug.ja4_endpoint_enabled { - build_ja4_debug_response(&req).send_to_client(); - } else { - FastlyResponse::from_status(fastly::http::StatusCode::NOT_FOUND).send_to_client(); + match get_settings() { + Ok(settings) if settings.debug.ja4_endpoint_enabled => { + build_ja4_debug_response(&req).send_to_client(); + } + Ok(_) => { + FastlyResponse::from_status(fastly::http::StatusCode::NOT_FOUND).send_to_client(); + } + Err(e) => { + log::warn!("EdgeZero JA4 endpoint: failed to load settings: {e:?}"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + } } return; } - // Build the auction orchestrator once at startup - let orchestrator = match build_orchestrator(&settings) { - Ok(orchestrator) => orchestrator, - Err(e) => { - log::error!("Failed to build auction orchestrator: {:?}", e); - to_error_response(&e).send_to_client(); - return; + let app = TrustedServerApp::build_app(); + + // Strip client-spoofable forwarded headers before handing off to the + // EdgeZero dispatcher, mirroring the sanitization done in legacy_main. + compat::sanitize_fastly_forwarded_headers(&mut req); + + // Re-inject a trusted TLS scheme signal after sanitization has stripped any + // client-sent fastly-ssl header. Setting it from Fastly's native TLS + // metadata here is authoritative. detect_request_scheme in http_util + // checks this header so scheme-sensitive logic (publisher URL rewriting, + // etc.) produces https URLs on HTTPS traffic, matching legacy path parity. + if req.get_tls_protocol().is_some() || req.get_tls_cipher_openssl_name().is_some() { + req.set_header("fastly-ssl", "1"); + } + + // Capture client IP before the request is consumed by dispatch. + let client_ip = req.get_client_ip_addr(); + + // Dispatch directly through the EdgeZero router without an intermediate + // fastly::Response conversion. The standard dispatch helpers + // (dispatch_with_config_handle, etc.) convert through fastly::Response using + // set_header, which drops duplicate header values — silently losing multiple + // Set-Cookie headers from publisher/origin responses. + // + // Bypassing to app.router().oneshot() preserves every header value in the + // http::HeaderMap and skips the logger-reinit that prevents using run_app_*. + let mut response = { + match into_core_request(req) { + Ok(mut core_req) => { + core_req.extensions_mut().insert(config_store); + futures::executor::block_on(app.router().oneshot(core_req)) + } + Err(e) => { + log::error!("EdgeZero request conversion failed: {e}"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + return; + } } }; - let integration_registry = match IntegrationRegistry::new(&settings) { - Ok(r) => r, + if !take_finalize_sentinel(&mut response) { + // Apply finalize headers at the entry point so that router-level + // 405/404 responses for unregistered HTTP methods (e.g. TRACE, WebDAV + // verbs) carry TS/geo headers. Middleware-finalized responses are + // skipped here to avoid a second settings read and geo lookup on the + // normal registered-route path. + match get_settings() { + Ok(settings) => { + let geo_info = resolve_geo_for_response(&response, client_ip, |client_ip| { + FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + log::warn!("entry-point geo lookup failed: {e}"); + None + }) + }); + apply_finalize_headers(&settings, geo_info.as_ref(), &mut response); + } + Err(e) => { + log::warn!("entry-point finalize skipped: failed to reload settings: {e:?}"); + } + } + } + + compat::to_fastly_response(response).send_to_client(); +} + +fn take_finalize_sentinel(response: &mut HttpResponse) -> bool { + response + .headers_mut() + .remove(HEADER_X_TS_FINALIZED) + .is_some() +} + +/// Handles a request using the original Fastly-native entry point. +/// +/// Preserves identical semantics to the pre-PR14 `main()`. Called whenever +/// the `EdgeZero` flag is disabled or cannot be read/parsed as enabled — that +/// includes config-store open failures, key-read errors, missing keys, and +/// any value other than the accepted `"true"` / `"1"` forms. +/// +/// The thin fastly<->http conversion layer (via `compat::from_fastly_request` / +/// `compat::to_fastly_response`) lives here in the adapter crate. `compat.rs` +/// will be deleted in PR 15 once this legacy path is retired. +// TODO: delete after Phase 5 EdgeZero cutover - see issue #495 +fn legacy_main(mut req: FastlyRequest) { + let state = match build_state() { + Ok(state) => state, Err(e) => { - log::error!("Failed to create integration registry: {:?}", e); + log::error!("Failed to build application state: {:?}", e); to_error_response(&e).send_to_client(); return; } }; + // lgtm[rust/cleartext-logging] + // `Settings` uses `Redacted` for secrets, so this debug dump is redacted. + log::debug!("Settings {:?}", state.settings); + + // Short-circuit the ja4 debug probe before finalize_response so that + // Cache-Control: no-store, private cannot be replaced by operator [response_headers]. + if req.get_method() == FastlyMethod::GET && req.get_path() == "/_ts/debug/ja4" { + if state.settings.debug.ja4_endpoint_enabled { + build_ja4_debug_response(&req).send_to_client(); + } else { + FastlyResponse::from_status(fastly::http::StatusCode::NOT_FOUND).send_to_client(); + } + return; + } - let partner_registry = match PartnerRegistry::from_config(&settings.ec.partners) { + let partner_registry = match PartnerRegistry::from_config(&state.settings.ec.partners) { Ok(registry) => registry, Err(e) => { log::error!("Failed to build partner registry: {:?}", e); @@ -165,22 +330,17 @@ fn main() { } }; - // Start with an unavailable primary KV slot. EC-backed routes lazily - // replace it with the configured EC identity store at dispatch time so - // unrelated routes stay available when EC KV is unavailable. - let kv_store = std::sync::Arc::new(UnavailableKvStore) - as std::sync::Arc; // Strip client-spoofable forwarded headers at the edge before building // any request-derived context or converting to the core HTTP types. compat::sanitize_fastly_forwarded_headers(&mut req); - let runtime_services = build_runtime_services(&req, kv_store); + let runtime_services = build_runtime_services(&req, std::sync::Arc::clone(&state.kv_store)); let http_req = compat::from_fastly_request(req); let route_result = futures::executor::block_on(route_request( - &settings, - &orchestrator, - &integration_registry, + &state.settings, + &state.orchestrator, + &state.registry, &partner_registry, &runtime_services, http_req, @@ -220,10 +380,10 @@ fn main() { match outcome { HandlerOutcome::Buffered(mut response) | HandlerOutcome::AuthChallenge(mut response) => { - finalize_response(&settings, geo_info.as_ref(), &mut response); + finalize_response(&state.settings, geo_info.as_ref(), &mut response); let mut fastly_resp = compat::to_fastly_response(response); ec_finalize_response( - &settings, + &state.settings, &ec_context, finalize_kv_graph.as_ref(), &partner_registry, @@ -235,7 +395,7 @@ fn main() { if is_real_browser { if let Some(context) = build_pull_sync_context(&ec_context) { - run_pull_sync_after_send(&settings, &partner_registry, &context); + run_pull_sync_after_send(&state.settings, &partner_registry, &context); } } } @@ -244,10 +404,10 @@ fn main() { body, params, } => { - finalize_response(&settings, geo_info.as_ref(), &mut response); + finalize_response(&state.settings, geo_info.as_ref(), &mut response); let mut fastly_resp = compat::to_fastly_response_skeleton(response); ec_finalize_response( - &settings, + &state.settings, &ec_context, finalize_kv_graph.as_ref(), &partner_registry, @@ -261,8 +421,8 @@ fn main() { body, &mut streaming_body, ¶ms, - &settings, - &integration_registry, + &state.settings, + &state.registry, ) { Ok(()) => { if let Err(e) = streaming_body.finish() { @@ -281,7 +441,7 @@ fn main() { if is_real_browser && stream_succeeded { if let Some(context) = build_pull_sync_context(&ec_context) { - run_pull_sync_after_send(&settings, &partner_registry, &context); + run_pull_sync_after_send(&state.settings, &partner_registry, &context); } } } @@ -292,7 +452,7 @@ const FALLBACK_UNAVAILABLE: &str = "unavailable"; const FALLBACK_NOT_SENT: &str = "not sent"; const FALLBACK_NONE: &str = "none"; -// TODO: remove after JA4 evaluation completes — see #645 +// TODO: remove after JA4 evaluation completes - see #645 fn build_ja4_debug_response(req: &FastlyRequest) -> FastlyResponse { let ja4 = req.get_tls_ja4().unwrap_or(FALLBACK_UNAVAILABLE); let h2 = req @@ -458,7 +618,7 @@ async fn route_request( Err(e) => return Err(e), } - // Get path and method for routing + // Get path and method for routing. let path = req.uri().path().to_string(); let method = req.method().clone(); @@ -507,7 +667,7 @@ async fn route_request( (outcome, false) } - // Unified auction endpoint (returns creative HTML inline) + // Unified auction endpoint. (Method::POST, "/auction") => { let registry_ref = if partner_registry.is_empty() { None @@ -566,7 +726,7 @@ async fn route_request( (result, true) } - // No known route matched, proxy to publisher origin as fallback + // No known route matched, proxy to publisher origin as fallback. _ => { log::info!( "No known route matched for path: {}, proxying to publisher origin", @@ -653,6 +813,90 @@ fn run_pull_sync_after_send( dispatch_pull_sync(settings, &kv, partner_registry, &limiter, context); } +struct BoundedWriter { + inner: Vec, + limit: usize, +} + +impl BoundedWriter { + fn new(limit: usize) -> Self { + Self { + inner: Vec::new(), + limit, + } + } + + fn into_inner(self) -> Vec { + self.inner + } +} + +impl Write for BoundedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.inner.len() + buf.len() > self.limit { + return Err(std::io::Error::other( + "publisher body exceeded maximum buffered size", + )); + } + self.inner.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +pub(crate) fn resolve_publisher_response_buffered( + publisher_response: PublisherResponse, + settings: &Settings, + integration_registry: &IntegrationRegistry, +) -> Result> { + match publisher_response { + PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::Stream { + mut response, + body, + params, + } => { + let bytes = match settings.publisher.max_buffered_body_bytes { + Some(limit) => { + let mut output = BoundedWriter::new(limit); + stream_publisher_body( + body, + &mut output, + ¶ms, + settings, + integration_registry, + )?; + output.into_inner() + } + None => { + let mut output = Vec::new(); + stream_publisher_body( + body, + &mut output, + ¶ms, + settings, + integration_registry, + )?; + output + } + }; + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from(bytes.len() as u64), + ); + *response.body_mut() = EdgeBody::from(bytes); + Ok(response) + } + PublisherResponse::PassThrough { mut response, body } => { + *response.body_mut() = body; + Ok(response) + } + } +} + /// Applies all standard response headers: geo, version, staging, and configured headers. /// /// Called from every response path (including auth early-returns) so that all @@ -662,35 +906,7 @@ fn run_pull_sync_after_send( /// version/staging, then operator-configured `settings.response_headers`. /// This means operators can intentionally override any managed header. fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: &mut HttpResponse) { - if let Some(geo) = geo_info { - geo.set_response_headers(response); - } else { - response.headers_mut().insert( - HEADER_X_GEO_INFO_AVAILABLE, - HeaderValue::from_static("false"), - ); - } - - if let Ok(v) = ::std::env::var(ENV_FASTLY_SERVICE_VERSION) { - if let Ok(value) = HeaderValue::from_str(&v) { - response.headers_mut().insert(HEADER_X_TS_VERSION, value); - } else { - log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value"); - } - } - if ::std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { - response - .headers_mut() - .insert(HEADER_X_TS_ENV, HeaderValue::from_static("staging")); - } - - for (key, value) in &settings.response_headers { - let header_name = HeaderName::from_bytes(key.as_bytes()) - .expect("settings.response_headers validated at load time"); - let header_value = - HeaderValue::from_str(value).expect("settings.response_headers validated at load time"); - response.headers_mut().insert(header_name, header_value); - } + apply_finalize_headers(settings, geo_info, response); } fn http_error_response(report: &Report) -> HttpResponse { @@ -750,8 +966,131 @@ fn derive_device_signals(req: &FastlyRequest) -> DeviceSignals { #[cfg(test)] mod tests { use super::*; + use edgezero_core::http::response_builder; use fastly::mime; + fn test_settings() -> Settings { + Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + "#, + ) + .expect("should parse test settings") + } + + #[test] + fn parses_true_flag_values() { + assert!(parse_edgezero_flag("true"), "should parse 'true'"); + assert!(parse_edgezero_flag("1"), "should parse '1'"); + assert!(parse_edgezero_flag(" true "), "should trim whitespace"); + assert!( + parse_edgezero_flag(" 1 "), + "should trim whitespace around '1'" + ); + assert!(parse_edgezero_flag("TRUE"), "should parse uppercase 'TRUE'"); + assert!( + parse_edgezero_flag("True"), + "should parse mixed-case 'True'" + ); + } + + #[test] + fn rejects_non_true_flag_values() { + assert!(!parse_edgezero_flag("false"), "should not parse 'false'"); + assert!(!parse_edgezero_flag(""), "should not parse empty string"); + assert!( + !parse_edgezero_flag(" "), + "should not parse whitespace-only" + ); + assert!(!parse_edgezero_flag("yes"), "should not parse 'yes'"); + } + + #[test] + fn health_response_short_circuits_get_health() { + let req = FastlyRequest::get("https://example.com/health"); + + let mut response = health_response(&req).expect("should build health response"); + + assert_eq!( + response.get_status(), + fastly::http::StatusCode::OK, + "should return 200 OK" + ); + assert_eq!( + response.take_body_str(), + "ok", + "should return the health body" + ); + } + + #[test] + fn health_response_ignores_non_health_paths() { + let req = FastlyRequest::get("https://example.com/auction"); + + assert!( + health_response(&req).is_none(), + "should only short-circuit /health" + ); + } + + #[test] + fn take_finalize_sentinel_strips_sentinel() { + let mut response = HttpResponse::new(EdgeBody::empty()); + response + .headers_mut() + .insert("x-ts-finalized", HeaderValue::from_static("1")); + + assert!( + take_finalize_sentinel(&mut response), + "should detect middleware-finalized responses" + ); + assert!( + response.headers().get("x-ts-finalized").is_none(), + "sentinel should not be sent to clients" + ); + } + + #[test] + #[allow(clippy::panic)] + fn entry_point_finalize_skips_geo_lookup_for_401() { + let settings = test_settings(); + let mut response = response_builder() + .status(edgezero_core::http::StatusCode::UNAUTHORIZED) + .body(EdgeBody::empty()) + .expect("should build response"); + + let geo_info = resolve_geo_for_response(&response, None, |_| { + panic!("should skip entry-point geo lookup for 401 responses"); + }); + apply_finalize_headers(&settings, geo_info.as_ref(), &mut response); + + assert_eq!( + response + .headers() + .get(trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE) + .and_then(|v| v.to_str().ok()), + Some("false"), + "401 responses should still carry geo-unavailable headers" + ); + } + #[test] fn ja4_debug_response_uses_plain_text_and_fallback_values() { let req = FastlyRequest::get("https://example.com/_ts/debug/ja4"); diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs new file mode 100644 index 00000000..c8e128fb --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -0,0 +1,502 @@ +//! Middleware implementations for the dual-path entry point. +//! +//! Provides two middleware types that mirror the finalization and auth logic +//! from the legacy [`crate::finalize_response`] and [`crate::route_request`]: +//! +//! - [`FinalizeResponseMiddleware`] — geo lookup and standard TS header injection +//! - [`AuthMiddleware`] — basic-auth enforcement via [`enforce_basic_auth`] +//! +//! Registration order in [`crate::app`]: `FinalizeResponseMiddleware` outermost, +//! then `AuthMiddleware`. This ensures auth-rejected responses also receive the +//! standard TS headers before being returned to the client. + +use std::sync::Arc; + +use async_trait::async_trait; +use edgezero_adapter_fastly::FastlyRequestContext; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderName, HeaderValue, Response, StatusCode}; +use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::response::IntoResponse; +use std::net::IpAddr; +use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::constants::{ + ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, + HEADER_X_TS_ENV, HEADER_X_TS_VERSION, +}; +use trusted_server_core::geo::GeoInfo; +use trusted_server_core::platform::PlatformGeo; +use trusted_server_core::settings::Settings; + +pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; + +// --------------------------------------------------------------------------- +// FinalizeResponseMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: performs geo lookup and injects all standard TS response headers. +/// +/// Registered first in the middleware chain so that it wraps all inner middleware +/// (including [`AuthMiddleware`]) and the handler. This guarantees every registered-route +/// response — including auth-rejected ones — carries a consistent set of headers. +/// +/// Router-level 405/404 responses for unregistered HTTP methods (e.g. TRACE) bypass the +/// middleware chain. Those are covered by a second call to [`apply_finalize_headers`] at +/// the `main.rs` entry point. Middleware-finalized responses carry +/// [`HEADER_X_TS_FINALIZED`] so the entry point can skip duplicate finalization. +/// +/// # Header precedence +/// +/// Headers are written in this order (last write wins): +/// 1. Geo headers (or `X-Geo-Info-Available: false` when geo is unavailable) +/// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var +/// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` +/// 4. Operator-configured `settings.response_headers` (can override any managed header) +pub struct FinalizeResponseMiddleware { + settings: Arc, + geo: Arc, +} + +impl FinalizeResponseMiddleware { + /// Creates a new [`FinalizeResponseMiddleware`] with the given settings and geo lookup service. + pub fn new(settings: Arc, geo: Arc) -> Self { + Self { settings, geo } + } +} + +#[async_trait(?Send)] +impl Middleware for FinalizeResponseMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + let client_ip = FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip); + + let mut response = match next.run(ctx).await { + Ok(r) => r, + Err(e) => { + log::error!("request handler failed: {e:?}"); + e.into_response() + } + }; + + let geo_info = resolve_geo_for_response(&response, client_ip, |ip| { + self.geo.lookup(ip).unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }) + }); + + apply_finalize_headers(&self.settings, geo_info.as_ref(), &mut response); + response + .headers_mut() + .insert(HEADER_X_TS_FINALIZED, HeaderValue::from_static("1")); + + Ok(response) + } +} + +// --------------------------------------------------------------------------- +// AuthMiddleware +// --------------------------------------------------------------------------- + +/// Inner middleware: enforces basic-auth before the handler runs. +/// +/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the +/// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). +/// - `Ok(None)` → no auth required or credentials accepted; continue the chain. +/// - `Err(report)` → internal error; log and convert to an HTTP response via +/// [`crate::app::http_error`] using the error's documented status code. +/// +/// # Errors +/// +/// When [`enforce_basic_auth`] returns an error report, converts it to an HTTP +/// response via [`crate::app::http_error`] (preserving the error's status code) +/// so that [`FinalizeResponseMiddleware`] can still inject standard TS headers +/// before the response reaches the client. +pub struct AuthMiddleware { + settings: Arc, +} + +impl AuthMiddleware { + /// Creates a new [`AuthMiddleware`] with the given settings. + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AuthMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + match enforce_basic_auth(&self.settings, ctx.request()) { + Ok(Some(response)) => return Ok(response), + Ok(None) => {} + Err(report) => { + log::error!("auth check failed: {:?}", report); + return Ok(crate::app::http_error(&report)); + } + } + + next.run(ctx).await + } +} + +// --------------------------------------------------------------------------- +// Shared geo resolution helper +// --------------------------------------------------------------------------- + +/// Resolves geo for a response, skipping the lookup for 401 responses. +/// +/// Returns `None` for authentication rejections (401) without calling `lookup_geo` +/// to avoid unnecessary work and exposing geo data to unauthenticated callers. +/// All other responses call `lookup_geo` and return its result. +/// +/// Used by both [`FinalizeResponseMiddleware`] and the entry-point finalization +/// in `main.rs` so the 401-skip rule is defined in one place. +/// +/// # Parity note +/// +/// The legacy path skips geo only for its own `HandlerOutcome::AuthChallenge` +/// responses; origin-forwarded 401s still receive geo headers there. The `EdgeZero` +/// path skips geo for **all** 401s by status. This is intentionally more +/// conservative: geo data is not sent to any unauthenticated caller regardless of +/// whether the 401 originated from this server or the upstream origin. +pub(crate) fn resolve_geo_for_response( + response: &Response, + client_ip: Option, + lookup_geo: F, +) -> Option +where + F: FnOnce(Option) -> Option, +{ + if response.status() == StatusCode::UNAUTHORIZED { + None + } else { + lookup_geo(client_ip) + } +} + +// --------------------------------------------------------------------------- +// apply_finalize_headers — extracted for unit testing +// --------------------------------------------------------------------------- + +/// Applies all standard Trusted Server response headers to the given response. +/// +/// Mirrors [`crate::finalize_response`] exactly, operating on [`Response`] from +/// `edgezero_core::http` instead of `HttpResponse`. +/// +/// Header write order (last write wins): +/// 1. Geo headers (`x-geo-*`) — or `X-Geo-Info-Available: false` when absent +/// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var +/// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` +/// 4. `settings.response_headers` — operator-configured overrides applied last +pub(crate) fn apply_finalize_headers( + settings: &Settings, + geo_info: Option<&GeoInfo>, + response: &mut Response, +) { + if let Some(geo) = geo_info { + geo.set_response_headers(response); + } else { + response.headers_mut().insert( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("false"), + ); + } + + if let Ok(v) = std::env::var(ENV_FASTLY_SERVICE_VERSION) { + if let Ok(value) = HeaderValue::from_str(&v) { + response.headers_mut().insert(HEADER_X_TS_VERSION, value); + } else { + log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value"); + } + } + + if std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { + response + .headers_mut() + .insert(HEADER_X_TS_ENV, HeaderValue::from_static("staging")); + } + + for (key, value) in &settings.response_headers { + let header_name = HeaderName::from_bytes(key.as_bytes()) + .expect("settings.response_headers validated at load time"); + let header_value = + HeaderValue::from_str(value).expect("settings.response_headers validated at load time"); + response.headers_mut().insert(header_name, header_value); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::HashMap; + use std::net::IpAddr; + use std::sync::Arc; + + use edgezero_core::body::Body; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{request_builder, response_builder, Method, StatusCode}; + use edgezero_core::middleware::Next; + use edgezero_core::params::PathParams; + use error_stack::Report; + use futures::executor::block_on; + use trusted_server_core::platform::{PlatformError, PlatformGeo}; + + fn empty_response() -> Response { + response_builder() + .body(Body::empty()) + .expect("should build empty test response") + } + + fn empty_ctx() -> RequestContext { + let req = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::new(HashMap::new())) + } + + struct FixedGeo(Option); + + impl PlatformGeo for FixedGeo { + fn lookup(&self, _: Option) -> Result, Report> { + Ok(self.0.clone()) + } + } + + fn test_settings() -> Settings { + Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + "#, + ) + .expect("should parse test settings") + } + + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { + let mut s = test_settings(); + s.response_headers = headers + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + s + } + + #[test] + fn operator_response_headers_override_earlier_headers() { + let settings = + settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); + let mut response = empty_response(); + + // No geo_info → would set "false"; operator header should win instead. + apply_finalize_headers(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("operator-override"), + "should override the managed geo header with the operator-configured value" + ); + } + + #[test] + fn sets_geo_unavailable_header_when_no_geo_info() { + let settings = settings_with_response_headers(vec![]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set X-Geo-Info-Available: false when no geo info is available" + ); + } + + // --------------------------------------------------------------------------- + // FinalizeResponseMiddleware::handle tests + // --------------------------------------------------------------------------- + + #[test] + fn finalize_handle_injects_geo_unavailable_on_ok_response() { + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed"); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set X-Geo-Info-Available: false when geo returns None" + ); + } + + #[test] + fn finalize_handle_marks_response_as_finalized() { + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed"); + + assert_eq!( + response + .headers() + .get("x-ts-finalized") + .and_then(|v| v.to_str().ok()), + Some("1"), + "middleware-finalized responses should carry the entry-point sentinel" + ); + } + + #[test] + fn finalize_handle_absorbs_handler_error_and_injects_headers() { + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = Arc::new(|_ctx: RequestContext| async move { + Err::(EdgeError::service_unavailable("test error")) + }); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should absorb handler error into a response"); + + assert!( + response.status().is_server_error(), + "should produce a server-error status for absorbed handler error" + ); + assert!( + response.headers().get("x-geo-info-available").is_some(), + "absorbed error response should still carry geo header" + ); + } + + #[test] + #[allow(clippy::panic)] + fn finalize_handle_skips_geo_lookup_for_401() { + struct PanicGeo; + impl PlatformGeo for PanicGeo { + fn lookup(&self, _: Option) -> Result, Report> { + panic!("should not call geo for 401 responses") + } + } + + let settings = settings_with_response_headers(vec![]); + let middleware = FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(PanicGeo)); + let handler = Arc::new(|_ctx: RequestContext| async move { + let mut resp = empty_response(); + *resp.status_mut() = StatusCode::UNAUTHORIZED; + Ok::(resp) + }); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed without calling geo"); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "should preserve 401 status" + ); + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set geo-unavailable header without calling geo for 401" + ); + } + + // --------------------------------------------------------------------------- + // AuthMiddleware::handle tests + // --------------------------------------------------------------------------- + + #[test] + fn finalize_handle_preserves_duplicate_set_cookie_headers() { + // Regression guard: FinalizeResponseMiddleware must not drop duplicate + // Set-Cookie headers. The old dispatch_with_config_handle path silently + // collapsed them because fastly::Response uses set_header (last-wins). + // This test verifies the EdgeZero middleware chain is header-transparent. + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = Arc::new(|_ctx: RequestContext| async move { + let resp = response_builder() + .header("set-cookie", "session=abc; Path=/; HttpOnly") + .header("set-cookie", "tracker=xyz; Path=/; SameSite=Lax") + .body(Body::empty()) + .expect("should build response with two Set-Cookie headers"); + Ok::(resp) + }); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed"); + + let cookie_count = response.headers().get_all("set-cookie").iter().count(); + assert_eq!( + cookie_count, 2, + "FinalizeResponseMiddleware must not drop duplicate Set-Cookie headers" + ); + } + + #[test] + fn auth_handle_passes_through_when_auth_not_configured() { + let settings = test_settings(); + let middleware = AuthMiddleware::new(Arc::new(settings)); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should pass through when auth is not configured"); + + assert_eq!( + response.status(), + StatusCode::OK, + "should reach the handler when auth is not required" + ); + } +} diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index be855241..a426fb3e 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -216,7 +216,7 @@ fn normalize_scheme(value: &str) -> Option { /// 1. Fastly SDK TLS detection methods (most reliable) /// 2. Forwarded header (RFC 7239) /// 3. X-Forwarded-Proto header -/// 4. Fastly-SSL header (least reliable, can be spoofed) +/// 4. Fastly-SSL header (trusted on `EdgeZero` path; can be spoofed on legacy path) /// 5. Default to HTTP fn detect_request_scheme( req: &Request, @@ -257,7 +257,9 @@ fn detect_request_scheme( } } - // 4. Check Fastly-SSL header (can be spoofed by clients, use as last resort) + // 4. Check Fastly-SSL header. On the `EdgeZero` path this is injected from + // authoritative Fastly TLS metadata after spoofable headers are stripped, + // so it is reliable. On direct or legacy paths it can be spoofed by clients. if let Some(ssl) = req.headers().get("fastly-ssl") { if let Ok(ssl_str) = ssl.to_str() { if ssl_str == "1" || ssl_str.to_lowercase() == "true" { diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index bdb7ebb1..08ae34d2 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -74,9 +74,15 @@ pub struct ProxyRequestConfig<'a> { pub stream_passthrough: bool, /// Domains allowed for the initial request and any redirects. /// - /// When empty every host is permitted (open mode). Integration proxies - /// should leave this empty; first-party handlers should pass - /// `&settings.proxy.allowed_domains` to enforce the publisher allowlist. + /// **Open mode** (`&[]`): every host is permitted. Integration proxies pass `&[]` + /// because their target URLs originate from operator-controlled configuration + /// (e.g. `trusted-server.toml` integration settings) and are therefore trusted at + /// operator setup time rather than at request time. + /// + /// **Restricted mode** (non-empty slice): only hosts matching a listed pattern are + /// permitted. Currently only [`handle_first_party_proxy`] passes + /// `&settings.proxy.allowed_domains` because it follows redirect chains that may + /// originate from untrusted creative-supplied URLs. pub allowed_domains: &'a [String], } @@ -419,10 +425,11 @@ struct ProxyRequestHeaders<'a> { additional_headers: &'a [(header::HeaderName, HeaderValue)], copy_request_headers: bool, services: &'a RuntimeServices, - /// Domains permitted for the initial request and any redirects. - /// - /// Empty slice means open mode (all hosts allowed). Populated by first-party - /// handlers; integration proxies leave it empty. +} + +struct ProxyRedirectPolicy<'a> { + follow_redirects: bool, + stream_passthrough: bool, allowed_domains: &'a [String], } @@ -467,15 +474,17 @@ pub async fn proxy_request( settings, &req, target_url_parsed, - follow_redirects, body.as_deref(), ProxyRequestHeaders { additional_headers: &headers, copy_request_headers, services, + }, + ProxyRedirectPolicy { + follow_redirects, + stream_passthrough, allowed_domains, }, - stream_passthrough, ) .await } @@ -554,10 +563,9 @@ async fn proxy_with_redirects( settings: &Settings, req: &Request, target_url_parsed: url::Url, - follow_redirects: bool, body: Option<&[u8]>, request_headers: ProxyRequestHeaders<'_>, - stream_passthrough: bool, + redirect_policy: ProxyRedirectPolicy<'_>, ) -> Result, Report> { const MAX_REDIRECTS: usize = 4; @@ -585,7 +593,7 @@ async fn proxy_with_redirects( })); } - if !redirect_is_permitted(request_headers.allowed_domains, host) { + if !redirect_is_permitted(redirect_policy.allowed_domains, host) { log::warn!( "request to `{}` blocked: host not in proxy allowed_domains", host @@ -657,8 +665,14 @@ async fn proxy_with_redirects( let beresp = platform_resp.response; - if !follow_redirects { - return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); + if !redirect_policy.follow_redirects { + return finalize_response( + settings, + req, + ¤t_url, + beresp, + redirect_policy.stream_passthrough, + ); } let status = beresp.status(); @@ -672,7 +686,13 @@ async fn proxy_with_redirects( ); if !is_redirect { - return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); + return finalize_response( + settings, + req, + ¤t_url, + beresp, + redirect_policy.stream_passthrough, + ); } let Some(location) = beresp @@ -681,7 +701,13 @@ async fn proxy_with_redirects( .and_then(|h| h.to_str().ok()) .filter(|value| !value.is_empty()) else { - return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); + return finalize_response( + settings, + req, + ¤t_url, + beresp, + redirect_policy.stream_passthrough, + ); }; if redirect_attempt == MAX_REDIRECTS { @@ -702,7 +728,13 @@ async fn proxy_with_redirects( let next_scheme = next_url.scheme().to_ascii_lowercase(); if next_scheme != "http" && next_scheme != "https" { - return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); + return finalize_response( + settings, + req, + ¤t_url, + beresp, + redirect_policy.stream_passthrough, + ); } let next_host = match next_url.host_str() { @@ -713,7 +745,7 @@ async fn proxy_with_redirects( })); } }; - if !redirect_is_permitted(request_headers.allowed_domains, next_host) { + if !redirect_is_permitted(redirect_policy.allowed_domains, next_host) { log::warn!( "redirect to `{}` blocked: host not in proxy allowed_domains", next_host @@ -1276,7 +1308,8 @@ fn reconstruct_and_validate_signed_target( #[cfg(test)] mod tests { - use std::sync::Arc; + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; use super::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, @@ -1358,6 +1391,79 @@ mod tests { .expect("response body should be valid UTF-8") } + struct QueuedHttpResponse { + status: u16, + headers: Vec<(header::HeaderName, HeaderValue)>, + body: Vec, + } + + #[derive(Default)] + struct HeaderAwareStubHttpClient { + responses: Mutex>, + } + + impl HeaderAwareStubHttpClient { + fn new() -> Self { + Self::default() + } + + fn push_response( + &self, + status: u16, + headers: Vec<(header::HeaderName, HeaderValue)>, + body: Vec, + ) { + self.responses + .lock() + .expect("should lock queued responses") + .push_back(QueuedHttpResponse { + status, + headers, + body, + }); + } + } + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for HeaderAwareStubHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + let queued = self + .responses + .lock() + .expect("should lock queued responses") + .pop_front() + .ok_or_else(|| Report::new(PlatformError::HttpClient))?; + + let mut builder = edgezero_core::http::response_builder().status(queued.status); + for (name, value) in queued.headers { + builder = builder.header(name, value); + } + + let response = builder + .body(EdgeBody::from(queued.body)) + .expect("should build stub HTTP response"); + + Ok(PlatformResponse::new(response)) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + } + fn build_http_response(status: StatusCode, body: EdgeBody) -> Response { let mut response = Response::new(body); *response.status_mut() = status; @@ -2123,6 +2229,83 @@ mod tests { ); } + #[tokio::test] + async fn proxy_request_allows_open_mode_when_settings_allowlist_is_non_empty() { + let mut settings = create_test_settings(); + settings.proxy.allowed_domains = vec!["allowed.example".to_string()]; + + let stub = Arc::new(HeaderAwareStubHttpClient::new()); + stub.push_response(200, Vec::new(), b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = build_http_request(Method::GET, "https://edge.example/"); + + let response = proxy_request( + &settings, + req, + ProxyRequestConfig { + target_url: "https://blocked.example/resource.js", + follow_redirects: false, + forward_ec_id: false, + body: None, + headers: Vec::new(), + copy_request_headers: false, + stream_passthrough: false, + allowed_domains: &[], + }, + &services, + ) + .await + .expect("open mode should ignore settings.proxy.allowed_domains"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_body_string(response), "ok"); + } + + #[tokio::test] + async fn proxy_request_uses_config_allowlist_for_redirect_hops() { + let mut settings = create_test_settings(); + settings.proxy.allowed_domains = vec!["origin.example".to_string()]; + + let stub = Arc::new(HeaderAwareStubHttpClient::new()); + stub.push_response( + 302, + vec![( + header::LOCATION, + HeaderValue::from_static("https://redirected.example/final.js"), + )], + Vec::new(), + ); + stub.push_response(200, Vec::new(), b"redirected".to_vec()); + + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = build_http_request(Method::GET, "https://edge.example/"); + + let response = proxy_request( + &settings, + req, + ProxyRequestConfig { + target_url: "https://origin.example/start.js", + follow_redirects: true, + forward_ec_id: false, + body: None, + headers: Vec::new(), + copy_request_headers: false, + stream_passthrough: false, + allowed_domains: &[], + }, + &services, + ) + .await + .expect("open mode should allow redirect hops outside settings allowlist"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_body_string(response), "redirected"); + } + #[tokio::test] async fn proxy_request_forwards_curated_headers_when_copy_request_headers_is_true() { use crate::platform::test_support::StubHttpClient; @@ -2495,12 +2678,9 @@ mod tests { // --- initial target allowlist enforcement (integration-level) --- // - // NOTE: A test for Nth-hop redirect blocking (i.e. exercising the - // `redirect_is_permitted` check that fires *after* receiving a 302 - // response) requires a Viceroy backend fixture that returns a redirect. - // That infrastructure is not available here. The unit tests above for - // `redirect_is_permitted` and `ip_literal_blocked_by_domain_allowlist` - // cover the blocking logic used at every hop. + // The unit tests above cover the host-matching logic itself. The tests + // below verify that proxy_request threads config.allowed_domains through + // the initial target check and redirect hops. #[tokio::test] async fn proxy_initial_target_blocked_by_allowlist() { diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 919277e7..342d74dc 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -32,6 +32,16 @@ pub struct Publisher { /// Keep this secret stable to allow existing links to decode. #[validate(custom(function = validate_redacted_not_empty))] pub proxy_secret: Redacted, + /// Maximum number of bytes buffered when the `EdgeZero` publisher fallback processes + /// a streaming response. Defaults to 16 MiB — a conservative cap that prevents + /// Wasm-heap OOM at flag-flip. Set explicitly to a larger integer value + /// when the deployment serves publisher pages larger than 16 MiB. + #[serde(default = "default_max_buffered_body_bytes")] + pub max_buffered_body_bytes: Option, +} + +fn default_max_buffered_body_bytes() -> Option { + Some(16 * 1024 * 1024) } impl Publisher { @@ -70,6 +80,7 @@ impl Publisher { /// cookie_domain: ".example.com".to_string(), /// origin_url: "https://origin.example.com:8080".to_string(), /// proxy_secret: Redacted::new("proxy-secret".to_string()), + /// max_buffered_body_bytes: None, /// }; /// assert_eq!(publisher.origin_host(), "origin.example.com:8080"); /// ``` @@ -1941,6 +1952,7 @@ mod tests { cookie_domain: ".example.com".to_string(), origin_url: "https://origin.example.com:8080".to_string(), proxy_secret: Redacted::new("test-secret".to_string()), + max_buffered_body_bytes: None, }; assert_eq!(publisher.origin_host(), "origin.example.com:8080"); @@ -1950,6 +1962,7 @@ mod tests { cookie_domain: ".example.com".to_string(), origin_url: "https://origin.example.com".to_string(), proxy_secret: Redacted::new("test-secret".to_string()), + max_buffered_body_bytes: None, }; assert_eq!(publisher.origin_host(), "origin.example.com"); @@ -1959,6 +1972,7 @@ mod tests { cookie_domain: ".example.com".to_string(), origin_url: "http://localhost:9090".to_string(), proxy_secret: Redacted::new("test-secret".to_string()), + max_buffered_body_bytes: None, }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -1968,6 +1982,7 @@ mod tests { cookie_domain: ".example.com".to_string(), origin_url: "localhost:9090".to_string(), proxy_secret: Redacted::new("test-secret".to_string()), + max_buffered_body_bytes: None, }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -1977,6 +1992,7 @@ mod tests { cookie_domain: ".example.com".to_string(), origin_url: "http://192.168.1.1:8080".to_string(), proxy_secret: Redacted::new("test-secret".to_string()), + max_buffered_body_bytes: None, }; assert_eq!(publisher.origin_host(), "192.168.1.1:8080"); @@ -1986,6 +2002,7 @@ mod tests { cookie_domain: ".example.com".to_string(), origin_url: "http://[::1]:8080".to_string(), proxy_secret: Redacted::new("test-secret".to_string()), + max_buffered_body_bytes: None, }; assert_eq!(publisher.origin_host(), "[::1]:8080"); } diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 5207a7e6..ed290f98 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -1,4 +1,6 @@ use core::str; +use std::sync::OnceLock; + use error_stack::{Report, ResultExt}; use validator::Validate; @@ -8,18 +10,37 @@ use crate::settings::Settings; pub use crate::auction_config_types::AuctionConfig; const SETTINGS_DATA: &[u8] = include_bytes!("../../../target/trusted-server-out.toml"); +static SETTINGS: OnceLock = OnceLock::new(); -/// Creates a new [`Settings`] instance from the embedded configuration file. +/// Returns the embedded [`Settings`], loading and validating them once per Wasm instance +/// and cloning the cached value on subsequent calls. /// -/// Loads the pre-built TOML that was generated by `build.rs` (base config -/// merged with any `TRUSTED_SERVER__` environment variable overrides at -/// build time). Environment variables are **not** read at runtime. +/// The first successful call parses the pre-built TOML generated by `build.rs` (base config +/// merged with any `TRUSTED_SERVER__` environment variable overrides at build time), +/// validates the result, and stores it in a [`OnceLock`]. Later calls return a clone of the +/// cached settings without re-running validation or emitting warning logs. +/// Environment variables are **not** read at runtime. /// /// # Errors /// /// - [`TrustedServerError::InvalidUtf8`] if the embedded TOML file contains invalid UTF-8 /// - [`TrustedServerError::Configuration`] if the configuration is invalid or missing required fields pub fn get_settings() -> Result> { + if let Some(settings) = SETTINGS.get() { + return Ok(settings.clone()); + } + + let settings = load_settings()?; + if SETTINGS.set(settings.clone()).is_err() { + if let Some(settings) = SETTINGS.get() { + return Ok(settings.clone()); + } + } + + Ok(settings) +} + +fn load_settings() -> Result> { let toml_bytes = SETTINGS_DATA; let toml_str = str::from_utf8(toml_bytes).change_context(TrustedServerError::InvalidUtf8 { message: "embedded trusted-server.toml file".to_string(), diff --git a/fastly.toml b/fastly.toml index 2ea512a6..dd58af16 100644 --- a/fastly.toml +++ b/fastly.toml @@ -55,6 +55,14 @@ build = """ env = "FASTLY_KEY" [local_server.config_stores] + [local_server.config_stores.trusted_server_config] + format = "inline-toml" + [local_server.config_stores.trusted_server_config.contents] + # "true" / "1" (case-insensitive) enable the EdgeZero path. Missing, + # unreadable, or any other value falls back to the legacy entry point. + # Keep "false" until EdgeZero reaches full functional parity with legacy. + edgezero_enabled = "false" + [local_server.config_stores.jwks_store] format = "inline-toml" [local_server.config_stores.jwks_store.contents] diff --git a/trusted-server.toml b/trusted-server.toml index 7f04e277..9c5865c7 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -13,6 +13,10 @@ domain = "test-publisher.com" cookie_domain = ".test-publisher.com" origin_url = "https://origin.test-publisher.com" proxy_secret = "change-me-proxy-secret" +# Maximum bytes buffered when processing a streaming publisher response on the EdgeZero path. +# Defaults to 16 MiB when omitted; responses exceeding the cap return 500. +# Raise it for deployments serving larger publisher pages: +# max_buffered_body_bytes = 16777216 # 16 MiB [ec] passphrase = "local-dev-passphrase-32-bytes-min"