hecks 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  3. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  5. data/lib/hecks/cache_dir.rb +10 -0
  6. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  7. data/lib/hecks/deploy/oidc.json +10 -0
  8. data/lib/hecks/doors/usage_cache.rb +19 -9
  9. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  10. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  11. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  12. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  13. data/lib/hecks/fuzzing/form_census.rb +28 -52
  14. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  15. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  16. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  17. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  18. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  19. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  20. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  21. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  22. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  23. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  24. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  25. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  26. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  27. data/lib/hecks/fuzzing/mutation.rb +12 -0
  28. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  29. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  30. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  31. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  32. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  33. data/lib/hecks/fuzzing/properties.rb +7 -0
  34. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  35. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  36. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  37. data/lib/hecks/fuzzing/replay.rb +1 -0
  38. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  39. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  40. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  41. data/lib/hecks/fuzzing.rb +3 -0
  42. data/lib/hecks/gate/stages.yml +1 -1
  43. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  44. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  45. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  46. data/lib/hecks/hecks/custodian.behaviors +6 -0
  47. data/lib/hecks/hecks/custodian.bluebook +37 -0
  48. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +3 -0
  50. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  51. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  52. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  53. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  54. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  55. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  56. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  57. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  58. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  59. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  60. data/lib/hecks/projections/deploy/box.rb +14 -2
  61. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  62. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  63. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  64. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  65. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  66. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  67. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  68. data/lib/hecks/projections.rb +2 -0
  69. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  70. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  71. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  72. data/lib/hecks/rust_build/native_build.rb +29 -1
  73. data/lib/hecks/rust_build/wasm.rb +16 -3
  74. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  75. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  76. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  77. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  78. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  79. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  80. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  81. data/lib/hecks/tools/lanes/files.rb +2 -4
  82. data/lib/hecks/tools/lanes/live.rb +3 -4
  83. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  84. data/lib/hecks/tools/lanes.rb +12 -1
  85. data/lib/hecks/tools/mutation_run.rb +73 -0
  86. data/lib/hecks/tools.rb +1 -0
  87. data/lib/hecks/version.rb +1 -1
  88. data/lib/hecks/vocabulary.rb +2 -0
  89. data/prebuilt/945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal +0 -0
  90. data/prebuilt/a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal +0 -0
  91. data/prebuilt/verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json +1 -0
  92. data/qa/settings.yml +1 -0
  93. data/rust/host/Cargo.toml +2 -2
  94. data/rust/host/HECKS_RELEASE +1 -1
  95. data/rust/host/src/api.rs +28 -5
  96. data/rust/host/src/auth.rs +11 -1
  97. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  98. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  99. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  100. data/rust/host/src/boundary_fuzz/ir.rs +78 -0
  101. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  102. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  103. data/rust/host/src/fuzz_support.rs +165 -0
  104. data/rust/host/src/ir.rs +4 -0
  105. data/rust/host/src/main.rs +9 -4
  106. data/rust/host/src/secrets.rs +44 -0
  107. data/rust/host/src/server.rs +6 -1
  108. data/rust/host/src/web.rs +12 -2
  109. data/rust/parser/fuzz/.gitignore +4 -0
  110. data/rust/parser/fuzz/Cargo.lock +89 -0
  111. data/rust/parser/fuzz/Cargo.toml +27 -0
  112. data/rust/parser/fuzz/README.md +19 -0
  113. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  114. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  115. data/rust/parser/src/parse/command.rs +14 -2
  116. data/rust/parser/src/parse/mod.rs +25 -0
  117. data/rust/parser/src/ruby_value.rs +95 -6
  118. data/rust/src/kernel/json.rs +34 -3
  119. metadata +50 -1
@@ -0,0 +1,78 @@
1
+ //! Seeded fuzz of the IR sidecar readers: `ir.json` is generated, but it is read from disk at
2
+ //! boot, so a stale, hand-edited or truncated file must come back as `None` or a refusal, never
3
+ //! a panic.
4
+
5
+ use super::*;
6
+ use crate::fuzz_support::*;
7
+ use serde_json::json;
8
+
9
+ const KEYS: &[&str] = &[
10
+ "name", "aggregates", "lineage", "capable_aggregates", "storage_name", "persistence", "adapter", "authorization", "grant", "assignment_aggregate", "membership", "provider", "aggregate",
11
+ "identity", "register", "link", "resolve", "newsletter", "subscribe", "add_name", "confirm", "unsubscribe", "newsletter_issues", "issue_aggregate", "schedule", "send_issue", "record_delivery",
12
+ "payments", "initiate", "succeeded", "failed", "event_aggregate", "registrations", "registration_aggregate", "request", "payment_connection", "commands", "attributes",
13
+ ];
14
+
15
+ /// An object over the keys the readers look up, with each value the right shape, the wrong
16
+ /// shape, or missing: the mix that finds a reader trusting its input.
17
+ fn random_ir(rng: &mut Rng, depth: usize) -> Value {
18
+ let mut fields = serde_json::Map::new();
19
+ for key in KEYS {
20
+ if rng.chance(3) {
21
+ continue;
22
+ }
23
+ let value = match rng.below(5) {
24
+ 0 if depth < 2 => random_ir(rng, depth + 1),
25
+ 1 => Value::String(random_text(rng)),
26
+ 2 => Value::Array((0..rng.below(2)).map(|_| if depth < 2 { random_ir(rng, depth + 1) } else { Value::Null }).collect()),
27
+ 3 => json!(*rng.pick(&["Postgres", "PostgresEra", "Heki", "Memory", ""])),
28
+ _ => random_value(rng, 2),
29
+ };
30
+ fields.insert(key.to_string(), value);
31
+ }
32
+ Value::Object(fields)
33
+ }
34
+
35
+ #[test]
36
+ fn every_provider_reader_answers_any_ir_shape() {
37
+ for seed in 0..seeds() {
38
+ guarded(&format!("seed {seed}"), move || {
39
+ let mut rng = Rng::new(seed);
40
+ for _ in 0..40 {
41
+ let ir = if rng.chance(8) { random_value(&mut rng, 0) } else { random_ir(&mut rng, 0) };
42
+ let _ = lineage_capable_aggregates(&ir);
43
+ let _ = persistence_adapters(&ir);
44
+ let _ = mirrored_aggregates(&ir);
45
+ let _ = authorization_provider(&ir);
46
+ let _ = membership_provider(&ir);
47
+ let _ = identity_provider(&ir);
48
+ let _ = command_declares(&ir, &random_text(&mut rng), &random_text(&mut rng), &random_text(&mut rng));
49
+ let _ = newsletter_provider(&ir).map(|p| p.instance_prefix());
50
+ let _ = newsletter_issues_provider(&ir).map(|p| p.issue_prefix());
51
+ let _ = payments_provider(&ir).map(|p| p.instance_prefix());
52
+ if let Some(p) = registrations_provider(&ir) {
53
+ let _ = (p.event_prefix(), p.registration_prefix(), p.request_target());
54
+ }
55
+ if let Some(p) = payment_connection_provider(&ir) {
56
+ let _ = p.instance_prefix();
57
+ }
58
+ let _ = registrations_binding(&random_text(&mut rng));
59
+ let _ = payment_connection_binding(&random_text(&mut rng));
60
+ if let Err(message) = refuse_unsupported_persistence_adapters(&ir) {
61
+ assert!(message.contains("rust/host only has a backend"), "seed {seed}: unhelpful refusal {message:?}");
62
+ }
63
+ }
64
+ });
65
+ }
66
+ }
67
+
68
+ #[test]
69
+ fn an_unsupported_persistence_adapter_is_always_refused_at_boot() {
70
+ for adapter in ["Heki", "Memory", "Sqlite", "", "postgres", "Postgres "] {
71
+ let ir = json!({"name": "D", "persistence": {"aggregates": [{"name": "A", "adapter": adapter}]}});
72
+ assert!(refuse_unsupported_persistence_adapters(&ir).is_err(), "{adapter:?}");
73
+ }
74
+ for adapter in ["Postgres", "PostgresEra"] {
75
+ let ir = json!({"name": "D", "persistence": {"aggregates": [{"name": "A", "adapter": adapter}]}});
76
+ assert!(refuse_unsupported_persistence_adapters(&ir).is_ok(), "{adapter:?}");
77
+ }
78
+ }
@@ -0,0 +1,227 @@
1
+ //! Seeded fuzz of the HTTP front door (`admit`, `value_to_response`): hostile bodies, methods, URIs
2
+ //! and headers must be answered, never panic, and a refused request must leave no trace.
3
+ //! Everything here is in-process: `admit` is the whole of what runs before dispatch, so none of
4
+ //! it needs Postgres or wasm.
5
+
6
+ use super::*;
7
+ use crate::fuzz_support::*;
8
+ use serde_json::json;
9
+
10
+ fn limits(pairs: &[(&str, &str)]) -> RateLimits {
11
+ let map: std::collections::HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
12
+ RateLimits::new(crate::rate_limit::Config::from_lookup(|name| map.get(name).cloned()).0)
13
+ }
14
+
15
+ fn peer(text: &str) -> SocketAddr {
16
+ text.parse().unwrap()
17
+ }
18
+
19
+ const PEERS: &[&str] = &["127.0.0.1:5000", "[::1]:5000", "[::ffff:127.0.0.1]:5000", "10.0.0.5:5000", "203.0.113.7:5000", "[2001:db8::1]:5000"];
20
+
21
+ fn random_method(rng: &mut Rng) -> Method {
22
+ let methods = [Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::PATCH, Method::HEAD, Method::OPTIONS, Method::TRACE, Method::CONNECT];
23
+ if rng.chance(8) {
24
+ Method::from_bytes(b"BREW").unwrap()
25
+ } else {
26
+ rng.pick(&methods).clone()
27
+ }
28
+ }
29
+
30
+ /// A URI that parses: a hostile path built from the pieces, or a fixed route the limiter knows.
31
+ fn random_uri(rng: &mut Rng) -> Uri {
32
+ const FIXED: &[&str] = &["/", "/newsletter/subscribers", "/registrations", "/api/me", "/login", "/%00", "/../..", "/a?b=%a", "/x?%", "/a//b", "/a;b", "*"];
33
+ if rng.chance(3) {
34
+ return rng.pick(FIXED).parse().unwrap();
35
+ }
36
+ for _ in 0..8 {
37
+ let path: String = (0..rng.below(4)).map(|_| format!("/{}", rng.pick(PIECES_ASCII))).collect();
38
+ let query = if rng.chance(2) { format!("?{}={}", rng.pick(PIECES_ASCII), rng.pick(PIECES_ASCII)) } else { String::new() };
39
+ if let Ok(uri) = format!("{path}{query}").parse::<Uri>() {
40
+ return uri;
41
+ }
42
+ }
43
+ "/".parse().unwrap()
44
+ }
45
+
46
+ const PIECES_ASCII: &[&str] = &["a", "b", "0", "%41", "%zz", "%", "%00", "..", ".", "-", "_", "id", "api", "me", "newsletter", "subscribers", "x y", "a+b", "=", "&", "a=b"];
47
+
48
+ fn random_headers(rng: &mut Rng) -> HeaderMap {
49
+ const NAMES: &[&str] = &["x-forwarded-for", "cookie", "content-type", "authorization", "host", "x-real-ip", "x-hecks-proxy-auth", "x-anything", "content-length", "set-cookie"];
50
+ let mut headers = HeaderMap::new();
51
+ for _ in 0..rng.below(5) {
52
+ let name = HeaderName::from_static(rng.pick(NAMES));
53
+ let bytes: Vec<u8> = match rng.below(4) {
54
+ 0 => random_text(rng).into_bytes(),
55
+ 1 => (0..rng.below(24)).map(|_| rng.next() as u8).collect(),
56
+ 2 => b"1.2.3.4, 5.6.7.8, ::1, not-an-ip, [::ffff:127.0.0.1]".to_vec(),
57
+ _ => "x".repeat(rng.below(9000)).into_bytes(),
58
+ };
59
+ // Header values are a restricted byte set; the ones the type refuses never reach `admit`.
60
+ if let Ok(value) = HeaderValue::from_bytes(&bytes) {
61
+ headers.append(name, value);
62
+ }
63
+ }
64
+ headers
65
+ }
66
+
67
+ fn status_of(refusal: &Response) -> u16 {
68
+ refusal.status().as_u16()
69
+ }
70
+
71
+ #[test]
72
+ fn admit_answers_every_hostile_request_without_panicking() {
73
+ for seed in 0..seeds() {
74
+ guarded(&format!("seed {seed}"), move || {
75
+ let mut rng = Rng::new(seed);
76
+ let limits = limits(&[]);
77
+ for _ in 0..30 {
78
+ let from = peer(rng.pick(PEERS));
79
+ let method = random_method(&mut rng);
80
+ let uri = random_uri(&mut rng);
81
+ let headers = random_headers(&mut rng);
82
+ let body = Bytes::from(random_bytes(&mut rng));
83
+
84
+ let parsed = if body.is_empty() { Some(json!({})) } else { serde_json::from_slice::<Value>(&body).ok() };
85
+ let answer = admit(&limits, from, &method, &uri, &headers, &body);
86
+ match (parsed, answer) {
87
+ (None, Err(refusal)) => assert_eq!(status_of(&refusal), 400, "seed {seed}: a body that is not JSON is a 400, got {}", status_of(&refusal)),
88
+ (None, Ok(envelope)) => panic!("seed {seed}: admitted a body that is not JSON: {envelope}"),
89
+ (Some(_), Err(refusal)) => assert_eq!(status_of(&refusal), 429, "seed {seed}: valid JSON is refused only by the limiter, got {}", status_of(&refusal)),
90
+ (Some(parsed), Ok(envelope)) => {
91
+ let internal = trusts_internal_dispatch(from) && is_internal_dispatch_shape(&parsed);
92
+ if internal {
93
+ assert_eq!(envelope, parsed, "seed {seed}: a trusted internal body passes through untouched");
94
+ } else {
95
+ assert_eq!(envelope["requestContext"]["http"]["method"], method.as_str(), "seed {seed}");
96
+ assert_eq!(envelope["rawPath"], uri.path(), "seed {seed}");
97
+ assert!(envelope["body"].is_string(), "seed {seed}: the synthesized body is text");
98
+ assert_eq!(envelope["isBase64Encoded"], false, "seed {seed}");
99
+ }
100
+ }
101
+ }
102
+ }
103
+ });
104
+ }
105
+ }
106
+
107
+ #[test]
108
+ fn a_public_peer_can_never_smuggle_in_the_internal_dispatch_protocol() {
109
+ // The internal shapes carry a caller's own `role` with no session check; only loopback may use them.
110
+ for seed in 0..seeds() {
111
+ guarded(&format!("seed {seed}"), move || {
112
+ let mut rng = Rng::new(seed ^ 0xBEEF);
113
+ let limits = limits(&[]);
114
+ for _ in 0..20 {
115
+ let mut internal = serde_json::Map::new();
116
+ for key in ["verb", "role", "actor_id", "args", "read", "query", "to", "with", "requestContext", "cookies"] {
117
+ if rng.chance(2) {
118
+ internal.insert(key.to_string(), random_value(&mut rng, 1));
119
+ }
120
+ }
121
+ let body = Bytes::from(Value::Object(internal).to_string());
122
+ let from = peer(rng.pick(&PEERS[3..]));
123
+ let envelope = admit(&limits, from, &Method::POST, &"/x".parse().unwrap(), &random_headers(&mut rng), &body)
124
+ .unwrap_or_else(|_| panic!("seed {seed}: valid JSON from a public peer is admitted"));
125
+ let keys: std::collections::BTreeSet<&str> = envelope.as_object().unwrap().keys().map(String::as_str).collect();
126
+ let expected: std::collections::BTreeSet<&str> = ["requestContext", "rawPath", "rawQueryString", "headers", "body", "isBase64Encoded"].into();
127
+ assert_eq!(keys, expected, "seed {seed}: a public peer's body must be wrapped as plain request data");
128
+ }
129
+ });
130
+ }
131
+ }
132
+
133
+ #[test]
134
+ fn a_malformed_body_is_refused_before_it_costs_any_rate_limit_budget() {
135
+ let limits = limits(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1")]);
136
+ let from = peer("203.0.113.7:5000");
137
+ let uri: Uri = "/newsletter/subscribers".parse().unwrap();
138
+ for body in ["{", "not json", "{\"a\":", "\u{0}", "[1,", "{\"a\":1}x", "\u{feff}{}"] {
139
+ for _ in 0..10 {
140
+ let refusal = admit(&limits, from, &Method::POST, &uri, &HeaderMap::new(), &Bytes::from(body.to_string())).err().expect("refused");
141
+ assert_eq!(status_of(&refusal), 400, "{body:?}");
142
+ }
143
+ }
144
+ let spent = |limits: &RateLimits| admit(limits, from, &Method::POST, &uri, &HeaderMap::new(), &Bytes::from("{}")).is_ok();
145
+ assert!(spent(&limits), "the one allowed request still goes through after the refusals");
146
+ assert!(!spent(&limits), "and the next is limited: only the valid request spent budget");
147
+ }
148
+
149
+ #[test]
150
+ fn invalid_utf8_and_oversized_bodies_are_answered() {
151
+ let limits = limits(&[]);
152
+ let from = peer("203.0.113.7:5000");
153
+ let uri: Uri = "/x".parse().unwrap();
154
+ let invalid = Bytes::from(vec![0xff, 0xfe, 0xfd]);
155
+ assert_eq!(status_of(&admit(&limits, from, &Method::POST, &uri, &HeaderMap::new(), &invalid).err().unwrap()), 400);
156
+
157
+ // A 16 MiB body of valid JSON is admitted as text (a size cap belongs to the listener, not here)
158
+ // and a 16 MiB run of openers is refused by the parser's depth limit rather than overflowing.
159
+ guarded("large bodies", move || {
160
+ let big_string = Bytes::from(format!("\"{}\"", "a".repeat(16 * 1024 * 1024)));
161
+ assert!(admit(&limits, from, &Method::POST, &uri, &HeaderMap::new(), &big_string).is_ok());
162
+ let openers = Bytes::from("[".repeat(16 * 1024 * 1024));
163
+ let refusal = admit(&limits, from, &Method::POST, &uri, &HeaderMap::new(), &openers).err().expect("refused");
164
+ assert_eq!(status_of(&refusal), 400);
165
+ let nested = Bytes::from(format!("{}1{}", "[".repeat(100_000), "]".repeat(100_000)));
166
+ assert_eq!(status_of(&admit(&limits, from, &Method::POST, &uri, &HeaderMap::new(), &nested).err().expect("refused")), 400);
167
+ });
168
+ }
169
+
170
+ #[test]
171
+ fn value_to_response_answers_any_shape_of_handler_output() {
172
+ for seed in 0..seeds() {
173
+ guarded(&format!("seed {seed}"), move || {
174
+ let mut rng = Rng::new(seed ^ 0xFACE);
175
+ for _ in 0..30 {
176
+ let mut value = serde_json::Map::new();
177
+ for key in ["statusCode", "headers", "cookies", "body", "isBase64Encoded", "extra"] {
178
+ if rng.chance(4) {
179
+ continue;
180
+ }
181
+ let field = match key {
182
+ "headers" if rng.chance(2) => {
183
+ let mut map = serde_json::Map::new();
184
+ for _ in 0..rng.below(4) {
185
+ map.insert(random_text(&mut rng), random_value(&mut rng, 2));
186
+ }
187
+ Value::Object(map)
188
+ }
189
+ "cookies" if rng.chance(2) => Value::Array((0..rng.below(4)).map(|_| random_value(&mut rng, 2)).collect()),
190
+ "statusCode" if rng.chance(2) => json!(*rng.pick(&[0u64, 99, 100, 199, 200, 204, 299, 301, 404, 599, 600, 999, 65_536, 65_736, u64::MAX])),
191
+ _ => random_value(&mut rng, 1),
192
+ };
193
+ value.insert(key.to_string(), field);
194
+ }
195
+ let response = value_to_response(Value::Object(value));
196
+ // Whatever came in, what goes out is a real status.
197
+ assert!((100..1000).contains(&status_of(&response)), "seed {seed}");
198
+ }
199
+ let plain = value_to_response(random_value(&mut rng, 0));
200
+ assert!((100..1000).contains(&status_of(&plain)), "seed {seed}");
201
+ });
202
+ }
203
+ }
204
+
205
+ #[test]
206
+ fn an_out_of_range_status_code_is_a_500_not_a_wrapped_status() {
207
+ // `65736 as u16` is 200: a handler bug must not turn into a success.
208
+ for code in [0u64, 99, 1000, 65_536, 65_736, 131_272, u64::MAX] {
209
+ let response = value_to_response(json!({ "statusCode": code, "body": "x" }));
210
+ assert_eq!(status_of(&response), 500, "statusCode {code}");
211
+ }
212
+ assert_eq!(status_of(&value_to_response(json!({ "statusCode": 204 }))), 204);
213
+ }
214
+
215
+ #[test]
216
+ fn control_characters_in_response_headers_and_cookies_are_dropped_not_emitted() {
217
+ let response = value_to_response(json!({
218
+ "statusCode": 200,
219
+ "headers": { "x-ok": "fine", "x-split": "a\r\nSet-Cookie: stolen=1", "bad name": "v" },
220
+ "cookies": ["session=abc; Path=/", "evil=1\r\nX-Injected: yes", "nul=\u{0}"],
221
+ "body": ""
222
+ }));
223
+ assert!(response.headers().get("x-split").is_none());
224
+ assert!(response.headers().get("x-injected").is_none());
225
+ let cookies: Vec<&str> = response.headers().get_all("set-cookie").iter().filter_map(|v| v.to_str().ok()).collect();
226
+ assert_eq!(cookies, vec!["session=abc; Path=/"]);
227
+ }
@@ -0,0 +1,125 @@
1
+ //! Seeded fuzz of the web layer's request decoding: percent-decoding, form and query strings,
2
+ //! cookies, base64 bodies, path shaping and the auth gate. Every one takes text an anonymous
3
+ //! caller controls, and none may panic on it.
4
+
5
+ use super::*;
6
+ use crate::fuzz_support::*;
7
+
8
+ #[test]
9
+ fn percent_decoding_never_slices_through_a_multi_byte_character() {
10
+ // `%a` followed by a 4-byte character puts the two-digit hex window mid-character.
11
+ for text in ["%a🍕", "%🍕", "%é", "a%é", "%a日", "%%a🍕", "%1é1", "🍕%", "🍕%a", "%a", "%", "%%", "%4", "%41", "x%e6%97%a5y", "%e6%97", "%ff%fe"] {
12
+ for plus_as_space in [true, false] {
13
+ let decoded = percent_decode_impl(text, plus_as_space);
14
+ assert!(decoded.len() < text.len() * 4 + 4, "{text:?}");
15
+ }
16
+ }
17
+ assert_eq!(percent_decode("%41%2f%2F+x"), "A// x");
18
+ assert_eq!(percent_decode_impl("a+b", false), "a+b");
19
+ }
20
+
21
+ #[test]
22
+ fn percent_decoding_reads_only_two_hex_digits_never_a_sign() {
23
+ // `u8::from_str_radix` accepts a leading `+`, so `%+1` would decode to byte 0x01.
24
+ assert_eq!(percent_decode_impl("%+1", false), "%+1");
25
+ assert_eq!(percent_decode_impl("%-1", false), "%-1");
26
+ assert_eq!(percent_decode_impl("%4g", false), "%4g");
27
+ assert_eq!(percent_decode_impl("%41", false), "A");
28
+ }
29
+
30
+ #[test]
31
+ fn form_and_query_strings_decode_any_text() {
32
+ for seed in 0..seeds() {
33
+ guarded(&format!("seed {seed}"), move || {
34
+ let mut rng = Rng::new(seed);
35
+ for _ in 0..100 {
36
+ let text = random_text(&mut rng);
37
+ let _ = parse_form(&text);
38
+ let _ = parse_query(&text);
39
+ let _ = percent_decode(&text);
40
+ let _ = auth::urlencode(&text);
41
+ // What the encoder emits, the decoder must read back exactly.
42
+ assert_eq!(percent_decode_impl(&auth::urlencode(&text), false), text, "seed {seed}: {text:?}");
43
+ }
44
+ });
45
+ }
46
+ }
47
+
48
+ #[test]
49
+ fn huge_query_strings_decode_in_linear_time() {
50
+ guarded("huge query", || {
51
+ let pairs = (0..200_000).map(|i| format!("k{i}=%41%e6%97%a5")).collect::<Vec<_>>().join("&");
52
+ assert_eq!(parse_query(&pairs).len(), 200_000);
53
+ let _ = parse_form(&"%".repeat(5_000_000));
54
+ let _ = parse_form(&"%a🍕".repeat(1_000_000));
55
+ });
56
+ }
57
+
58
+ #[test]
59
+ fn cookie_extraction_reads_any_event_shape() {
60
+ for seed in 0..seeds() {
61
+ guarded(&format!("seed {seed}"), move || {
62
+ let mut rng = Rng::new(seed ^ 0xC00C);
63
+ for _ in 0..50 {
64
+ let body = match rng.below(4) {
65
+ 0 => random_value(&mut rng, 0),
66
+ 1 => json!({"cookies": random_value(&mut rng, 2)}),
67
+ 2 => json!({"headers": {"cookie": random_value(&mut rng, 1)}}),
68
+ _ => json!({"cookies": [random_text(&mut rng), random_text(&mut rng)], "headers": {"cookie": random_text(&mut rng)}}),
69
+ };
70
+ let _ = extract_cookies(&body);
71
+ }
72
+ });
73
+ }
74
+ }
75
+
76
+ #[test]
77
+ fn base64_bodies_decode_any_text_without_panicking() {
78
+ for seed in 0..seeds() {
79
+ guarded(&format!("seed {seed}"), move || {
80
+ let mut rng = Rng::new(seed ^ 0xB64);
81
+ for _ in 0..100 {
82
+ let text = random_text(&mut rng);
83
+ let _ = base64_decode(&text);
84
+ let _ = String::from_utf8(base64_decode(&text)).unwrap_or_default();
85
+ }
86
+ });
87
+ }
88
+ assert_eq!(base64_decode("aGk="), b"hi");
89
+ assert!(base64_decode("").is_empty());
90
+ }
91
+
92
+ #[test]
93
+ fn paths_and_origins_shape_any_text() {
94
+ for seed in 0..seeds() {
95
+ guarded(&format!("seed {seed}"), move || {
96
+ let mut rng = Rng::new(seed ^ 0x9A7);
97
+ for _ in 0..100 {
98
+ let path = format!("/{}", random_text(&mut rng));
99
+ let _ = json_shaped(&path);
100
+ let _ = split_format(&path);
101
+ let _ = origin_of(&path);
102
+ let _ = same_origin(&path, &random_text(&mut rng));
103
+ let _ = humanize(&path);
104
+ let _ = esc(&path);
105
+ for authenticated in [true, false] {
106
+ if let Some(refusal) = auth_gate(&path, authenticated) {
107
+ let status = refusal["statusCode"].as_u64().expect("a status");
108
+ assert!(status == 401 || status == 302, "seed {seed}: {path:?} gated with {status}");
109
+ }
110
+ }
111
+ }
112
+ });
113
+ }
114
+ }
115
+
116
+ #[test]
117
+ fn an_unauthenticated_request_is_never_let_past_the_gate_by_a_path_trick() {
118
+ // Only the four sign-in paths are open; near-misses must be gated.
119
+ for path in ["/login/", "/LOGIN", "/login%2f", "//login", "/login/../admin", "/auth/google/", "/auth/google/callback/x", "/logout.json", "/./login", "/login?x", "/login "] {
120
+ assert!(auth_gate(path, false).is_some(), "{path:?} must be gated for an anonymous caller");
121
+ }
122
+ for path in ["/login", "/logout", "/auth/google", "/auth/google/callback"] {
123
+ assert!(auth_gate(path, false).is_none(), "{path:?} is the sign-in path");
124
+ }
125
+ }
@@ -0,0 +1,165 @@
1
+ //! Shared pieces of the boundary fuzz tests that sit beside `api`, `server`, `web`, `auth`,
2
+ //! `expr_json` and `ir`: a seeded generator, a guard that fails on a panic or a hang, and
3
+ //! generators for hostile text and JSON. Std-only, so a failing case is named by its seed alone.
4
+ //!
5
+ //! Run more seeds with `HOST_FUZZ_SEEDS=2000 cargo test fuzz`.
6
+
7
+ use serde_json::{json, Map, Value};
8
+ use std::sync::mpsc;
9
+ use std::time::Duration;
10
+
11
+ /// xorshift64*: no dependency, and the seed is the whole reproduction.
12
+ pub(crate) struct Rng(u64);
13
+
14
+ impl Rng {
15
+ pub(crate) fn new(seed: u64) -> Self {
16
+ Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
17
+ }
18
+
19
+ pub(crate) fn next(&mut self) -> u64 {
20
+ self.0 ^= self.0 >> 12;
21
+ self.0 ^= self.0 << 25;
22
+ self.0 ^= self.0 >> 27;
23
+ self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
24
+ }
25
+
26
+ pub(crate) fn below(&mut self, n: usize) -> usize {
27
+ (self.next() % n.max(1) as u64) as usize
28
+ }
29
+
30
+ pub(crate) fn chance(&mut self, one_in: usize) -> bool {
31
+ self.below(one_in) == 0
32
+ }
33
+
34
+ pub(crate) fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
35
+ &items[self.below(items.len())]
36
+ }
37
+ }
38
+
39
+ /// How many seeds each fuzz test runs.
40
+ pub(crate) fn seeds() -> u64 {
41
+ std::env::var("HOST_FUZZ_SEEDS").ok().and_then(|v| v.parse().ok()).unwrap_or(200)
42
+ }
43
+
44
+ /// The most a single guarded case may take before it counts as a hang.
45
+ pub(crate) const LIMIT: Duration = Duration::from_secs(20);
46
+
47
+ /// Runs `f` on a thread with a 2 MiB stack (a tokio worker's default) and returns its answer.
48
+ /// A panic or a missed `LIMIT` fails the test and names `label`; a stack overflow aborts the
49
+ /// whole binary, which fails it too.
50
+ pub(crate) fn guarded<T: Send + 'static>(label: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
51
+ let (tx, rx) = mpsc::channel();
52
+ let handle = std::thread::Builder::new()
53
+ .stack_size(2 * 1024 * 1024)
54
+ .spawn(move || {
55
+ let _ = tx.send(f());
56
+ })
57
+ .expect("spawn a guarded thread");
58
+ match rx.recv_timeout(LIMIT) {
59
+ Ok(value) => {
60
+ handle.join().expect("guarded thread joined");
61
+ value
62
+ }
63
+ Err(mpsc::RecvTimeoutError::Timeout) => panic!("{label}: no answer within {LIMIT:?} (hang)"),
64
+ Err(mpsc::RecvTimeoutError::Disconnected) => {
65
+ let panic = handle.join().expect_err("a guarded thread that sent nothing must have panicked");
66
+ let message = panic.downcast_ref::<String>().cloned().or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string()));
67
+ panic!("{label}: panicked: {}", message.unwrap_or_default())
68
+ }
69
+ }
70
+ }
71
+
72
+ /// Pieces that break naive text handling: multi-byte characters next to ASCII delimiters (the
73
+ /// shape that slices through a char boundary), control characters, NUL, bidi marks, astral
74
+ /// planes, combining marks, percent signs, and every JSON/URL/cookie delimiter.
75
+ const PIECES: &[&str] = &[
76
+ "a", "Z", "0", "9", " ", "\"", "'", "\\", "/", "%", "%4", "%41", "%zz", "%+1", "+", "=", "&", ";", "?", "#", ".", "..", "-", "_", ":", ",", "{", "}", "[", "]",
77
+ "\n", "\t", "\r", "\u{0}", "\u{1f}", "\u{7f}", "é", "ü", "日本", "🍕", "\u{10FFFF}", "\u{200B}", "\u{FEFF}", "\u{202E}", "\u{FFFD}", "e\u{301}", "__", "::",
78
+ "<script>", "../", "%0d%0a", "id", "name", "value", "sort", "direction", "desc",
79
+ ];
80
+
81
+ pub(crate) fn random_text(rng: &mut Rng) -> String {
82
+ (0..rng.below(10)).map(|_| *rng.pick(PIECES)).collect()
83
+ }
84
+
85
+ /// Edge-case numbers a JSON consumer is likely to mishandle, as JSON values.
86
+ pub(crate) fn edge_number(rng: &mut Rng) -> Value {
87
+ let edges: Vec<Value> = vec![
88
+ json!(0),
89
+ json!(-1),
90
+ json!(1),
91
+ json!(i64::MAX),
92
+ json!(i64::MIN),
93
+ json!(u64::MAX),
94
+ json!(0.0),
95
+ json!(-0.0),
96
+ json!(0.1),
97
+ json!(1e308),
98
+ json!(-1e308),
99
+ json!(5e-324),
100
+ json!(1.7976931348623157e308),
101
+ json!(9007199254740993_i64),
102
+ ];
103
+ rng.pick(&edges).clone()
104
+ }
105
+
106
+ /// A JSON value of any shape, to a bounded depth, favoring the wrong type for whatever reads it.
107
+ pub(crate) fn random_value(rng: &mut Rng, depth: usize) -> Value {
108
+ let leaf_only = depth >= 3;
109
+ match rng.below(if leaf_only { 6 } else { 8 }) {
110
+ 0 => Value::Null,
111
+ 1 => Value::Bool(rng.chance(2)),
112
+ 2 => edge_number(rng),
113
+ 3 | 4 => Value::String(random_text(rng)),
114
+ 5 => json!(rng.next() as i64 >> rng.below(63)),
115
+ 6 => Value::Array((0..rng.below(4)).map(|_| random_value(rng, depth + 1)).collect()),
116
+ _ => {
117
+ let mut map = Map::new();
118
+ for _ in 0..rng.below(4) {
119
+ map.insert(random_text(rng), random_value(rng, depth + 1));
120
+ }
121
+ Value::Object(map)
122
+ }
123
+ }
124
+ }
125
+
126
+ /// `text` with a few random edits, on char boundaries.
127
+ pub(crate) fn mutate_text(rng: &mut Rng, text: &str) -> String {
128
+ let mut chars: Vec<char> = text.chars().collect();
129
+ const JUNK: &[char] = &['{', '}', '[', ']', '"', '\\', ',', ':', '-', '+', '.', 'e', '0', '9', 'n', 't', ' ', '\u{0}', '\n', '%', 'é', '🍕'];
130
+ for _ in 0..1 + rng.below(3) {
131
+ if chars.is_empty() {
132
+ chars.push(*rng.pick(JUNK));
133
+ continue;
134
+ }
135
+ let at = rng.below(chars.len());
136
+ match rng.below(4) {
137
+ 0 => {
138
+ chars.remove(at);
139
+ }
140
+ 1 => chars.insert(at, *rng.pick(JUNK)),
141
+ 2 => chars[at] = *rng.pick(JUNK),
142
+ _ => chars.truncate(at),
143
+ }
144
+ }
145
+ chars.into_iter().collect()
146
+ }
147
+
148
+ /// Raw bytes, valid UTF-8 or not: random, mutated JSON, truncated mid-character, or hostile.
149
+ pub(crate) fn random_bytes(rng: &mut Rng) -> Vec<u8> {
150
+ match rng.below(6) {
151
+ 0 => (0..rng.below(64)).map(|_| rng.next() as u8).collect(),
152
+ 1 => {
153
+ let valid = random_value(rng, 0).to_string();
154
+ mutate_text(rng, &valid).into_bytes()
155
+ }
156
+ 2 => {
157
+ let mut bytes = random_value(rng, 0).to_string().into_bytes();
158
+ bytes.extend_from_slice(&[0xff, 0xfe, 0xc0, 0x80, 0xf0, 0x9f]);
159
+ bytes
160
+ }
161
+ 3 => "{\"a\":\"🍕\"}".as_bytes()[..rng.below(10)].to_vec(),
162
+ 4 => random_text(rng).into_bytes(),
163
+ _ => random_value(rng, 0).to_string().into_bytes(),
164
+ }
165
+ }
data/rust/host/src/ir.rs CHANGED
@@ -731,3 +731,7 @@ mod tests {
731
731
  assert!(refuse_unsupported_persistence_adapters(&ir).is_ok());
732
732
  }
733
733
  }
734
+
735
+ #[cfg(test)]
736
+ #[path = "boundary_fuzz/ir.rs"]
737
+ mod boundary_fuzz;
@@ -8,6 +8,11 @@ mod checkout;
8
8
  mod dispatch;
9
9
  mod expr_json;
10
10
  mod field_hints;
11
+ #[cfg(test)]
12
+ mod fuzz_support;
13
+ #[cfg(test)]
14
+ #[path = "boundary_fuzz/expr_json.rs"]
15
+ mod expr_json_fuzz;
11
16
  mod ir;
12
17
  mod journal;
13
18
  mod lambda_client;
@@ -69,10 +74,10 @@ async fn main() -> Result<(), Error> {
69
74
  .fetch_secret_string(&secret_arn)
70
75
  .await
71
76
  .map_err(|e| format!("fetching DB_SECRET_ARN from Secrets Manager: {e:#}"))?;
72
- let password = secrets::extract_field(&secret_json, "password")?;
73
- // Literal, unescaped, no percent-encoding -- parse_database_url
74
- // below never percent-decodes the password segment either way.
75
- format!("postgres://postgres:{password}@{db_host}:5432/{db_name}")
77
+ // The user and port come from the secret too (a client sharing an instance logs in as
78
+ // its own role); the password goes in literal, since parse_database_url below never
79
+ // percent-decodes it.
80
+ secrets::database_url(&secret_json, &db_host, &db_name)?
76
81
  }
77
82
  Err(_) => std::env::var("DATABASE_URL")
78
83
  .map_err(|_| "either DB_SECRET_ARN (+ DB_HOST/DB_NAME) or DATABASE_URL is required")?,