cfsolve 0.1.0 → 0.1.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e16a4a51d964a033d8395a0757bf128554f4d5d3f52ae1ef914786e651218b62
4
- data.tar.gz: d5645dcdb32e335adbc69a521b3aa8289993c41ccf03e6a9380c7a95d5054ab1
3
+ metadata.gz: 4828b2b3d60862c05be945e5ae1ca02c96c63889f39ad326759e01702b54d75e
4
+ data.tar.gz: bd34543669e65937407d5670ad0bfdb679cfb0fd2211ac8176de0d1f9d85d563
5
5
  SHA512:
6
- metadata.gz: 075eb4eed86e21dd138d95714355792e456c8535c8ec87f76dd49cec4c19f753add411319472dc2a85950938f3722f0bff9cb9f7ce993bace80a9413400b7c50
7
- data.tar.gz: 6e1575b02b5f658c79c4dc3d7159edded8a15637974e12b67a0578ddb5c6c457b898318210968523947dd20c9666c2b22444f478264c8423d543bb5f876eee69
6
+ metadata.gz: d03f438c214dfa30a921c9e015a087155fdf8c74b192b705ce8d338c5e8868d6bebe22c8977f824ac69127a1d687a4b5cd587d071f253d0cc5dddf40e7c3f232
7
+ data.tar.gz: b8eb750a28710676dcdb2671d5fc1168200d6c749981c9418d54a01dd1299facc40a0f0e1144bee16f3d9ece694aafa0b56d7a889b0446cc6f4b4481fc3d5628
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1
4
+
5
+ Update the native engine to rebased upstream PR #5 (`0f97eb7`). Adopt upstream's
6
+ regional worker-host discovery, copied-option recovery, and lower probe-matching
7
+ threshold while retaining the fixes for replay across challenge builds.
8
+
3
9
  ## 0.1.0
4
10
 
5
11
  Initial Ruby interface based on cfsolve PR #5. Includes isolated native solves,
data/UPSTREAM.md CHANGED
@@ -1,6 +1,6 @@
1
1
  The Rust engine is vendored from [chris124567/cfsolve](https://github.com/chris124567/cfsolve),
2
2
  including [PR #5](https://github.com/chris124567/cfsolve/pull/5), at commit
3
- `1ee7a203e2a9a17fbb82148b6707d2d82181f56a`.
3
+ `0f97eb7ccfea54f445b60a624ad8949beae95e95`.
4
4
 
5
5
  `vendor/cfsolve` is an unmodified archive of that revision. Its three patched
6
6
  dependencies are applied by the extension's top-level Cargo manifest. The Ruby
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cfsolve
4
- VERSION = "0.1.0"
5
- UPSTREAM_REVISION = "1ee7a203e2a9a17fbb82148b6707d2d82181f56a"
4
+ VERSION = "0.1.1"
5
+ UPSTREAM_REVISION = "0f97eb7ccfea54f445b60a624ad8949beae95e95"
6
6
  end
@@ -1,12 +1,9 @@
1
1
  //! Recovers how the iframe copies the embedding page's `extraParams` message fields into
2
2
  //! randomly named `_cf_chl_opt` options.
3
3
 
4
- use std::collections::HashMap;
5
-
6
4
  use anyhow::{Context, Result};
7
5
  use swc_core::ecma::ast::{
8
- AssignOp, AssignTarget, BinaryOp, CallExpr, Callee, Decl, Expr, FnDecl, Pat,
9
- SimpleAssignTarget, Stmt, UnaryOp,
6
+ AssignTarget, BinaryOp, CallExpr, Callee, Decl, Expr, FnDecl, SimpleAssignTarget, Stmt, UnaryOp,
10
7
  };
11
8
  use swc_core::ecma::visit::{Visit, VisitWith};
12
9
 
@@ -283,40 +280,37 @@ fn writes(function: &FnDecl) -> Vec<OptionWrite> {
283
280
  return Vec::new();
284
281
  };
285
282
  // Some options pass through a local so stored browser state can override them before they
286
- // are written back. Keep only the latest straight-line value; `None` invalidates a copy.
287
- let mut staged: HashMap<&str, Option<&str>> = HashMap::new();
283
+ // are written back.
284
+ let mut staged: Vec<(&str, &str)> = Vec::new();
288
285
  let mut writes = Vec::new();
289
286
  for statement in &body.stmts {
290
- if let Stmt::Decl(Decl::Var(declaration)) = statement {
291
- for binding in &declaration.decls {
292
- if let Pat::Ident(local) = &binding.name
293
- && let Some(value) = &binding.init
294
- {
295
- staged.insert(&local.id.sym, expression::option_key(value.unwrap_parens()));
296
- }
297
- }
298
- }
299
287
  if let Some(write) = sampled(statement, &parameters) {
300
288
  writes.push(write);
301
289
  continue;
302
290
  }
291
+ if let Stmt::Decl(Decl::Var(declaration)) = statement {
292
+ staged.extend(declaration.decls.iter().filter_map(|declarator| {
293
+ let local = &*declarator.name.as_ident()?.id.sym;
294
+ Some((local, expression::option_key(declarator.init.as_deref()?)?))
295
+ }));
296
+ continue;
297
+ }
303
298
  let Some(assignment) = expression::assignment(statement) else {
304
299
  continue;
305
300
  };
306
- let assigned =
307
- (assignment.op == AssignOp::Assign).then_some(assignment.right.unwrap_parens());
308
- if let AssignTarget::Simple(SimpleAssignTarget::Ident(local)) = &assignment.left {
309
- staged.insert(&local.id.sym, assigned.and_then(expression::option_key));
301
+ if let AssignTarget::Simple(SimpleAssignTarget::Ident(local)) = &assignment.left
302
+ && let Some(option) = expression::option_key(&assignment.right)
303
+ {
304
+ staged.push((&local.id.sym, option));
310
305
  continue;
311
306
  }
312
307
  let Some(option) = expression::option_assignment(&assignment.left).map(str::to_owned)
313
308
  else {
314
309
  continue;
315
310
  };
316
- let (mut value, default) =
317
- assigned.map_or((Written::Other, None), |value| read(value, &parameters));
318
- if let (Written::Other, Some(Expr::Ident(ident))) = (&value, assigned)
319
- && let Some(Some(from)) = staged.get(&*ident.sym)
311
+ let (mut value, default) = read(&assignment.right, &parameters);
312
+ if let (Written::Other, Expr::Ident(ident)) = (&value, &*assignment.right)
313
+ && let Some((_, from)) = staged.iter().rfind(|(local, _)| *local == &*ident.sym)
320
314
  {
321
315
  value = Written::Copied((*from).to_owned());
322
316
  }
@@ -476,78 +470,29 @@ mod tests {
476
470
  }
477
471
  }
478
472
 
473
+ /// Stored state may override an option copied through a local. Some builds declare the local
474
+ /// with its value; others assign it separately.
479
475
  #[test]
480
- fn copies_bootstrap_values_staged_by_a_declaration_or_assignment() {
481
- for initial in [
482
- "var saved = e._cf_chl_opt.seed;",
483
- "let saved = e._cf_chl_opt.seed;",
484
- "const saved = e._cf_chl_opt.seed;",
485
- "var unused, saved = (e._cf_chl_opt.seed);",
486
- "saved = e._cf_chl_opt.seed;",
487
- "saved = (e._cf_chl_opt.seed);",
488
- "var saved = e._cf_chl_opt.other; saved = e._cf_chl_opt.seed;",
489
- "saved = e._cf_chl_opt.other; var saved = e._cf_chl_opt.seed;",
490
- "var saved = e._cf_chl_opt.seed; var saved;",
476
+ fn recovers_options_copied_through_locals() {
477
+ for staging in [
478
+ "var z8 = e._cf_chl_opt.qBdzw2;",
479
+ "z8 = e._cf_chl_opt.qBdzw2;",
491
480
  ] {
492
481
  let source = SOURCE.replace(
493
482
  "e._cf_chl_opt.WbdgY0 = z2;",
494
483
  &format!(
495
- r#"
496
- {initial}
497
- try {{
498
- var cached = localStorage.getItem("visitor");
499
- if (cached) {{ saved = cached; }}
500
- }} catch (error) {{}}
501
- e._cf_chl_opt.WbdgY0 = (saved);
502
- "#
484
+ "{staging} try {{ z8 = localStorage.getItem(`u`) || z8; }} catch (z9) {{}} e._cf_chl_opt.WbdgY0 = z8;"
503
485
  ),
504
486
  );
505
- let mapping = Mapping::from_javascript(&source).unwrap();
506
- let entry = mapping
507
- .0
508
- .iter()
509
- .find(|entry| entry.option == "WbdgY0")
510
- .unwrap();
511
- assert_eq!(
512
- entry.source,
513
- OptionSource::Copied("seed".into()),
514
- "{initial}"
515
- );
516
- let mut options = Options::from_iter([("seed".into(), json!("synthetic-visitor"))]);
487
+ let mapping = Mapping::from_javascript(&source).expect("modified source parses");
488
+ let copied = OptionSource::Copied("qBdzw2".into());
517
489
  assert!(
518
490
  mapping
519
- .merge(&mut options, &json!({}), "https://example.com", 1)
520
- .is_empty()
521
- );
522
- assert_eq!(options["WbdgY0"], json!("synthetic-visitor"));
523
- }
524
- }
525
-
526
- #[test]
527
- fn does_not_reuse_copies_after_overwrites_or_compound_assignments() {
528
- for body in [
529
- "saved = undefined; e._cf_chl_opt.WbdgY0 = saved;",
530
- "var saved = undefined; e._cf_chl_opt.WbdgY0 = saved;",
531
- "saved += e._cf_chl_opt.other; e._cf_chl_opt.WbdgY0 = saved;",
532
- "e._cf_chl_opt.WbdgY0 += saved;",
533
- ] {
534
- let source = SOURCE.replace(
535
- "e._cf_chl_opt.WbdgY0 = z2;",
536
- &format!("var saved = e._cf_chl_opt.seed; {body}"),
537
- );
538
- let mapping = Mapping::from_javascript(&source).unwrap();
539
- assert_eq!(
540
- mapping.0.last().unwrap().source,
541
- OptionSource::Runtime,
542
- "{body}"
543
- );
544
- let mut options = Options::from_iter([("seed".into(), json!("synthetic-visitor"))]);
545
- assert_eq!(
546
- mapping.merge(&mut options, &json!({}), "https://example.com", 1),
547
- ["WbdgY0"],
548
- "{body}",
491
+ .0
492
+ .iter()
493
+ .any(|entry| entry.option == "WbdgY0" && entry.source == copied),
494
+ "WbdgY0 is not copied from qBdzw2 after {staging}"
549
495
  );
550
- assert!(!options.contains_key("WbdgY0"), "{body}");
551
496
  }
552
497
  }
553
498
 
@@ -7,7 +7,6 @@ use url::Url;
7
7
  use crate::js::{self, JsObject, Options, json};
8
8
  use crate::stage2::analysis::recognize::ProbeView;
9
9
  use crate::stage2::reference;
10
- use crate::{candidates, listing};
11
10
 
12
11
  pub(crate) const OUTPUTS: [&str; 2] = [
13
12
  reference::WORKER_FETCH_STATUS,
@@ -16,11 +15,14 @@ pub(crate) const OUTPUTS: [&str; 2] = [
16
15
 
17
16
  /// The response header whose value the probe reports as its token.
18
17
  const HEADER: &str = "cf-chl-peek";
19
- /// The worker endpoint's protocol path. The host comes from the current program:
20
- /// Cloudflare rotates it independently of the iframe's host.
18
+ /// Explicit host and path prefix for the worker fetch. Builds may store them as one string or two.
19
+ /// The host's first label varies by region, such as `brunhild` or `hagen`.
20
+ /// [`super::platform::url`] builds URLs relative to the iframe instead.
21
+ const HOST_SUFFIX: &str = ".challenges.cloudflare.com";
21
22
  const PATH: &str = "/cdn-cgi/challenge-platform/h/";
22
23
 
23
- /// The worker's host and session options naming its platform segment, ray and nonce.
24
+ /// The fetched host and the session's options naming the request path's platform segment, ray
25
+ /// and nonce.
24
26
  #[derive(Clone, Debug, Deserialize, Serialize)]
25
27
  pub struct Fields {
26
28
  pub host: String,
@@ -90,154 +92,44 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
90
92
  "omit",
91
93
  "no-store",
92
94
  ];
93
- if !(strings.stores_all(&OUTPUTS) && strings.has_all(&markers)) {
94
- return None;
95
- }
96
- Some(Fields {
97
- host: request_host(probe.session_features, probe.pool)?,
95
+ let features = strings.features();
96
+ let host = features.iter().find_map(|text| host(text))?;
97
+ let path = features.iter().any(|text| text.contains(PATH));
98
+ (strings.stores_all(&OUTPUTS) && strings.has_all(&markers) && path).then(|| Fields {
99
+ host: host.to_owned(),
98
100
  platform_property: probe.session_name(reference::PLATFORM_OPTION),
99
101
  ray_property: probe.session_name(reference::RAY_OPTION),
100
102
  nonce_property: probe.session_name(reference::NONCE_OPTION),
101
103
  })
102
104
  }
103
105
 
104
- /// Recovers a unique endpoint from a complete URL or adjacent scheme, host and path
105
- /// fragments. Malformed descriptors remain in place so they cannot join unrelated fragments.
106
- fn request_host(features: &[String], pool: &str) -> Option<String> {
107
- let strings: Vec<String> = features
108
- .iter()
109
- .map(|text| {
110
- if text.starts_with('\0') || text.starts_with("ÿÿ") {
111
- listing::decode_string(text, pool).unwrap_or_else(|_| text.clone())
112
- } else {
113
- text.clone()
114
- }
115
- })
116
- .collect();
117
- let hosts = strings.iter().enumerate().filter_map(|(start, text)| {
118
- if !text.starts_with("https://") {
119
- return None;
120
- }
121
- let mut candidate = String::new();
122
- strings[start..].iter().take(3).find_map(|part| {
123
- candidate.push_str(part);
124
- let url = Url::parse(&candidate).ok()?;
125
- let host = url.host_str()?;
126
- // Require the bare HTTPS endpoint, without credentials or extra URL components.
127
- (host.ends_with(".challenges.cloudflare.com")
128
- && url.as_str() == format!("https://{host}{PATH}"))
129
- .then(|| host.to_owned())
130
- })
131
- });
132
- candidates::at_most_one_distinct(hosts, "worker fetch host").ok()?
106
+ /// Finds the challenge host in `text`, which may be a full URL or a host after a length prefix.
107
+ fn host(text: &str) -> Option<&str> {
108
+ let suffix = text.find(HOST_SUFFIX)?;
109
+ let start = text[..suffix]
110
+ .rfind(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
111
+ .map_or(0, |index| index + 1);
112
+ (start < suffix).then(|| &text[start..suffix + HOST_SUFFIX.len()])
133
113
  }
134
114
 
135
115
  #[cfg(test)]
136
116
  mod tests {
137
- use std::collections::BTreeSet;
138
-
139
- use super::*;
140
- use crate::listing::Index;
141
- use crate::stage2::analysis::recognize::Strings;
142
- use crate::stage2::records::WriteOrder;
143
- use crate::stage2::registry::Kind;
144
- use crate::stage2::vocabulary::Vocabulary;
145
- use crate::vm::Listing;
146
-
147
- fn strings(parts: &[&str]) -> Vec<String> {
148
- parts.iter().map(|part| (*part).to_owned()).collect()
149
- }
150
-
151
- #[test]
152
- fn reads_current_hosts_from_complete_split_and_encoded_urls() {
153
- for host in ["brunhild", "hagen", "future-worker"] {
154
- let host = format!("{host}.challenges.cloudflare.com");
155
- let full = format!("https://{host}{PATH}");
156
- let encoded = format!("\0{}{host}\0padding", char::from(host.len() as u8));
157
- for features in [
158
- strings(&[&full]),
159
- strings(&[&full, &full]),
160
- strings(&["https://", &host, PATH]),
161
- strings(&["https://", &format!("{host}{PATH}")]),
162
- strings(&["https://", &encoded, PATH]),
163
- strings(&[&format!("https://{host}"), PATH]),
164
- ] {
165
- assert_eq!(request_host(&features, ""), Some(host.clone()));
166
- }
167
- }
168
- }
169
-
170
- #[test]
171
- fn recognizes_the_session_host_without_rewriting_it_to_the_reference() {
172
- let reference = &reference::REFERENCE.probes[Kind::WorkerFetch.references()[0] - 1];
173
- let session: Vec<String> = reference
174
- .iter()
175
- .map(|text| text.replace("brunhild.", "current-worker."))
176
- .collect();
177
- let listing = Listing::default();
178
- let index = Index::new(&listing);
179
- let vocabulary = Vocabulary::infer(&[&session]);
180
- let empty = BTreeSet::new();
181
- let outputs = OUTPUTS.map(str::to_owned).into_iter().collect();
182
- let fields = recognize(&ProbeView {
183
- index: &index,
184
- vocabulary: &vocabulary,
185
- position: 0,
186
- header: &empty,
187
- wrapper: &empty,
188
- pool: "",
189
- entry: 0,
190
- session_features: &session,
191
- writes: &WriteOrder::default(),
192
- strings: Strings::new(reference, &outputs),
193
- })
194
- .unwrap();
195
- assert_eq!(fields.host, "current-worker.challenges.cloudflare.com");
196
- }
197
-
198
- #[test]
199
- fn reads_pooled_hosts_and_builds_the_current_request() {
200
- let host = "hagen.challenges.cloudflare.com";
201
- let pool = format!("\0\0{host}");
202
- let descriptor = format!("ÿÿ\0\0\0\0\0\0\0\0\0{}", char::from(host.len() as u8));
203
- let host = request_host(&strings(&["https://", &descriptor, PATH]), &pool).unwrap();
204
- let fields = Fields {
205
- host,
206
- platform_property: "platform".into(),
207
- ray_property: "ray".into(),
208
- nonce_property: "nonce".into(),
209
- };
210
- let options =
211
- serde_json::from_str(r#"{"platform":"g","ray":"fresh-ray","nonce":"fresh-nonce"}"#)
212
- .unwrap();
213
- assert_eq!(
214
- fields.url(&options).unwrap().as_str(),
215
- "https://hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g/i/fresh-ray/fresh-nonce"
216
- );
217
- }
117
+ use super::host;
218
118
 
219
119
  #[test]
220
- fn rejects_missing_ambiguous_and_unrelated_endpoints() {
221
- for parts in [
222
- vec![],
223
- vec!["http://hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/"],
224
- vec!["https://hagen.challenges.cloudflare.com/other/"],
225
- vec![
226
- "https://hagen.challenges.cloudflare.com.evil.example/cdn-cgi/challenge-platform/h/",
227
- ],
228
- vec!["https://user@hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/"],
229
- vec!["https://hagen.challenges.cloudflare.com:8443/cdn-cgi/challenge-platform/h/"],
230
- vec!["https://", "\0", PATH],
231
- vec!["https://hagen.challenges.cloudflare.com", "\0", PATH],
232
- vec!["https://hagen.challenges.cloudflare.com", "ÿÿ\0", PATH],
233
- vec!["https://hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/?extra"],
234
- vec!["https://hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/#extra"],
235
- vec![
236
- "https://hagen.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/",
120
+ fn finds_regional_hosts_in_urls_and_prefixed_strings() {
121
+ for (text, expected) in [
122
+ (
237
123
  "https://brunhild.challenges.cloudflare.com/cdn-cgi/challenge-platform/h/",
238
- ],
124
+ Some("brunhild.challenges.cloudflare.com"),
125
+ ),
126
+ (
127
+ "\0\u{1f}hagen.challenges.cloudflare.com\0\0",
128
+ Some("hagen.challenges.cloudflare.com"),
129
+ ),
130
+ ("https://.challenges.cloudflare.com", None),
239
131
  ] {
240
- assert_eq!(request_host(&strings(&parts), ""), None, "{parts:?}");
132
+ assert_eq!(host(text), expected, "{text:?}");
241
133
  }
242
134
  }
243
135
  }
@@ -484,6 +484,11 @@ fn best_partners<'a>(
484
484
  best.into_values().map(|(pair, _)| pair).collect()
485
485
  }
486
486
 
487
+ /// The lowest similarity that pairs two probes. Below it, probes share mostly common wrapper
488
+ /// strings: unrelated probes score at most 0.2. The devtools probe scores only about 0.5 against
489
+ /// its reference, and drops to 0.49 in programs that never name `document`.
490
+ const MIN_PAIR_SIMILARITY: f64 = 0.35;
491
+
487
492
  /// Pairs probes by descending Jaccard similarity of stable strings, using each reference probe
488
493
  /// at most once. Also retains each session probe's closest reference match regardless of
489
494
  /// pairing.
@@ -520,8 +525,7 @@ fn pair_probes<S: AsRef<[String]>>(
520
525
  let mut used = HashSet::new();
521
526
  let mut pairs = vec![None; session.len()];
522
527
  for (index, candidate) in pairings {
523
- // Below this threshold, probes share mostly common wrapper strings.
524
- if candidate.similarity < 0.5 {
528
+ if candidate.similarity < MIN_PAIR_SIMILARITY {
525
529
  break;
526
530
  }
527
531
  if pairs[index].is_none() && used.insert(candidate.reference) {
@@ -901,6 +905,24 @@ mod tests {
901
905
  }
902
906
  }
903
907
 
908
+ /// A probe that shares only three of seven stable strings with its reference still pairs,
909
+ /// as the devtools probe must when a program never names `document`.
910
+ #[test]
911
+ fn pairs_a_probe_below_half_similarity() {
912
+ let session = [
913
+ strings("debug stack console"),
914
+ strings("fetch then"),
915
+ strings("now floor"),
916
+ ];
917
+ let reference = [
918
+ strings("debug stack console document create remove append"),
919
+ strings("fetch then"),
920
+ strings("now floor"),
921
+ ];
922
+ let (pairs, _) = pair_probes(&session, &reference);
923
+ assert_eq!(pairs, [Some(1), Some(2), Some(3)]);
924
+ }
925
+
904
926
  #[test]
905
927
  fn prefers_a_probes_own_pairs_over_the_global_map() {
906
928
  // The reference reuses one name where the session has two.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cfsolve
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Morgan