tests.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. //! `test(params)` suite: strict urlencoded parsing, repeated-value capture,
  2. //! transport-contract preservation, and fail-closed resource validation.
  3. use super::{
  4. MAX_FORM_PAIRS, MAX_RESOURCE_VALUES, RESOURCE_PARAM, form_pairs, has_duplicate_field,
  5. parse_pairs, query_pairs, require_form_content_type, single_valued_map, validate_resources,
  6. values_of,
  7. };
  8. use crate::{db::resource_repo, error::OAuthErrorCode};
  9. /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
  10. type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
  11. /// Repeated `resource` values are captured in order.
  12. #[test]
  13. fn params_repeated_resource_is_captured_in_order() -> TestResult {
  14. let raw = "client_id=web&resource=https%3A%2F%2Fapi.example.test%2Fa&scope=read&resource=https%3A%2F%2Fapi.example.test%2Fb";
  15. let pairs = parse_pairs(raw)?;
  16. let values = values_of(&pairs, RESOURCE_PARAM);
  17. if values
  18. != vec![
  19. "https://api.example.test/a".to_owned(),
  20. "https://api.example.test/b".to_owned(),
  21. ]
  22. {
  23. return Err(format!("repeated resource order lost: {values:?}").into());
  24. }
  25. Ok(())
  26. }
  27. /// A body with more than `MAX_FORM_PAIRS` pairs is rejected, bounding the
  28. /// ~170 MB amplification of a 2 MiB repeated-pair body.
  29. #[test]
  30. fn params_form_pair_cap_rejects_oversized_body() -> TestResult {
  31. let mut raw = String::new();
  32. for index in 0..=MAX_FORM_PAIRS {
  33. if index > 0 {
  34. raw.push('&');
  35. }
  36. raw.push_str("a=1");
  37. }
  38. match parse_pairs(&raw) {
  39. Err(_) => Ok(()),
  40. Ok(_) => Err("oversized pair body was silently accepted".into()),
  41. }
  42. }
  43. /// More than `MAX_RESOURCE_VALUES` raw values are `invalid_target`.
  44. #[tokio::test]
  45. async fn params_resource_value_cap_rejects_oversized_set() -> TestResult {
  46. let (_db, pool) = crate::db::test_support::migrated_pool().await?;
  47. let values: Vec<String> = (0..=MAX_RESOURCE_VALUES)
  48. .map(|index| format!("https://api.example.test/{index}"))
  49. .collect();
  50. match validate_resources(&pool, true, &values).await {
  51. Err(error) if error.code() == OAuthErrorCode::InvalidTarget => Ok(()),
  52. Err(error) => Err(format!("wrong code for oversized resource set: {}", error.code()).into()),
  53. Ok(_) => Err("oversized resource set was accepted".into()),
  54. }
  55. }
  56. /// Duplicate resource values collapse to one sorted output with a single
  57. /// catalog lookup per distinct value.
  58. #[tokio::test]
  59. async fn params_duplicate_resources_deduped_before_catalog() -> TestResult {
  60. let (_db, pool) = crate::db::test_support::migrated_pool().await?;
  61. resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
  62. resource_repo::upsert(&pool, "https://a.example.test/", "a").await?;
  63. let aud = validate_resources(
  64. &pool,
  65. true,
  66. &[
  67. "https://b.example.test/".to_owned(),
  68. "https://a.example.test/".to_owned(),
  69. "https://b.example.test/".to_owned(),
  70. "https://a.example.test/".to_owned(),
  71. ],
  72. )
  73. .await?;
  74. if aud
  75. != vec![
  76. "https://a.example.test/".to_owned(),
  77. "https://b.example.test/".to_owned(),
  78. ]
  79. {
  80. return Err(format!("duplicates must collapse to one sorted output: {aud:?}").into());
  81. }
  82. Ok(())
  83. }
  84. /// A `%00`/NUL resource is `invalid_target`, never a database error or 500.
  85. #[tokio::test]
  86. async fn params_nul_resource_is_invalid_target() -> TestResult {
  87. let (_db, pool) = crate::db::test_support::migrated_pool().await?;
  88. for value in [
  89. "https://api.example.test/\0",
  90. "https://api.example.test/\x01x",
  91. ] {
  92. match validate_resources(&pool, true, &[value.to_owned()]).await {
  93. Err(error) if error.code() == OAuthErrorCode::InvalidTarget => {}
  94. Err(error) => {
  95. return Err(format!("wrong code for control-char resource: {}", error.code()).into());
  96. }
  97. Ok(_) => return Err(format!("control-char resource accepted: {value:?}").into()),
  98. }
  99. }
  100. Ok(())
  101. }
  102. /// Repeated-resource capture from pre-parsed pairs matches the old behavior:
  103. /// wire order, `+` as space, invalid `%` literal, bare name empty, empty
  104. /// segments skipped, `;` not a separator.
  105. #[test]
  106. fn params_values_of_matches_wire_order() -> TestResult {
  107. let pairs = parse_pairs(
  108. "resource=a+b&resource=%ZZ&empty&resource=x%3By&;resource=semi&other=1&resource=last",
  109. )?;
  110. let values = values_of(&pairs, RESOURCE_PARAM);
  111. if values
  112. != vec![
  113. "a b".to_owned(),
  114. "%ZZ".to_owned(),
  115. "x;y".to_owned(),
  116. "last".to_owned(),
  117. ]
  118. {
  119. return Err(format!("values_of wire order broken: {values:?}").into());
  120. }
  121. if !values_of(&pairs, "missing").is_empty() {
  122. return Err("unknown name must yield empty".into());
  123. }
  124. Ok(())
  125. }
  126. /// The single-valued map is last-wins: duplicates are silently absorbed.
  127. #[test]
  128. fn params_single_valued_map_absorbs_duplicates_last_wins() -> TestResult {
  129. let pairs = parse_pairs("resource=https%3A%2F%2Fa.example&resource=https%3A%2F%2Fb.example")?;
  130. let map = single_valued_map(&pairs);
  131. if map.get("resource").map(String::as_str) != Some("https://b.example") {
  132. return Err(format!("last-wins absorption broken: {map:?}").into());
  133. }
  134. Ok(())
  135. }
  136. /// Misencoded input decodes lossily, mirroring `serde_urlencoded`: a `%`
  137. /// not followed by two hex digits stays a literal `%` (`%ZZ`, trailing `%`,
  138. /// short `%2`), and non-UTF-8 bytes decode with replacement (`%FF`).
  139. /// Nothing here fails the parse; odd encodings reach typed validation
  140. /// downstream (`unsupported_grant_type`, `invalid_scope`, ...).
  141. #[test]
  142. fn params_malformed_input_fails_strict_parse() -> TestResult {
  143. let map = single_valued_map(&parse_pairs("a=%ZZ&b=%&c=%2&d=%FF")?);
  144. if map.get("a").map(String::as_str) != Some("%ZZ") {
  145. return Err(format!("bare % must stay literal: {map:?}").into());
  146. }
  147. if map.get("b").map(String::as_str) != Some("%") {
  148. return Err(format!("trailing % must stay literal: {map:?}").into());
  149. }
  150. if map.get("c").map(String::as_str) != Some("%2") {
  151. return Err(format!("short % must stay literal: {map:?}").into());
  152. }
  153. if map.get("d").map(String::as_str) != Some("�") {
  154. return Err(format!("non-UTF-8 must decode lossily: {map:?}").into());
  155. }
  156. if parse_pairs("a=ok&b=%41").is_err() {
  157. return Err("well-formed input rejected".into());
  158. }
  159. Ok(())
  160. }
  161. /// `+` decodes to a space and a bare key carries an empty value.
  162. #[test]
  163. fn params_plus_and_bare_key_decode() -> TestResult {
  164. let pairs = parse_pairs("scope=orders+read&flag")?;
  165. let map = single_valued_map(&pairs);
  166. if map.get("scope").map(String::as_str) != Some("orders read") {
  167. return Err(format!("plus decoding broken: {map:?}").into());
  168. }
  169. if map.get("flag").map(String::as_str) != Some("") {
  170. return Err(format!("bare key must carry an empty value: {map:?}").into());
  171. }
  172. Ok(())
  173. }
  174. /// Only repeated KNOWN fields count as duplicates; repeated `resource`
  175. /// (unknown to every struct form) is silently absorbed, never rejected.
  176. #[test]
  177. fn params_duplicate_detection_ignores_unknown_resource() -> TestResult {
  178. let known = ["grant_type", "code"];
  179. let pairs = parse_pairs(
  180. "grant_type=code&resource=https%3A%2F%2Fa.example&resource=https%3A%2F%2Fb.example",
  181. )?;
  182. if has_duplicate_field(&pairs, &known) {
  183. return Err("repeated unknown resource must not count as a duplicate".into());
  184. }
  185. let pairs = parse_pairs("grant_type=code&grant_type=refresh")?;
  186. if !has_duplicate_field(&pairs, &known) {
  187. return Err("repeated known field must count as a duplicate".into());
  188. }
  189. Ok(())
  190. }
  191. /// The content-type gate preserves axum's 415 contract for `Bytes` bodies.
  192. #[test]
  193. fn params_content_type_gate_preserves_415() -> TestResult {
  194. use axum::http::{HeaderMap, header};
  195. let mut headers = HeaderMap::new();
  196. if require_form_content_type(&headers).is_ok() {
  197. return Err("missing content-type must be 415".into());
  198. }
  199. headers.insert(header::CONTENT_TYPE, "application/json".parse()?);
  200. if require_form_content_type(&headers).is_ok() {
  201. return Err("json content-type must be 415".into());
  202. }
  203. headers.insert(
  204. header::CONTENT_TYPE,
  205. "application/x-www-form-urlencoded; charset=UTF-8".parse()?,
  206. );
  207. require_form_content_type(&headers).map_err(|_| "suffixed urlencoded must pass")?;
  208. Ok(())
  209. }
  210. /// Only structurally unreadable bodies map to 400 (the `Form` extractor's
  211. /// class): raw non-UTF-8 bytes. Percent-misencodings (`%ZZ`, `%FF`)
  212. /// decode lossily and reach typed validation downstream, never the parse.
  213. #[test]
  214. fn params_malformed_body_is_400_not_silent() -> TestResult {
  215. use axum::http::StatusCode;
  216. let (status, _) = match form_pairs(b"a=\xFF") {
  217. Ok(_) => return Err("non-UTF-8 body was silently accepted".into()),
  218. Err(reject) => reject,
  219. };
  220. if status != StatusCode::BAD_REQUEST {
  221. return Err(format!("non-UTF-8 body must be 400, got {status}").into());
  222. }
  223. if form_pairs(b"grant_type=%ZZ").is_err()
  224. || form_pairs(b"grant_type=%E0%A4%A").is_err()
  225. || query_pairs(Some("grant_type=%ZZ")).is_err()
  226. {
  227. return Err("misencoded input must decode lossily, not fail the parse".into());
  228. }
  229. if form_pairs(b"grant_type=code").is_err() || query_pairs(None).is_err() {
  230. return Err("well-formed input must parse".into());
  231. }
  232. Ok(())
  233. }
  234. /// Flag off + `resource` present is `invalid_target` (fail-closed, no drop).
  235. #[tokio::test]
  236. async fn params_flag_off_with_resource_is_invalid_target() -> TestResult {
  237. let (_db, pool) = crate::db::test_support::migrated_pool().await?;
  238. match validate_resources(&pool, false, &["https://api.example.test".to_owned()]).await {
  239. Err(error) if error.code() == OAuthErrorCode::InvalidTarget => Ok(()),
  240. Err(error) => Err(format!("wrong code for flag-off resource: {}", error.code()).into()),
  241. Ok(_) => Err("flag-off resource was silently accepted".into()),
  242. }
  243. }
  244. /// Empty input pins an empty set without touching the catalog.
  245. #[tokio::test]
  246. async fn params_empty_input_pins_empty_without_catalog() -> TestResult {
  247. let (_db, pool) = crate::db::test_support::migrated_pool().await?;
  248. let aud = validate_resources(&pool, true, &[]).await?;
  249. if !aud.is_empty() {
  250. return Err(format!("empty input must pin empty, got {aud:?}").into());
  251. }
  252. Ok(())
  253. }
  254. /// `http:` and relative values are `invalid_target` (no loopback exception).
  255. #[tokio::test]
  256. async fn params_http_and_relative_resources_are_invalid_target() -> TestResult {
  257. let (_db, pool) = crate::db::test_support::migrated_pool().await?;
  258. for value in [
  259. "http://api.example.test/",
  260. "http://localhost:8080/",
  261. "https://127.0.0.1/",
  262. "//api.example.test/",
  263. "/relative/path",
  264. "not-a-uri",
  265. ] {
  266. match validate_resources(&pool, true, &[value.to_owned()]).await {
  267. Err(error) if error.code() == OAuthErrorCode::InvalidTarget => {}
  268. Err(error) => {
  269. return Err(format!("wrong code for {value}: {}", error.code()).into());
  270. }
  271. Ok(_) => return Err(format!("non-https resource accepted: {value}").into()),
  272. }
  273. }
  274. Ok(())
  275. }
  276. /// Userinfo, a query, and a fragment are rejected syntactically before any
  277. /// catalog lookup: even an exact matching row cannot make them acceptable.
  278. #[tokio::test]
  279. async fn params_resources_reject_userinfo_query_fragment() -> TestResult {
  280. let (_db, pool) = crate::db::test_support::migrated_pool().await?;
  281. for value in [
  282. "https://user@api.example.test/path",
  283. "https://user:pass@api.example.test/path",
  284. "https://api.example.test/path?audience=x",
  285. "https://api.example.test/path#frag",
  286. ] {
  287. // Seed the exact invalid shape: the syntactic gate must reject it before
  288. // `resource_repo::find_enabled` could ever match this row.
  289. resource_repo::upsert(&pool, value, "invalid shape").await?;
  290. match validate_resources(&pool, true, &[value.to_owned()]).await {
  291. Err(error) if error.code() == OAuthErrorCode::InvalidTarget => {}
  292. Err(error) => {
  293. return Err(format!("wrong code for {value}: {}", error.code()).into());
  294. }
  295. Ok(_) => {
  296. return Err(format!("resource with userinfo/query/fragment accepted: {value}").into());
  297. }
  298. }
  299. }
  300. Ok(())
  301. }
  302. /// A clean absolute `https` resource with a path passes and pins.
  303. #[tokio::test]
  304. async fn params_valid_resource_with_path_passes() -> TestResult {
  305. let (_db, pool) = crate::db::test_support::migrated_pool().await?;
  306. resource_repo::upsert(&pool, "https://api.example.test/path", "api").await?;
  307. let aud = validate_resources(&pool, true, &["https://api.example.test/path".to_owned()]).await?;
  308. if aud != vec!["https://api.example.test/path".to_owned()] {
  309. return Err(format!("clean resource must pass unchanged: {aud:?}").into());
  310. }
  311. Ok(())
  312. }
  313. /// Unknown and disabled catalog entries are `invalid_target`.
  314. #[tokio::test]
  315. async fn params_unknown_and_disabled_resources_are_invalid_target() -> TestResult {
  316. let (_db, pool) = crate::db::test_support::migrated_pool().await?;
  317. match validate_resources(&pool, true, &["https://ghost.example.test/".to_owned()]).await {
  318. Err(error) if error.code() == OAuthErrorCode::InvalidTarget => {}
  319. Err(error) => {
  320. return Err(format!("wrong code for unknown resource: {}", error.code()).into());
  321. }
  322. Ok(_) => return Err("unknown resource was accepted".into()),
  323. }
  324. resource_repo::upsert(&pool, "https://api.example.test/", "test api").await?;
  325. resource_repo::disable(&pool, "https://api.example.test/").await?;
  326. match validate_resources(&pool, true, &["https://api.example.test/".to_owned()]).await {
  327. Err(error) if error.code() == OAuthErrorCode::InvalidTarget => Ok(()),
  328. Err(error) => Err(format!("wrong code for disabled resource: {}", error.code()).into()),
  329. Ok(_) => Err("disabled resource was accepted".into()),
  330. }
  331. }
  332. /// Two catalog resources validate and pin as a sorted deduped set.
  333. #[tokio::test]
  334. async fn params_two_catalog_resources_pin_sorted_deduped() -> TestResult {
  335. let (_db, pool) = crate::db::test_support::migrated_pool().await?;
  336. resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
  337. resource_repo::upsert(&pool, "https://a.example.test/", "a").await?;
  338. let aud = validate_resources(
  339. &pool,
  340. true,
  341. &[
  342. "https://b.example.test/".to_owned(),
  343. "https://a.example.test/".to_owned(),
  344. "https://b.example.test/".to_owned(),
  345. ],
  346. )
  347. .await?;
  348. if aud
  349. != vec![
  350. "https://a.example.test/".to_owned(),
  351. "https://b.example.test/".to_owned(),
  352. ]
  353. {
  354. return Err(format!("sorted deduped set broken: {aud:?}").into());
  355. }
  356. Ok(())
  357. }