Skip to content

Commit 972c1f0

Browse files
authored
Merge pull request #99 from encryption4all/fix/validate-cryptify-token-on-finalize
fix: validate cryptifytoken header in upload_finalize
2 parents dcf5344 + 448f82e commit 972c1f0

1 file changed

Lines changed: 180 additions & 6 deletions

File tree

src/main.rs

Lines changed: 180 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,15 @@ fn compute_hash(cryptify_token: &[u8], data: &[u8]) -> String {
266266
format!("{:x}", hash.finalize())
267267
}
268268

269+
fn check_cryptify_token(header: &str, expected: &str) -> Result<(), Error> {
270+
if header != expected {
271+
return Err(Error::BadRequest(Some(
272+
"Cryptify Token header does not match".to_owned(),
273+
)));
274+
}
275+
Ok(())
276+
}
277+
269278
#[put("/fileupload/<uuid>", data = "<data>")]
270279
async fn upload_chunk(
271280
config: &State<CryptifyConfig>,
@@ -319,11 +328,7 @@ async fn upload_chunk(
319328
}));
320329
}
321330

322-
if headers.cryptify_token != state.cryptify_token {
323-
return Err(Error::BadRequest(Some(
324-
"Cryptify Token header does not match".to_owned(),
325-
)));
326-
}
331+
check_cryptify_token(&headers.cryptify_token, &state.cryptify_token)?;
327332

328333
let mut file = match OpenOptions::new()
329334
.write(true)
@@ -364,6 +369,7 @@ async fn upload_chunk(
364369
}
365370

366371
struct FinalizeHeaders {
372+
cryptify_token: String,
367373
content_range: ContentRange,
368374
}
369375

@@ -374,6 +380,17 @@ impl<'r> FromRequest<'r> for FinalizeHeaders {
374380
async fn from_request(
375381
request: &'r rocket::Request<'_>,
376382
) -> rocket::request::Outcome<Self, Self::Error> {
383+
let cryptify_token = match request.headers().get_one("CryptifyToken") {
384+
Some(cryptify_token) => cryptify_token,
385+
None => {
386+
return rocket::request::Outcome::Error((
387+
rocket::http::Status::BadRequest,
388+
"Missing Cryptify Token header".into(),
389+
))
390+
}
391+
}
392+
.to_string();
393+
377394
let content_range = match request.headers().get_one("Content-Range") {
378395
Some(content_range) => content_range,
379396
None => {
@@ -390,7 +407,10 @@ impl<'r> FromRequest<'r> for FinalizeHeaders {
390407
return rocket::request::Outcome::Error((rocket::http::Status::BadRequest, e))
391408
}
392409
};
393-
rocket::request::Outcome::Success(FinalizeHeaders { content_range })
410+
rocket::request::Outcome::Success(FinalizeHeaders {
411+
cryptify_token,
412+
content_range,
413+
})
394414
}
395415
}
396416

@@ -408,6 +428,8 @@ async fn upload_finalize(
408428
};
409429
let mut state = state.lock().await;
410430

431+
check_cryptify_token(&headers.cryptify_token, &state.cryptify_token)?;
432+
411433
if headers.content_range.size != Some(state.uploaded) {
412434
return Err(Error::UnprocessableEntity(None));
413435
}
@@ -559,3 +581,155 @@ async fn rocket() -> _ {
559581
.manage(Store::new())
560582
.manage(vk)
561583
}
584+
585+
#[cfg(test)]
586+
mod tests {
587+
use super::*;
588+
use rocket::http::{Header, Status};
589+
use rocket::local::asynchronous::Client;
590+
591+
// Test-only route exercising the FinalizeHeaders extractor in isolation.
592+
// Echoes the extracted fields so the test can verify successful parsing.
593+
#[post("/__test/finalize_headers")]
594+
fn finalize_headers_echo(h: FinalizeHeaders) -> String {
595+
format!(
596+
"{}|{}",
597+
h.cryptify_token,
598+
h.content_range.size.unwrap_or(0)
599+
)
600+
}
601+
602+
async fn headers_client() -> Client {
603+
let r = rocket::build().mount("/", routes![finalize_headers_echo]);
604+
Client::tracked(r).await.expect("valid rocket")
605+
}
606+
607+
#[rocket::async_test]
608+
async fn finalize_headers_reject_missing_cryptify_token() {
609+
let client = headers_client().await;
610+
let res = client
611+
.post("/__test/finalize_headers")
612+
.header(Header::new("Content-Range", "bytes 0-99/100"))
613+
.dispatch()
614+
.await;
615+
assert_eq!(res.status(), Status::BadRequest);
616+
}
617+
618+
#[rocket::async_test]
619+
async fn finalize_headers_reject_missing_content_range() {
620+
let client = headers_client().await;
621+
let res = client
622+
.post("/__test/finalize_headers")
623+
.header(Header::new("CryptifyToken", "abc123"))
624+
.dispatch()
625+
.await;
626+
assert_eq!(res.status(), Status::BadRequest);
627+
}
628+
629+
#[rocket::async_test]
630+
async fn finalize_headers_reject_malformed_content_range() {
631+
let client = headers_client().await;
632+
let res = client
633+
.post("/__test/finalize_headers")
634+
.header(Header::new("CryptifyToken", "abc123"))
635+
.header(Header::new("Content-Range", "not a real range"))
636+
.dispatch()
637+
.await;
638+
assert_eq!(res.status(), Status::BadRequest);
639+
}
640+
641+
#[rocket::async_test]
642+
async fn finalize_headers_extract_both_headers() {
643+
let client = headers_client().await;
644+
let res = client
645+
.post("/__test/finalize_headers")
646+
.header(Header::new("CryptifyToken", "deadbeef"))
647+
.header(Header::new("Content-Range", "bytes 0-99/100"))
648+
.dispatch()
649+
.await;
650+
assert_eq!(res.status(), Status::Ok);
651+
assert_eq!(res.into_string().await.as_deref(), Some("deadbeef|100"));
652+
}
653+
654+
#[test]
655+
fn content_range_parses_well_formed_range() {
656+
let cr: ContentRange = "bytes 0-99/100".parse().unwrap();
657+
assert_eq!(cr.start, Some(0));
658+
assert_eq!(cr.end, Some(99));
659+
assert_eq!(cr.size, Some(100));
660+
}
661+
662+
#[test]
663+
fn content_range_accepts_wildcard_range() {
664+
let cr: ContentRange = "bytes */100".parse().unwrap();
665+
assert_eq!(cr.start, None);
666+
assert_eq!(cr.end, None);
667+
assert_eq!(cr.size, Some(100));
668+
}
669+
670+
#[test]
671+
fn content_range_accepts_wildcard_size() {
672+
let cr: ContentRange = "bytes 0-99/*".parse().unwrap();
673+
assert_eq!(cr.start, Some(0));
674+
assert_eq!(cr.end, Some(99));
675+
assert_eq!(cr.size, None);
676+
}
677+
678+
#[test]
679+
fn content_range_rejects_wrong_unit() {
680+
assert!("items 0-99/100".parse::<ContentRange>().is_err());
681+
}
682+
683+
#[test]
684+
fn content_range_rejects_empty_string() {
685+
assert!("".parse::<ContentRange>().is_err());
686+
}
687+
688+
#[test]
689+
fn check_cryptify_token_accepts_matching_token() {
690+
assert!(check_cryptify_token("abc123", "abc123").is_ok());
691+
}
692+
693+
#[test]
694+
fn check_cryptify_token_rejects_mismatched_token() {
695+
let result = check_cryptify_token("wrong", "expected");
696+
match result {
697+
Err(Error::BadRequest(Some(msg))) => {
698+
assert_eq!(msg, "Cryptify Token header does not match");
699+
}
700+
other => panic!("expected BadRequest, got {:?}", other),
701+
}
702+
}
703+
704+
#[test]
705+
fn check_cryptify_token_rejects_empty_header_when_token_expected() {
706+
assert!(matches!(
707+
check_cryptify_token("", "expected"),
708+
Err(Error::BadRequest(_))
709+
));
710+
}
711+
712+
#[test]
713+
fn check_cryptify_token_is_case_sensitive() {
714+
assert!(matches!(
715+
check_cryptify_token("ABC123", "abc123"),
716+
Err(Error::BadRequest(_))
717+
));
718+
}
719+
720+
#[test]
721+
fn compute_hash_is_deterministic() {
722+
let h1 = compute_hash(b"token", b"data");
723+
let h2 = compute_hash(b"token", b"data");
724+
assert_eq!(h1, h2);
725+
assert_eq!(h1.len(), 64);
726+
}
727+
728+
#[test]
729+
fn compute_hash_differs_for_different_tokens() {
730+
assert_ne!(
731+
compute_hash(b"token-a", b"data"),
732+
compute_hash(b"token-b", b"data")
733+
);
734+
}
735+
}

0 commit comments

Comments
 (0)