static_config.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. //! Integration tests for static SPA serving and `GET /api/config`.
  2. //!
  3. //! These drive the [`Router`] through `tower::ServiceExt::oneshot`: no ports
  4. //! are bound and no servers start. The SPA fixture and the config files live
  5. //! in a `tempfile` directory, never in the real `config/` or `dist/`.
  6. use std::collections::HashMap;
  7. use axum::body::Body;
  8. use axum::http::{Request, StatusCode};
  9. use server::app::build_app;
  10. use server::config::ServerConfig;
  11. use server::state::AppState;
  12. use tower::ServiceExt as _;
  13. /// Build an app backed by a temp dist dir (with `index.html` + one asset)
  14. /// and temp config files loaded through the real layered loader.
  15. fn fixture_app() -> (axum::Router, tempfile::TempDir) {
  16. let dir = tempfile::tempdir().expect("temp dir is creatable");
  17. let dist = dir.path().join("dist");
  18. std::fs::create_dir_all(dist.join("assets")).expect("fixture dirs are creatable");
  19. std::fs::write(
  20. dist.join("index.html"),
  21. "<html><body>spa-shell</body></html>",
  22. )
  23. .expect("fixture index is writable");
  24. std::fs::write(dist.join("assets/app.js"), "console.log('app');")
  25. .expect("fixture asset is writable");
  26. let config_json = format!(
  27. r#"{{
  28. "server": {{ "dist_dir": {} }},
  29. "oauth": {{
  30. "issuer": "https://as.example",
  31. "redirect_uri": "http://127.0.0.1:3000/callback",
  32. "scope": "demo.read",
  33. "capabilities": {{ "par": true, "dpop": true, "introspection": true }}
  34. }}
  35. }}"#,
  36. serde_json::to_string(&dist).expect("path serializes")
  37. );
  38. std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable");
  39. std::fs::write(
  40. dir.path().join("client.json"),
  41. r#"{"client_id": "demo-client", "client_secret": "FILE-S3CRET"}"#,
  42. )
  43. .expect("client fixture is writable");
  44. let empty: HashMap<&str, &str> = HashMap::new();
  45. let config = ServerConfig::load_from_paths_with(
  46. Some(&dir.path().join("config.json")),
  47. Some(&dir.path().join("client.json")),
  48. &|key| empty.get(key).map(|value| (*value).to_owned()),
  49. )
  50. .expect("fixture config loads");
  51. (build_app(AppState::new(config)), dir)
  52. }
  53. async fn get(
  54. app: axum::Router,
  55. uri: &str,
  56. ) -> Result<axum::http::Response<Body>, Box<dyn std::error::Error>> {
  57. Ok(
  58. app
  59. .oneshot(Request::builder().uri(uri).body(Body::empty())?)
  60. .await?,
  61. )
  62. }
  63. async fn body_text(
  64. response: axum::http::Response<Body>,
  65. ) -> Result<String, Box<dyn std::error::Error>> {
  66. let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
  67. Ok(std::str::from_utf8(&bytes)?.to_owned())
  68. }
  69. /// Build an app whose profile points at `issuer` with no static capabilities
  70. /// (all false), so any `true` flag in `/api/config` must come from discovery.
  71. fn discovery_fixture_app(
  72. issuer: &str,
  73. lookup: &dyn Fn(&str) -> Option<String>,
  74. ) -> (axum::Router, tempfile::TempDir) {
  75. let dir = tempfile::tempdir().expect("temp dir is creatable");
  76. let dist = dir.path().join("dist");
  77. std::fs::create_dir_all(&dist).expect("fixture dist is creatable");
  78. std::fs::write(
  79. dist.join("index.html"),
  80. "<html><body>spa-shell</body></html>",
  81. )
  82. .expect("fixture index is writable");
  83. let config_json = format!(
  84. r#"{{
  85. "server": {{ "dist_dir": {} }},
  86. "oauth": {{
  87. "issuer": "{issuer}",
  88. "redirect_uri": "http://127.0.0.1:3000/callback",
  89. "scope": "demo.read"
  90. }}
  91. }}"#,
  92. serde_json::to_string(&dist).expect("path serializes")
  93. );
  94. std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable");
  95. std::fs::write(
  96. dir.path().join("client.json"),
  97. r#"{"client_id": "demo-client", "client_secret": "FILE-S3CRET"}"#,
  98. )
  99. .expect("client fixture is writable");
  100. let config = ServerConfig::load_from_paths_with(
  101. Some(&dir.path().join("config.json")),
  102. Some(&dir.path().join("client.json")),
  103. lookup,
  104. )
  105. .expect("fixture config loads");
  106. (
  107. build_app(AppState::with_client(config, reqwest::Client::new())),
  108. dir,
  109. )
  110. }
  111. #[tokio::test]
  112. async fn root_serves_index_html_with_static_csp() -> Result<(), Box<dyn std::error::Error>> {
  113. let (app, _dir) = fixture_app();
  114. let response = get(app, "/").await?;
  115. assert_eq!(response.status(), StatusCode::OK);
  116. let csp = response
  117. .headers()
  118. .get("content-security-policy")
  119. .and_then(|value| value.to_str().ok())
  120. .unwrap_or_default()
  121. .to_owned();
  122. assert!(csp.contains("script-src 'self'"), "unexpected CSP: {csp}");
  123. assert!(
  124. csp.contains("'wasm-unsafe-eval'"),
  125. "WASM must be allowed for the Leptos app to boot: {csp}"
  126. );
  127. assert!(
  128. !csp.contains("unsafe-inline"),
  129. "inline scripts forbidden: {csp}"
  130. );
  131. assert!(
  132. !csp.contains("'unsafe-eval'"),
  133. "full eval forbidden (wasm-unsafe-eval only): {csp}"
  134. );
  135. assert_eq!(
  136. response
  137. .headers()
  138. .get("x-content-type-options")
  139. .and_then(|value| value.to_str().ok()),
  140. Some("nosniff")
  141. );
  142. let body = body_text(response).await?;
  143. assert!(body.contains("spa-shell"), "unexpected body: {body:?}");
  144. Ok(())
  145. }
  146. #[tokio::test]
  147. async fn missing_asset_is_404_not_fallback() -> Result<(), Box<dyn std::error::Error>> {
  148. let (app, _dir) = fixture_app();
  149. let response = get(app, "/assets/missing.js").await?;
  150. assert_eq!(response.status(), StatusCode::NOT_FOUND);
  151. Ok(())
  152. }
  153. #[tokio::test]
  154. async fn existing_asset_is_served() -> Result<(), Box<dyn std::error::Error>> {
  155. let (app, _dir) = fixture_app();
  156. let response = get(app, "/assets/app.js").await?;
  157. assert_eq!(response.status(), StatusCode::OK);
  158. let content_type = response
  159. .headers()
  160. .get("content-type")
  161. .and_then(|value| value.to_str().ok())
  162. .unwrap_or_default()
  163. .to_owned();
  164. assert!(
  165. content_type.contains("javascript"),
  166. "unexpected MIME: {content_type}"
  167. );
  168. Ok(())
  169. }
  170. #[tokio::test]
  171. async fn client_route_spa_falls_back_to_index() -> Result<(), Box<dyn std::error::Error>> {
  172. let (app, _dir) = fixture_app();
  173. let response = get(app, "/dashboard").await?;
  174. assert_eq!(response.status(), StatusCode::OK);
  175. let body = body_text(response).await?;
  176. assert!(body.contains("spa-shell"), "unexpected body: {body:?}");
  177. Ok(())
  178. }
  179. #[tokio::test]
  180. async fn api_config_returns_public_display_config_only() -> Result<(), Box<dyn std::error::Error>> {
  181. let (app, _dir) = fixture_app();
  182. let response = get(app, "/api/config").await?;
  183. assert_eq!(response.status(), StatusCode::OK);
  184. let csp = response
  185. .headers()
  186. .get("content-security-policy")
  187. .and_then(|value| value.to_str().ok())
  188. .unwrap_or_default()
  189. .to_owned();
  190. assert!(
  191. csp.contains("frame-ancestors 'none'"),
  192. "unexpected API CSP: {csp}"
  193. );
  194. assert_eq!(
  195. response
  196. .headers()
  197. .get("x-content-type-options")
  198. .and_then(|value| value.to_str().ok()),
  199. Some("nosniff")
  200. );
  201. assert_eq!(
  202. response
  203. .headers()
  204. .get("referrer-policy")
  205. .and_then(|value| value.to_str().ok()),
  206. Some("same-origin")
  207. );
  208. let body = body_text(response).await?;
  209. let json: serde_json::Value = serde_json::from_str(&body)?;
  210. assert_eq!(json["issuer"], "https://as.example");
  211. assert_eq!(json["client_id"], "demo-client");
  212. assert_eq!(json["scope"], "demo.read");
  213. assert_eq!(json["provider_logout_url"], "https://as.example/logout");
  214. assert_eq!(json["capabilities"]["par"], true);
  215. assert_eq!(json["capabilities"]["userinfo"], false);
  216. for forbidden in ["S3CRET", "client_secret", "dpop_key", "private_key"] {
  217. assert!(!body.contains(forbidden), "leak of {forbidden} in {body}");
  218. }
  219. Ok(())
  220. }
  221. #[tokio::test]
  222. async fn api_config_derives_capabilities_from_discovery() -> Result<(), Box<dyn std::error::Error>>
  223. {
  224. use wiremock::{
  225. Mock, MockServer, ResponseTemplate,
  226. matchers::{method, path},
  227. };
  228. let fake = MockServer::start().await;
  229. let issuer = fake.uri();
  230. let doc = serde_json::json!({
  231. "issuer": issuer,
  232. "authorization_endpoint": format!("{issuer}/authorize"),
  233. "token_endpoint": format!("{issuer}/token"),
  234. "pushed_authorization_request_endpoint": format!("{issuer}/par"),
  235. "introspection_endpoint": format!("{issuer}/introspect"),
  236. "registration_endpoint": format!("{issuer}/register"),
  237. "grant_types_supported": ["authorization_code", "refresh_token"],
  238. "dpop_signing_alg_values_supported": ["ES256"],
  239. });
  240. Mock::given(method("GET"))
  241. .and(path("/.well-known/oauth-authorization-server"))
  242. .respond_with(ResponseTemplate::new(200).set_body_json(&doc))
  243. .mount(&fake)
  244. .await;
  245. let (app, _dir) = discovery_fixture_app(&issuer, &|_| None);
  246. let response = get(app, "/api/config").await?;
  247. assert_eq!(response.status(), StatusCode::OK);
  248. let body = body_text(response).await?;
  249. let json: serde_json::Value = serde_json::from_str(&body)?;
  250. assert_eq!(json["capabilities"]["par"], true);
  251. assert_eq!(json["capabilities"]["dpop"], true);
  252. assert_eq!(json["capabilities"]["introspection"], true);
  253. assert_eq!(json["capabilities"]["userinfo"], false);
  254. assert_eq!(json["start_url"], "/oauth/start");
  255. assert_eq!(json["provider_logout_url"], format!("{issuer}/logout"));
  256. for forbidden in ["S3CRET", "client_secret", "dpop_key", "private_key"] {
  257. assert!(!body.contains(forbidden), "leak of {forbidden} in {body}");
  258. }
  259. Ok(())
  260. }
  261. #[tokio::test]
  262. async fn api_config_falls_back_to_profile_caps_when_discovery_is_down()
  263. -> Result<(), Box<dyn std::error::Error>> {
  264. let lookup = |key: &str| {
  265. (key == server::config::START_URL_ENV).then(|| "http://127.0.0.1:3001/oauth/start".to_owned())
  266. };
  267. let (app, _dir) = discovery_fixture_app("http://127.0.0.1:9", &lookup);
  268. let response = get(app, "/api/config").await?;
  269. assert_eq!(response.status(), StatusCode::OK);
  270. let body = body_text(response).await?;
  271. let json: serde_json::Value = serde_json::from_str(&body)?;
  272. assert_eq!(json["capabilities"]["par"], false);
  273. assert_eq!(json["capabilities"]["dpop"], false);
  274. assert_eq!(json["capabilities"]["introspection"], false);
  275. assert_eq!(json["start_url"], "http://127.0.0.1:3001/oauth/start");
  276. Ok(())
  277. }
  278. #[tokio::test]
  279. async fn path_traversal_does_not_escape_dist_root() -> Result<(), Box<dyn std::error::Error>> {
  280. let (app, _dir) = fixture_app();
  281. let response = get(app, "/../etc/passwd").await?;
  282. // The stack normalizes `/../etc/passwd` to the extensionless `/etc/passwd`,
  283. // which hits the SPA fallback: safe as long as the body is the app shell
  284. // and never the target file.
  285. let body = body_text(response).await?;
  286. assert!(!body.contains("root:"), "traversal escaped dist root");
  287. assert!(body.contains("spa-shell"), "unexpected body: {body:?}");
  288. let (app, _dir) = fixture_app();
  289. let encoded = get(app, "/assets/%2e%2e/%2e%2e/etc/passwd").await?;
  290. // The fallback only ever serves the app shell, so an encoded traversal
  291. // cannot escape either; assert the body, not the status.
  292. let encoded_body = body_text(encoded).await?;
  293. assert!(!encoded_body.contains("root:"), "encoded traversal escaped");
  294. assert!(
  295. encoded_body.contains("spa-shell"),
  296. "unexpected body: {encoded_body:?}"
  297. );
  298. Ok(())
  299. }