diff --git a/crates/fakecloud-cloudfront/src/dataplane.rs b/crates/fakecloud-cloudfront/src/dataplane.rs index 9702e1f24..32d09e5eb 100644 --- a/crates/fakecloud-cloudfront/src/dataplane.rs +++ b/crates/fakecloud-cloudfront/src/dataplane.rs @@ -162,12 +162,20 @@ impl CloudFrontDataPlane { )); }; - let path_and_query = parts - .uri - .path_and_query() - .map(|p| p.as_str()) - .unwrap_or("/") - .to_string(); + // A root request is fetched as the distribution's DefaultRootObject; the + // query string is preserved, as CloudFront does. + let path_and_query = match &route.root_object { + Some(object) => match parts.uri.query() { + Some(q) => format!("{object}?{q}"), + None => object.clone(), + }, + None => parts + .uri + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/") + .to_string(), + }; let url = format!("{}{path_and_query}", route.upstream.url_base); trace!(%host, path = %parts.uri.path(), origin = %route.upstream.host_header, "CloudFront data plane: proxying"); let resp = self @@ -293,13 +301,17 @@ struct RouteResolution { default_upstream: UpstreamTarget, /// CustomErrorResponses that have a response page path. error_rules: Vec, + /// `DefaultRootObject` as a percent-encoded path (`/index.html`), set only when this + /// request is for the distribution root and the distribution configures one. + root_object: Option, } /// A resolved origin address: the scheme+authority to connect to and the `Host` /// header to send. #[derive(Clone)] struct UpstreamTarget { - /// `scheme://authority` (no trailing slash); the request path is appended. + /// `scheme://authority` plus the origin's `OriginPath` (no trailing slash); + /// the request path is appended. url_base: String, /// `Host` header sent upstream (the origin domain name). host_header: String, @@ -325,13 +337,13 @@ fn resolve_route( .origin .iter() .find(|o| o.id == target) - .map(|o| upstream_for(o, s3_endpoint))?; + .map(|o| origin_target(o, s3_endpoint))?; let default_target = cfg.default_cache_behavior.target_origin_id.as_str(); let default_upstream = items .origin .iter() .find(|o| o.id == default_target) - .map(|o| upstream_for(o, s3_endpoint)) + .map(|o| origin_target(o, s3_endpoint)) .unwrap_or_else(|| upstream.clone()); let error_rules = cfg .custom_error_responses @@ -350,13 +362,105 @@ fn resolve_route( .collect() }) .unwrap_or_default(); + let root_object = root_object_path(cfg, path); Some(RouteResolution { upstream, default_upstream, error_rules, + root_object, }) } +/// The origin path to fetch in place of the viewer's `path` when it names the +/// distribution root and a `DefaultRootObject` is configured. +/// +/// AWS applies the default root object to the root only: a subdirectory request +/// (`/about/`) is never rewritten to `/`, and `///` is not treated as +/// the root either (the origin makes that comparison). The cache behavior is still +/// selected on the original viewer path: the substitution happens after behavior +/// selection, which is why a viewer-request function on the matched behavior still +/// sees `/` as the URI. +/// +/// An empty value is how the API clears the setting, so it means "none". Any +/// other value is appended verbatim after `/`: AWS does not strip a leading +/// slash, so `/index.html` is requested as `//index.html` (which is why AWS warns +/// that such a value "can lead to a 403 Access Denied error"). +fn root_object_path(cfg: &DistributionConfig, path: &str) -> Option { + // `""` only arises for a request-target with no path at all, which HTTP + // treats as `/`. + if !matches!(path, "" | "/") { + return None; + } + cfg.default_root_object + .as_deref() + .filter(|o| !o.is_empty()) + .map(|o| format!("/{}", encode_path(o))) +} + +/// Resolve `origin` to its upstream and apply its `OriginPath`. +/// +/// CloudFront prefixes every request it sends to an origin (viewer requests, +/// the default root object, custom error pages) with that origin's +/// `OriginPath`: OriginPath `/prod` + viewer `/img/a.png` fetches +/// `/prod/img/a.png`. It is folded into `url_base`, so every path appended to +/// it is prefixed. AWS requires the value to start with `/` and not end with +/// one; since only the leading `/` is structurally required to keep the value +/// out of the URL authority, it is added if missing and trailing slashes are +/// dropped so `/prod/` cannot produce `/prod//img`. +fn origin_target(origin: &crate::model::Origin, s3_endpoint: &str) -> UpstreamTarget { + let mut target = upstream_for(origin, s3_endpoint); + let prefix = origin + .origin_path + .as_deref() + .map(|p| p.trim_end_matches('/')) + .unwrap_or_default(); + if !prefix.is_empty() { + if !prefix.starts_with('/') { + target.url_base.push('/'); + } + target.url_base.push_str(&encode_path(prefix)); + } + target +} + +/// Percent-encode a configured path (a `DefaultRootObject` or `OriginPath`) for +/// use as a URL path. The value names an object literally, so every byte that +/// is not a valid path-segment character (RFC 3986 `pchar`) is escaped -- +/// including `#`, `?`, space, `%` and non-ASCII -- while `/` is kept so folder +/// values like `app/index.html` still address that folder. +fn encode_path(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for b in raw.bytes() { + let keep = b.is_ascii_alphanumeric() + || matches!( + b, + b'/' | b'-' + | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ); + if keep { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + /// First custom-error rule whose error code matches the origin status. fn match_error_rule(rules: &[ErrorRule], status: u16) -> Option { rules.iter().find(|r| r.error_code == status).cloned() @@ -513,7 +617,9 @@ fn is_hop_by_hop(name: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::model::{AliasItems, Aliases, CustomOriginConfig, Origin}; + use crate::model::{ + AliasItems, Aliases, CustomOriginConfig, DefaultCacheBehavior, Origin, OriginItems, Origins, + }; use crate::state::StoredDistribution; use chrono::Utc; @@ -632,6 +738,181 @@ mod tests { assert_eq!(up.host_header, "b.s3-website-us-east-1.amazonaws.com"); } + fn cfg_with_root(root: Option<&str>) -> DistributionConfig { + DistributionConfig { + default_root_object: root.map(Into::into), + origins: Origins { + quantity: 1, + items: Some(OriginItems { + origin: vec![origin("b.s3.us-east-1.amazonaws.com", None)], + }), + }, + default_cache_behavior: DefaultCacheBehavior { + target_origin_id: "o".into(), + ..Default::default() + }, + ..Default::default() + } + } + + fn root_object_for(cfg: &DistributionConfig, path: &str) -> Option { + resolve_route(cfg, path, "127.0.0.1:4566") + .expect("route resolves") + .root_object + } + + #[test] + fn default_root_object_applies_to_the_distribution_root() { + let cfg = cfg_with_root(Some("index.html")); + assert_eq!(root_object_for(&cfg, "/").as_deref(), Some("/index.html")); + assert_eq!(root_object_for(&cfg, "").as_deref(), Some("/index.html")); + } + + #[test] + fn default_root_object_does_not_apply_below_the_root() { + // AWS serves the default root object for the distribution root ONLY; a + // subdirectory request is never rewritten to `/`, and extra + // slashes are left for the origin to interpret. + let cfg = cfg_with_root(Some("index.html")); + for path in ["/about/", "/about", "/index.html", "//", "///"] { + assert_eq!(root_object_for(&cfg, path), None, "{path}"); + } + } + + #[test] + fn default_root_object_unset_or_empty_leaves_the_root_alone() { + for root in [None, Some("")] { + assert_eq!(root_object_for(&cfg_with_root(root), "/"), None, "{root:?}"); + } + } + + #[test] + fn default_root_object_path_is_used_as_given() { + // A folder path is fetched from that folder. + let cfg = cfg_with_root(Some("app/index.html")); + assert_eq!( + root_object_for(&cfg, "/").as_deref(), + Some("/app/index.html") + ); + // AWS does not strip a leading slash: the origin is asked for + // `//index.html`, the documented cause of a 403 with such a value. + let cfg = cfg_with_root(Some("/index.html")); + assert_eq!(root_object_for(&cfg, "/").as_deref(), Some("//index.html")); + } + + #[test] + fn root_request_still_selects_the_cache_behavior_on_the_viewer_path() { + // A behavior for the object's own path does not capture the root request. + let mut cfg = cfg_with_root(Some("index.html")); + cfg.origins.items.as_mut().unwrap().origin.push(Origin { + id: "other".into(), + ..origin("other.example.com", None) + }); + cfg.cache_behaviors = Some(crate::model::CacheBehaviors { + quantity: 1, + items: Some(crate::model::CacheBehaviorItems { + cache_behavior: vec![crate::model::CacheBehavior { + path_pattern: "index.html".into(), + target_origin_id: "other".into(), + ..Default::default() + }], + }), + }); + let route = resolve_route(&cfg, "/", "127.0.0.1:4566").expect("route resolves"); + assert_eq!(route.root_object.as_deref(), Some("/index.html")); + assert_eq!(route.upstream.host_header, "b.s3.us-east-1.amazonaws.com"); + } + + #[test] + fn default_root_object_is_percent_encoded_for_the_origin_url() { + // The value names an object literally: `#` and `?` must not become a + // fragment or query, and space / `%` / non-ASCII must be escaped. + for (root, want) in [ + ("report#v2.html", "/report%23v2.html"), + ("a?b.html", "/a%3Fb.html"), + ("my page.html", "/my%20page.html"), + ("100%.html", "/100%25.html"), + ("caf\u{e9}.html", "/caf%C3%A9.html"), + ("app/index.html", "/app/index.html"), + ("a-b_c.~!$&'()*+,;=:@.html", "/a-b_c.~!$&'()*+,;=:@.html"), + ] { + let cfg = cfg_with_root(Some(root)); + assert_eq!(root_object_for(&cfg, "/").as_deref(), Some(want), "{root}"); + } + } + + fn cfg_with_origin_path(origin_path: Option<&str>, domain: &str) -> DistributionConfig { + let mut cfg = cfg_with_root(Some("index.html")); + let o = &mut cfg.origins.items.as_mut().unwrap().origin[0]; + o.domain_name = domain.into(); + o.origin_path = origin_path.map(Into::into); + cfg + } + + #[test] + fn origin_path_prefixes_the_upstream_url_for_every_origin_kind() { + for (domain, custom, base) in [ + ( + "site.s3-website-us-east-1.amazonaws.com", + None, + "http://127.0.0.1:4566", + ), + ( + "b.s3.us-east-1.amazonaws.com", + None, + "http://127.0.0.1:4566", + ), + ( + "api.example.com", + Some(custom("https-only", 80, 8443)), + "https://api.example.com:8443", + ), + ] { + let mut cfg = cfg_with_origin_path(Some("/prod"), domain); + cfg.origins.items.as_mut().unwrap().origin[0].custom_origin_config = custom; + let route = resolve_route(&cfg, "/img/a.png", "127.0.0.1:4566").unwrap(); + assert_eq!(route.upstream.url_base, format!("{base}/prod"), "{domain}"); + assert_eq!(route.upstream.host_header, domain); + // Custom error pages come from the default origin, prefixed too. + assert_eq!(route.default_upstream.url_base, format!("{base}/prod")); + } + } + + #[test] + fn origin_path_composes_with_the_default_root_object() { + let cfg = cfg_with_origin_path(Some("/prod"), "b.s3.us-east-1.amazonaws.com"); + let route = resolve_route(&cfg, "/", "127.0.0.1:4566").unwrap(); + let url = format!("{}{}", route.upstream.url_base, route.root_object.unwrap()); + assert_eq!(url, "http://127.0.0.1:4566/prod/index.html"); + } + + #[test] + fn origin_path_unset_or_empty_adds_nothing() { + for p in [None, Some(""), Some("/")] { + let cfg = cfg_with_origin_path(p, "b.s3.us-east-1.amazonaws.com"); + let route = resolve_route(&cfg, "/x", "127.0.0.1:4566").unwrap(); + assert_eq!(route.upstream.url_base, "http://127.0.0.1:4566", "{p:?}"); + } + } + + #[test] + fn origin_path_is_normalized_and_encoded() { + for (p, want) in [ + ("/prod/", "/prod"), + ("prod", "/prod"), + ("/v 1/a#b", "/v%201/a%23b"), + ("/a/b", "/a/b"), + ] { + let cfg = cfg_with_origin_path(Some(p), "b.s3.us-east-1.amazonaws.com"); + let route = resolve_route(&cfg, "/x", "127.0.0.1:4566").unwrap(); + assert_eq!( + route.upstream.url_base, + format!("http://127.0.0.1:4566{want}"), + "{p}" + ); + } + } + #[test] fn s3_rest_origin_routes_to_local_port() { // What an `S3OriginConfig` origin carries (CDK's `S3BucketOrigin` emits the diff --git a/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs b/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs index 086ed0da2..98c5cfc0a 100644 --- a/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs +++ b/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs @@ -69,6 +69,33 @@ pub async fn make_spa_distribution( .clone() } +/// Create a SPA distribution that also sets `DefaultRootObject`. +pub async fn make_spa_distribution_with_root_object( + cf: &aws_sdk_cloudfront::Client, + default_origin_domain: &str, + root_object: &str, +) -> aws_sdk_cloudfront::types::Distribution { + let mut config = spa_config( + default_origin_domain, + None, + &format!("spa-{}", uuid_like()), + true, + true, + &[], + ); + config.default_root_object = Some(root_object.to_string()); + let create = cf + .create_distribution() + .distribution_config(config) + .send() + .await + .expect("create_distribution"); + create + .distribution() + .expect("distribution returned") + .clone() +} + /// Build the SPA distribution config. Shared by create (enabled=true) and the /// disable-via-update path (enabled=false) so both use an identical shape; the /// `caller_reference` must be preserved across an UpdateDistribution. @@ -718,3 +745,160 @@ async fn api_traffic_is_not_intercepted() { .await .expect("s3 list_buckets must pass through the viewer middleware"); } + +/// Regression: `DefaultRootObject` was stored on the model but never applied, so a +/// viewer request for the distribution root reached the bucket root and returned +/// S3's `ListBucketResult` XML instead of the SPA shell. +#[tokio::test] +async fn serves_default_root_object_at_the_distribution_root() { + let server = TestServer::start().await; + let s3 = server.s3_client().await; + s3.create_bucket() + .bucket("rootsite") + .send() + .await + .expect("create_bucket"); + put_object(&s3, "rootsite", "index.html", "text/html", b"SHELL").await; + put_object(&s3, "rootsite", "nested/index.html", "text/html", b"NESTED").await; + + let cf = server.cloudfront_client().await; + let dist = make_spa_distribution_with_root_object( + &cf, + "rootsite.s3-website-us-east-1.amazonaws.com", + "index.html", + ) + .await; + assert!(wait_for_served(&server, dist.id(), Duration::from_secs(10)).await); + let host = dist.domain_name(); + + // The root serves the default root object, not a bucket listing. + let r = viewer_get(&server, host, "/").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "SHELL"); + + // The query string does not stop the root from being the root. + let r = viewer_get(&server, host, "/?v=1").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "SHELL"); + + // A subdirectory is NOT rewritten to `nested/index.html`: AWS applies the + // default root object to the distribution root only. Here the miss falls + // through to the SPA CustomErrorResponse rule instead. + let r = viewer_get(&server, host, "/nested/").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "SHELL"); + + // UpdateDistribution takes effect for the next request: a folder path is + // served from that folder. + let got = cf + .get_distribution_config() + .id(dist.id()) + .send() + .await + .expect("get_distribution_config"); + let etag = got.e_tag().expect("etag").to_string(); + let mut cfg = got.distribution_config().expect("config").clone(); + cfg.default_root_object = Some("nested/index.html".to_string()); + cf.update_distribution() + .id(dist.id()) + .if_match(etag) + .distribution_config(cfg) + .send() + .await + .expect("update_distribution"); + let r = viewer_get(&server, host, "/").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "NESTED"); +} + +/// Regression: `OriginPath` was stored on the model but never applied, so every +/// request reached the bucket root instead of the configured prefix. CloudFront +/// prefixes each origin request with it, including the default root object and +/// custom error pages. Also covers a `DefaultRootObject` that needs +/// percent-encoding (a raw `#` would otherwise truncate the origin path). +#[tokio::test] +async fn origin_path_prefixes_every_origin_request() { + let server = TestServer::start().await; + let s3 = server.s3_client().await; + s3.create_bucket() + .bucket("prefixed") + .send() + .await + .expect("create_bucket"); + put_object(&s3, "prefixed", "index.html", "text/html", b"UNPREFIXED").await; + put_object(&s3, "prefixed", "prod/index.html", "text/html", b"PROD").await; + put_object(&s3, "prefixed", "prod/img/a.png", "image/png", b"PNG").await; + put_object(&s3, "prefixed", "img/a.png", "image/png", b"ROOTPNG").await; + put_object( + &s3, + "prefixed", + "prod/report#v2.html", + "text/html", + b"REPORT", + ) + .await; + + let cf = server.cloudfront_client().await; + let mut config = spa_config( + "prefixed.s3-website-us-east-1.amazonaws.com", + None, + &format!("prefixed-{}", uuid_like()), + true, + true, + &[], + ); + config.default_root_object = Some("index.html".to_string()); + config + .origins + .as_mut() + .map(|o| &mut o.items) + .expect("origins")[0] + .origin_path = Some("/prod".to_string()); + let dist = cf + .create_distribution() + .distribution_config(config) + .send() + .await + .expect("create_distribution") + .distribution() + .expect("distribution returned") + .clone(); + assert!(wait_for_served(&server, dist.id(), Duration::from_secs(10)).await); + let host = dist.domain_name(); + + // A viewer path is fetched under the origin path. + let r = viewer_get(&server, host, "/img/a.png").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "PNG"); + + // The root is the default root object under the origin path. + let r = viewer_get(&server, host, "/").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "PROD"); + + // The custom error page (404 -> /index.html) is fetched under it too. + let r = viewer_get(&server, host, "/missing/route").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "PROD"); + + // A default root object with a `#` addresses that exact key. + let got = cf + .get_distribution_config() + .id(dist.id()) + .send() + .await + .expect("get_distribution_config"); + let etag = got.e_tag().expect("etag").to_string(); + let mut cfg = got.distribution_config().expect("config").clone(); + cfg.default_root_object = Some("report#v2.html".to_string()); + cf.update_distribution() + .id(dist.id()) + .if_match(etag) + .distribution_config(cfg) + .send() + .await + .expect("update_distribution"); + let r = viewer_get(&server, host, "/").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "REPORT"); +}