1use serde::Serialize;
19use serde_json::Value;
20
21pub const MAX_INPUT: usize = 32 * 1024 * 1024;
24
25#[derive(Debug, Clone, Default, PartialEq, Serialize)]
26pub struct Cookie {
27 pub name: String,
28 pub value: String,
29 pub domain: Option<String>,
30 pub path: Option<String>,
31 pub secure: bool,
32 pub http_only: bool,
33 pub expires: i64,
35 pub partitioned: bool,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Serialize)]
40pub struct Capture {
41 pub origin: Option<String>,
43 pub cookies: Vec<Cookie>,
44 pub headers: Vec<(String, String)>,
46 pub user_agent: Option<String>,
47 pub dropped: Vec<String>,
49}
50
51const SENSITIVE: [&str; 4] = [
53 "authorization",
54 "proxy-authorization",
55 "x-api-key",
56 "x-auth-token",
57];
58const NOISE: [&str; 8] = [
60 "host",
61 "content-length",
62 "accept-encoding",
63 "connection",
64 "cookie",
65 "user-agent",
66 "priority",
67 "te",
68];
69
70fn origin_of(url: &str) -> Option<String> {
71 let (scheme, rest) = url.split_once("://")?;
72 if scheme != "http" && scheme != "https" {
73 return None;
74 }
75 let host = rest.split(['/', '?', '#']).next()?;
76 let host = host.rsplit('@').next()?;
78 if host.is_empty() {
79 return None;
80 }
81 Some(format!("{scheme}://{host}"))
82}
83
84fn host_of(origin: &str) -> &str {
85 let h = origin.split_once("://").map_or(origin, |(_, r)| r);
86 h.rsplit_once(':')
87 .filter(|(_, p)| p.chars().all(|c| c.is_ascii_digit()) && !h.ends_with(']'))
88 .map_or(h, |(h, _)| h)
89}
90
91fn valid_cookie_value(v: &str) -> bool {
94 let inner = v
95 .strip_prefix('"')
96 .and_then(|x| x.strip_suffix('"'))
97 .unwrap_or(v);
98 inner
99 .chars()
100 .all(|c| (c as u32) > 0x20 && (c as u32) < 0x7f && !matches!(c, '"' | ',' | ';' | '\\'))
101}
102
103fn cookie_pairs(header: &str, out: &mut Vec<Cookie>, dropped: &mut Vec<String>) {
104 for piece in header.split(';') {
105 let piece = piece.trim();
106 let Some((name, value)) = piece.split_once('=') else {
107 continue;
108 };
109 let (name, value) = (name.trim(), value.trim());
110 if name.is_empty() {
111 continue;
112 }
113 if !valid_cookie_value(value) {
114 dropped.push(format!("cookie {name} (illegal character in value)"));
115 continue;
116 }
117 upsert(
118 out,
119 Cookie {
120 name: name.to_string(),
121 value: value.to_string(),
122 ..Default::default()
123 },
124 );
125 }
126}
127
128fn upsert(out: &mut Vec<Cookie>, c: Cookie) {
130 let bare = |x: &Cookie| x.domain.is_none() && x.path.is_none();
134 if let Some(slot) = out.iter_mut().find(|x| {
135 x.name == c.name && ((x.domain == c.domain && x.path == c.path) || bare(x) || bare(&c))
136 }) {
137 *slot = c;
138 } else {
139 out.push(c);
140 }
141}
142
143fn keep_header(name: &str, value: &str, cap: &mut Capture) {
144 let lower = name.to_ascii_lowercase();
145 if lower == "user-agent" {
146 cap.user_agent = Some(value.to_string());
147 } else if SENSITIVE.contains(&lower.as_str()) {
148 let note = format!("{name} header");
149 if !cap.dropped.contains(¬e) {
150 cap.dropped.push(note);
151 }
152 } else if !NOISE.contains(&lower.as_str()) && !lower.starts_with("sec-fetch-") {
153 cap.headers.push((name.to_string(), value.to_string()));
154 }
155}
156
157fn shell_split(s: &str) -> Vec<String> {
162 let c: Vec<char> = s.chars().collect();
163 let mut out = Vec::new();
164 let mut cur = String::new();
165 let mut have = false;
166 let mut i = 0;
167 while i < c.len() {
168 match c[i] {
169 '\\' if matches!(c.get(i + 1), Some('\n')) => i += 2,
170 '\\' if matches!(c.get(i + 1), Some('\r')) => i += 3,
171 '\\' => {
172 if let Some(n) = c.get(i + 1) {
173 cur.push(*n);
174 have = true;
175 }
176 i += 2;
177 }
178 '\'' => {
179 have = true;
180 i += 1;
181 while i < c.len() && c[i] != '\'' {
182 cur.push(c[i]);
183 i += 1;
184 }
185 i += 1;
186 }
187 '$' if c.get(i + 1) == Some(&'\'') => {
188 have = true;
189 i += 2;
190 while i < c.len() && c[i] != '\'' {
191 if c[i] == '\\' && i + 1 < c.len() {
192 i += 1;
193 match c[i] {
194 'n' => cur.push('\n'),
195 'r' => cur.push('\r'),
196 't' => cur.push('\t'),
197 'u' | 'x' => {
198 let width = if c[i] == 'u' { 4 } else { 2 };
199 let hex: String = c[i + 1..].iter().take(width).collect();
200 match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
201 Some(ch) => {
202 cur.push(ch);
203 i += hex.len();
204 }
205 None => cur.push(c[i]),
206 }
207 }
208 other => cur.push(other),
209 }
210 } else {
211 cur.push(c[i]);
212 }
213 i += 1;
214 }
215 i += 1;
216 }
217 '"' => {
218 have = true;
219 i += 1;
220 while i < c.len() && c[i] != '"' {
221 if c[i] == '\\' && matches!(c.get(i + 1), Some('"' | '\\' | '$' | '`')) {
222 i += 1;
223 }
224 cur.push(c[i]);
225 i += 1;
226 }
227 i += 1;
228 }
229 ch if ch.is_whitespace() => {
230 if have {
231 out.push(std::mem::take(&mut cur));
232 have = false;
233 }
234 i += 1;
235 }
236 ch => {
237 cur.push(ch);
238 have = true;
239 i += 1;
240 }
241 }
242 }
243 if have {
244 out.push(cur);
245 }
246 out
247}
248
249fn uncaret(s: &str) -> String {
251 let joined = s.replace("^\r\n", "").replace("^\n", "");
252 let mut out = String::new();
253 let mut it = joined.chars().peekable();
254 while let Some(ch) = it.next() {
255 if ch == '^' {
256 if let Some(n) = it.next() {
257 out.push(n);
258 }
259 } else {
260 out.push(ch);
261 }
262 }
263 out
265}
266
267pub fn parse_curl(text: &str) -> Result<Capture, String> {
269 if text.len() > MAX_INPUT {
270 return Err("input is too large to be a login capture".into());
271 }
272 let text = text.trim();
273 let normalized = if text.contains("^\"") || text.contains("^\n") {
274 uncaret(text)
275 } else {
276 text.to_string()
277 };
278 let toks = shell_split(&normalized);
279 let mut it = toks.into_iter();
280 match it.next().as_deref() {
281 Some("curl") | Some("curl.exe") => {}
282 _ => return Err("not a curl command".into()),
283 }
284 let mut cap = Capture::default();
285 let mut url: Option<String> = None;
286 let mut body = false;
287 let mut args = it.peekable();
288 while let Some(a) = args.next() {
289 let take = |args: &mut std::iter::Peekable<std::vec::IntoIter<String>>| args.next();
290 match a.as_str() {
291 "-H" | "--header" => {
292 if let Some(h) = take(&mut args) {
293 if let Some((k, v)) = h.split_once(':') {
294 let (k, v) = (k.trim(), v.trim());
295 if k.eq_ignore_ascii_case("cookie") {
296 cookie_pairs(v, &mut cap.cookies, &mut cap.dropped);
297 } else {
298 keep_header(k, v, &mut cap);
299 }
300 }
301 }
302 }
303 "-b" | "--cookie" => {
304 if let Some(v) = take(&mut args) {
305 if v.contains('=') {
306 cookie_pairs(&v, &mut cap.cookies, &mut cap.dropped);
307 } else {
308 cap.dropped.push("cookie file reference (-b FILE)".into());
309 }
310 }
311 }
312 "-A" | "--user-agent" => cap.user_agent = take(&mut args),
313 "-d" | "--data" | "--data-raw" | "--data-binary" | "--data-urlencode" | "-F"
314 | "--form" => {
315 let _ = take(&mut args);
316 body = true;
317 }
318 "-X" | "--request" | "-e" | "--referer" | "-o" | "--output" | "-m" | "--max-time"
319 | "--connect-timeout" | "-x" | "--proxy" | "-u" | "--user" => {
320 if matches!(a.as_str(), "-u" | "--user") {
321 cap.dropped.push("basic-auth credentials (-u)".into());
322 }
323 let _ = take(&mut args);
324 }
325 "--url" => url = take(&mut args),
326 s if s.starts_with('-') => {} s => {
328 if url.is_none() {
329 url = Some(s.to_string());
330 }
331 }
332 }
333 }
334 if body {
335 cap.dropped.push("request body".into());
336 }
337 cap.origin = url.as_deref().and_then(origin_of);
338 if cap.origin.is_none() {
339 return Err("no http(s) URL in the curl command".into());
340 }
341 Ok(cap)
342}
343
344pub fn parse_powershell(text: &str) -> Result<Capture, String> {
346 if !text.contains("Invoke-WebRequest") && !text.contains("Invoke-RestMethod") {
347 return Err("not a PowerShell Invoke-WebRequest".into());
348 }
349 let mut cap = Capture::default();
350 let unesc = |s: &str| s.replace("`\"", "\"").replace("``", "`").replace("`$", "$");
352 let quoted = |hay: &str, from: usize| -> Option<(String, usize)> {
353 let rest = &hay[from..];
354 let open = rest.find('"')?;
355 let bytes = rest.as_bytes();
356 let mut i = open + 1;
357 while i < bytes.len() {
358 if bytes[i] == b'`' {
359 i += 2;
360 continue;
361 }
362 if bytes[i] == b'"' {
363 return Some((unesc(&rest[open + 1..i]), from + i + 1));
364 }
365 i += 1;
366 }
367 None
368 };
369 let mut at = 0;
371 while let Some(p) = text[at..].find("System.Net.Cookie(") {
372 let start = at + p + "System.Net.Cookie(".len();
373 let Some((name, n1)) = quoted(text, start) else {
374 break;
375 };
376 let Some((value, n2)) = quoted(text, n1) else {
377 break;
378 };
379 let path = quoted(text, n2);
380 let (path_v, n3) = path.map_or((None, n2), |(p, n)| (Some(p), n));
381 let dom = quoted(text, n3);
382 let (dom_v, n4) = dom.map_or((None, n3), |(d, n)| (Some(d), n));
383 if valid_cookie_value(&value) {
384 upsert(
385 &mut cap.cookies,
386 Cookie {
387 name,
388 value,
389 path: path_v,
390 domain: dom_v,
391 ..Default::default()
392 },
393 );
394 } else {
395 cap.dropped
396 .push(format!("cookie {name} (illegal character in value)"));
397 }
398 at = n4;
399 }
400 if let Some(h) = text.find("-Headers @{") {
402 let mut pos = h + "-Headers @{".len();
403 while let Some((k, n)) = quoted(text, pos) {
404 if text[pos..n].contains('}') {
406 break;
407 }
408 let Some((v, n2)) = quoted(text, n) else {
409 break;
410 };
411 keep_header(&k, &v, &mut cap);
412 pos = n2;
413 if text[pos..].trim_start().starts_with('}') {
414 break;
415 }
416 }
417 }
418 if let Some(u) = text.find("-Uri ") {
419 if let Some((url, _)) = quoted(text, u) {
420 cap.origin = origin_of(&url);
421 }
422 }
423 if let Some(ua) = text.find("-UserAgent ") {
424 if let Some((v, _)) = quoted(text, ua) {
425 cap.user_agent = Some(v);
426 }
427 }
428 if text.contains("-Body ") {
429 cap.dropped.push("request body".into());
430 }
431 if cap.origin.is_none() {
432 return Err("no -Uri in the PowerShell command".into());
433 }
434 Ok(cap)
435}
436
437pub fn parse_har(text: &str, origin: Option<&str>) -> Result<Capture, String> {
444 if text.len() > MAX_INPUT {
445 return Err("HAR is larger than 32 MiB; export a smaller capture".into());
446 }
447 let doc: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
448 let entries = doc
449 .pointer("/log/entries")
450 .and_then(Value::as_array)
451 .ok_or("not a HAR: no log.entries")?;
452
453 let origin_for = |e: &Value| {
454 e.pointer("/request/url")
455 .and_then(Value::as_str)
456 .and_then(origin_of)
457 };
458 let want = match origin {
459 Some(o) => origin_of(o).ok_or_else(|| format!("'{o}' is not an http(s) origin"))?,
460 None => {
461 let mut seen: Vec<String> = Vec::new();
462 for e in entries {
463 if let Some(o) = origin_for(e) {
464 if !seen.contains(&o) {
465 seen.push(o);
466 }
467 }
468 }
469 match seen.len() {
470 0 => return Err("the HAR has no http(s) requests".into()),
471 1 => seen.remove(0),
472 _ => {
473 return Err(format!(
474 "the HAR touches {} origins; pass --origin one of: {}",
475 seen.len(),
476 seen.join(", ")
477 ))
478 }
479 }
480 }
481 };
482
483 let mut cap = Capture {
484 origin: Some(want.clone()),
485 ..Default::default()
486 };
487 let mut other_hosts: Vec<String> = Vec::new();
488 for e in entries {
489 let Some(o) = origin_for(e) else { continue };
490 if o != want {
491 let host = host_of(&o).to_string();
492 if !other_hosts.contains(&host) {
493 other_hosts.push(host);
494 }
495 continue;
496 }
497 let req = &e["request"];
498 for c in req["cookies"].as_array().into_iter().flatten() {
499 let (Some(n), Some(v)) = (c["name"].as_str(), c["value"].as_str()) else {
500 continue;
501 };
502 if valid_cookie_value(v) {
503 upsert(
504 &mut cap.cookies,
505 Cookie {
506 name: n.into(),
507 value: v.into(),
508 ..Default::default()
509 },
510 );
511 } else {
512 cap.dropped
513 .push(format!("cookie {n} (illegal character in value)"));
514 }
515 }
516 for c in e["response"]["cookies"].as_array().into_iter().flatten() {
518 let (Some(n), Some(v)) = (c["name"].as_str(), c["value"].as_str()) else {
519 continue;
520 };
521 if !valid_cookie_value(v) {
522 continue;
523 }
524 let expires = c["expires"]
525 .as_str()
526 .and_then(|s| {
527 time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
528 .ok()
529 })
530 .map_or(0, |d| d.unix_timestamp());
531 upsert(
532 &mut cap.cookies,
533 Cookie {
534 name: n.into(),
535 value: v.into(),
536 domain: c["domain"].as_str().map(String::from),
537 path: c["path"].as_str().map(String::from),
538 secure: c["secure"].as_bool().unwrap_or(false),
539 http_only: c["httpOnly"].as_bool().unwrap_or(false),
540 expires,
541 partitioned: false,
542 },
543 );
544 }
545 for h in req["headers"].as_array().into_iter().flatten() {
546 if let (Some(n), Some(v)) = (h["name"].as_str(), h["value"].as_str()) {
547 if n.starts_with(':') {
548 continue; }
550 cap.headers.retain(|(k, _)| !k.eq_ignore_ascii_case(n));
552 keep_header(n, v, &mut cap);
553 }
554 }
555 if req.get("postData").is_some() && !cap.dropped.iter().any(|d| d == "request body") {
556 cap.dropped.push("request body".into());
557 }
558 }
559 if !other_hosts.is_empty() {
560 cap.dropped.push(format!(
561 "requests to {} other host(s): {}",
562 other_hosts.len(),
563 other_hosts.join(", ")
564 ));
565 }
566 Ok(cap)
567}
568
569fn parse_http_date(s: &str) -> Option<i64> {
572 if s.len() > 64 || s.contains(['(', ')']) {
577 return None;
578 }
579 time::OffsetDateTime::parse(s.trim(), &time::format_description::well_known::Rfc2822)
580 .ok()
581 .map(|d| d.unix_timestamp())
582 .or_else(|| {
583 let fixed = s.trim().replace('-', " ");
585 time::OffsetDateTime::parse(&fixed, &time::format_description::well_known::Rfc2822)
586 .ok()
587 .map(|d| d.unix_timestamp())
588 })
589}
590
591pub fn parse_set_cookie(text: &str, now_unix: i64) -> Result<Capture, String> {
593 let mut cap = Capture::default();
594 for line in text.lines() {
595 let line = line.trim();
596 let line = line
597 .strip_prefix("Set-Cookie:")
598 .or_else(|| line.strip_prefix("set-cookie:"))
599 .unwrap_or(line)
600 .trim();
601 if line.is_empty() || line.starts_with('#') {
602 continue;
603 }
604 let mut parts = line.split(';');
605 let Some((name, value)) = parts.next().and_then(|p| p.split_once('=')) else {
606 continue;
607 };
608 let (name, value) = (name.trim(), value.trim());
609 if name.is_empty() {
610 continue;
611 }
612 if !valid_cookie_value(value) {
613 cap.dropped
614 .push(format!("cookie {name} (illegal character in value)"));
615 continue;
616 }
617 let mut c = Cookie {
618 name: name.into(),
619 value: value.into(),
620 ..Default::default()
621 };
622 let mut max_age: Option<i64> = None;
623 for attr in parts {
624 let (k, v) = attr
625 .split_once('=')
626 .map_or((attr.trim(), ""), |(k, v)| (k.trim(), v.trim()));
627 match k.to_ascii_lowercase().as_str() {
628 "domain" => {
629 c.domain = Some(v.trim_start_matches('.').to_string())
630 .filter(|d| !d.is_empty())
631 .map(|d| format!(".{d}"))
632 }
633 "path" => c.path = Some(v.to_string()),
634 "secure" => c.secure = true,
635 "httponly" => c.http_only = true,
636 "partitioned" => c.partitioned = true,
637 "max-age" => max_age = v.parse().ok(),
638 "expires" => c.expires = parse_http_date(v).unwrap_or(0),
639 _ => {}
640 }
641 }
642 if let Some(m) = max_age {
644 c.expires = if m <= 0 { 1 } else { now_unix + m };
645 }
646 if name.starts_with("__Host-")
649 && (!c.secure || c.path.as_deref() != Some("/") || c.domain.is_some())
650 {
651 cap.dropped
652 .push(format!("cookie {name} (violates the __Host- prefix rules)"));
653 continue;
654 }
655 if name.starts_with("__Secure-") && !c.secure {
656 cap.dropped.push(format!(
657 "cookie {name} (violates the __Secure- prefix rules)"
658 ));
659 continue;
660 }
661 upsert(&mut cap.cookies, c);
662 }
663 if cap.cookies.is_empty() && cap.dropped.is_empty() {
664 return Err("no Set-Cookie lines found".into());
665 }
666 Ok(cap)
667}
668
669pub fn read_firefox(path: &std::path::Path, host: &str) -> Result<Capture, String> {
679 let dir = std::env::temp_dir().join(format!(
680 "unv-ff-{}-{}",
681 std::process::id(),
682 crate::new_uuid()
683 ));
684 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
685 let result = (|| {
686 let copy = dir.join("cookies.sqlite");
687 std::fs::copy(path, ©).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
688 let wal = path.with_extension("sqlite-wal");
689 if wal.exists() {
690 let _ = std::fs::copy(&wal, dir.join("cookies.sqlite-wal"));
691 }
692 let conn = rusqlite::Connection::open_with_flags(
693 ©,
694 rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE, )
696 .map_err(|e| e.to_string())?;
697 let mut stmt = conn
698 .prepare(
699 "SELECT name, value, host, path, expiry, isSecure, isHttpOnly, originAttributes \
700 FROM moz_cookies WHERE host = ?1 OR host = ?2 OR host LIKE ?3",
701 )
702 .map_err(|e| format!("not a Firefox cookies.sqlite: {e}"))?;
703 let host = host.trim_start_matches('.');
704 let rows = stmt
705 .query_map(
706 rusqlite::params![host, format!(".{host}"), format!("%.{host}")],
707 |r| {
708 Ok(Cookie {
709 name: r.get(0)?,
710 value: r.get(1)?,
711 domain: Some(r.get::<_, String>(2)?),
712 path: Some(r.get::<_, String>(3)?),
713 expires: {
715 let e: i64 = r.get(4)?;
716 if e > 100_000_000_000 {
717 e / 1000
718 } else {
719 e
720 }
721 },
722 secure: r.get::<_, i64>(5)? != 0,
723 http_only: r.get::<_, i64>(6)? != 0,
724 partitioned: r.get::<_, String>(7)?.contains("partitionKey="),
725 })
726 },
727 )
728 .map_err(|e| e.to_string())?;
729 let mut cap = Capture::default();
730 for row in rows {
731 let c = row.map_err(|e| e.to_string())?;
732 if valid_cookie_value(&c.value) {
733 upsert(&mut cap.cookies, c);
734 } else {
735 cap.dropped
736 .push(format!("cookie {} (illegal character in value)", c.name));
737 }
738 }
739 Ok(cap)
740 })();
741 let _ = std::fs::remove_dir_all(&dir);
742 result
743}
744
745pub const CHROME_REFUSAL: &str =
747 "Chrome cookies are not read. Since Chrome 127 on Windows, app-bound encryption ties cookie \
748 decryption to the Chrome process, so no outside tool can read them (yt-dlp's \
749 --cookies-from-browser fails the same way). Export from DevTools (Copy as cURL, or Save all \
750 as HAR) or use Firefox.";
751
752pub fn detect(text: &str) -> &'static str {
754 let t = text.trim_start();
755 if t.starts_with("curl") {
756 "curl"
757 } else if t.contains("Invoke-WebRequest") || t.contains("Invoke-RestMethod") {
758 "powershell"
759 } else if t.starts_with('{') && t.contains("\"log\"") {
760 "har"
761 } else {
762 "set-cookie"
763 }
764}
765
766pub fn parse_auto(text: &str, origin: Option<&str>, now_unix: i64) -> Result<Capture, String> {
768 match detect(text) {
769 "curl" => parse_curl(text),
770 "powershell" => parse_powershell(text),
771 "har" => parse_har(text, origin),
772 _ => parse_set_cookie(text, now_unix),
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 const BASH: &str = r#"curl 'https://www.example.com/api/me?x=1' \
781 -H 'accept: application/json' \
782 -H 'authorization: Bearer topsecret' \
783 -H 'cookie: sid=abc123; csrftoken=zz9; bad=a b' \
784 -H 'user-agent: Mozilla/5.0 (X11; Linux x86_64)' \
785 -H 'sec-fetch-mode: cors' \
786 -H 'x-csrf-token: zz9' \
787 --data-raw '{"a":1}' \
788 --compressed"#;
789
790 #[test]
791 fn bash_curl_keeps_the_origins_session_and_names_what_it_dropped() {
792 let c = parse_curl(BASH).unwrap();
793 assert_eq!(c.origin.as_deref(), Some("https://www.example.com"));
794 assert_eq!(
795 c.cookies
796 .iter()
797 .map(|c| c.name.as_str())
798 .collect::<Vec<_>>(),
799 ["sid", "csrftoken"]
800 );
801 assert_eq!(
802 c.user_agent.as_deref(),
803 Some("Mozilla/5.0 (X11; Linux x86_64)")
804 );
805 let names: Vec<_> = c.headers.iter().map(|(k, _)| k.as_str()).collect();
806 assert_eq!(
807 names,
808 ["accept", "x-csrf-token"],
809 "noise and credentials are not kept"
810 );
811 assert!(c.dropped.iter().any(|d| d == "authorization header"));
813 assert!(c.dropped.iter().any(|d| d == "request body"));
814 assert!(c
815 .dropped
816 .iter()
817 .any(|d| d.contains("bad") && d.contains("illegal")));
818 assert!(!format!("{:?}", c.dropped).contains("topsecret"));
819 }
820
821 #[test]
822 fn cmd_flavoured_curl_is_uncaret_ed() {
823 let cmd = "curl ^\"https://www.example.com/^\" ^\n -H ^\"cookie: sid=abc^\" ^\n -H ^\"user-agent: UA/1^\"";
824 let c = parse_curl(cmd).unwrap();
825 assert_eq!(c.origin.as_deref(), Some("https://www.example.com"));
826 assert_eq!(c.cookies[0].value, "abc");
827 assert_eq!(c.user_agent.as_deref(), Some("UA/1"));
828 }
829
830 #[test]
831 fn ansi_c_quoting_is_decoded() {
832 let c = parse_curl("curl 'https://e.test/' -H $'user-agent: caf\\u00e9'").unwrap();
833 assert_eq!(c.user_agent.as_deref(), Some("café"));
834 }
835
836 #[test]
837 fn powershell_capture_yields_cookies_headers_and_origin() {
838 let ps = r#"$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
839$session.UserAgent = "x"
840$session.Cookies.Add((New-Object System.Net.Cookie("sid", "abc", "/", "www.example.com")))
841Invoke-WebRequest -UseBasicParsing -Uri "https://www.example.com/a" `
842-WebSession $session `
843-Headers @{
844"accept"="text/html"
845"authorization"="Bearer nope"
846}"#;
847 let c = parse_powershell(ps).unwrap();
848 assert_eq!(c.origin.as_deref(), Some("https://www.example.com"));
849 assert_eq!(c.cookies[0].name, "sid");
850 assert_eq!(c.cookies[0].domain.as_deref(), Some("www.example.com"));
851 assert_eq!(
852 c.headers,
853 vec![("accept".to_string(), "text/html".to_string())]
854 );
855 assert!(c.dropped.iter().any(|d| d == "authorization header"));
856 }
857
858 fn har() -> String {
859 serde_json::json!({"log":{"entries":[
860 {"request":{"url":"https://www.example.com/a","headers":[
861 {"name":":authority","value":"x"},{"name":"user-agent","value":"UA"},
862 {"name":"authorization","value":"Bearer t"},{"name":"x-csrf-token","value":"old"}],
863 "cookies":[{"name":"sid","value":"1"}]},
864 "response":{"cookies":[{"name":"sid","value":"2","domain":".example.com","path":"/","secure":true,"httpOnly":true,"expires":"2030-01-01T00:00:00Z"}]}},
865 {"request":{"url":"https://www.example.com/b","headers":[{"name":"x-csrf-token","value":"new"}],
866 "cookies":[{"name":"csrf","value":"c"}]},"response":{"cookies":[]}},
867 {"request":{"url":"https://cdn.tracker.test/p","headers":[],"cookies":[{"name":"t","value":"9"}]},"response":{"cookies":[]}}
868 ]}}).to_string()
869 }
870
871 #[test]
872 fn har_keeps_one_origin_and_lists_the_others_it_dropped() {
873 let c = parse_har(&har(), Some("https://www.example.com")).unwrap();
874 assert!(
875 c.cookies.iter().all(|c| c.name != "t"),
876 "a CDN's cookie must not be kept"
877 );
878 let sid = c.cookies.iter().find(|c| c.name == "sid").unwrap();
879 assert_eq!(
880 sid.value, "2",
881 "the response cookie's value and attributes win"
882 );
883 assert!(sid.secure && sid.http_only && sid.expires > 0);
884 assert_eq!(
885 c.headers,
886 vec![("x-csrf-token".to_string(), "new".to_string())]
887 );
888 assert!(c.dropped.iter().any(|d| d.contains("cdn.tracker.test")));
889 assert!(c.dropped.iter().any(|d| d == "authorization header"));
890 }
891
892 #[test]
893 fn har_with_several_origins_asks_instead_of_guessing() {
894 let err = parse_har(&har(), None).unwrap_err();
895 assert!(err.contains("--origin") && err.contains("cdn.tracker.test"));
896 }
897
898 #[test]
899 fn set_cookie_attributes_and_prefix_rules() {
900 let now = 1_700_000_000;
901 let c = parse_set_cookie(
902 "Set-Cookie: sid=abc; Domain=example.com; Path=/; Secure; HttpOnly; Max-Age=3600; Partitioned\n\
903 Set-Cookie: __Host-x=1; Domain=example.com; Path=/; Secure\n\
904 Set-Cookie: old=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT",
905 now,
906 )
907 .unwrap();
908 let sid = &c.cookies[0];
909 assert_eq!(sid.domain.as_deref(), Some(".example.com"));
910 assert_eq!(sid.expires, now + 3600, "Max-Age beats Expires");
911 assert!(sid.secure && sid.http_only && sid.partitioned);
912 assert!(c.cookies.iter().all(|c| c.name != "__Host-x"));
913 assert!(c.dropped.iter().any(|d| d.contains("__Host-")));
914 assert_eq!(
915 c.cookies.iter().find(|c| c.name == "old").unwrap().expires,
916 1_445_412_480
917 );
918 }
919
920 #[test]
921 fn a_hostile_expires_value_cannot_exhaust_the_stack() {
922 let nested = format!(
923 "Wed, 21 Oct 2015 07:28:00 GMT {}{}",
924 "(".repeat(200_000),
925 ")".repeat(200_000)
926 );
927 let c = parse_set_cookie(&format!("Set-Cookie: a=b; Expires={nested}"), 0).unwrap();
928 assert_eq!(
929 c.cookies[0].expires, 0,
930 "an unparseable date is a session cookie, not a crash"
931 );
932 }
933
934 #[test]
935 fn detect_routes_each_dialect() {
936 assert_eq!(detect("curl 'https://a.test'"), "curl");
937 assert_eq!(
938 detect("Invoke-WebRequest -Uri \"https://a.test\""),
939 "powershell"
940 );
941 assert_eq!(detect("{\"log\":{}}"), "har");
942 assert_eq!(detect("Set-Cookie: a=b"), "set-cookie");
943 }
944
945 #[test]
946 fn firefox_sqlite_is_read_from_a_copy_and_filtered_by_host() {
947 let dir = std::env::temp_dir().join(format!("unv-ffsrc-{}", crate::new_uuid()));
948 std::fs::create_dir_all(&dir).unwrap();
949 let db = dir.join("cookies.sqlite");
950 {
951 let c = rusqlite::Connection::open(&db).unwrap();
952 c.execute_batch(
953 "CREATE TABLE moz_cookies (name TEXT, value TEXT, host TEXT, path TEXT, expiry INTEGER, isSecure INTEGER, isHttpOnly INTEGER, originAttributes TEXT);
954 INSERT INTO moz_cookies VALUES ('sid','1','.example.com','/',1893456000,1,1,'');
955 INSERT INTO moz_cookies VALUES ('p','2','www.example.com','/',1893456000000,0,0,'^partitionKey=%28https%2Cexample.com%29');
956 INSERT INTO moz_cookies VALUES ('other','3','.evil.test','/',0,0,0,'');",
957 )
958 .unwrap();
959 }
960 let cap = read_firefox(&db, "example.com").unwrap();
961 assert_eq!(cap.cookies.len(), 2);
962 assert!(cap.cookies.iter().all(|c| c.name != "other"));
963 let p = cap.cookies.iter().find(|c| c.name == "p").unwrap();
964 assert!(p.partitioned);
965 assert_eq!(p.expires, 1_893_456_000, "millisecond expiry is normalised");
966 assert!(db.exists(), "the live file is never modified");
967 let _ = std::fs::remove_dir_all(&dir);
968 }
969}