Skip to content

Commit b0bcef3

Browse files
fix(cors): allow Office add-in origins (addin.postguard.eu + localhost:3000) and DELETE method (#179)
* fix(cors): allow Office add-in origins + DELETE method Browser callers from the Office add-in were blocked by CORS when reaching /fileupload/* and /filedownload/*: the preflight returned no Access-Control-Allow-Origin because the production allowed_origins regex only matched the postguard.eu/nl website. - conf/config.toml: extend allowed_origins to also match https://addin.postguard.eu (Outlook prod) and https://localhost:3000 (Office add-in dev), keeping the existing postguard.(eu|nl) origins. - build_rocket CORS: add DELETE to allowed_methods so the preflight advertises GET, POST, PUT, DELETE. Content-Type and Authorization are already in the allowed-headers list. - Add integration tests over the real build_rocket CORS fairing asserting the preflight succeeds (echoes Allow-Origin, advertises all four methods and the required headers) for both add-in origins, and is rejected for an unlisted origin. Refs encryption4all/postguard#154 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(test): correct misleading PROD_ALLOWED_ORIGINS comment The comment claimed a config typo would fail the suite, but the CORS tests use this hand-maintained copy and never read conf/config.toml. Reword to state it is a copy that must be kept in sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7f6bfb3 commit b0bcef3

2 files changed

Lines changed: 111 additions & 2 deletions

File tree

conf/config.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@ smtp_url = "mailcrab"
77
smtp_port = 1025
88
# smtp_username = "user"
99
# smtp_password = "pw"
10-
allowed_origins = "^https://postguard.(eu|nl)$"
10+
# Browser callers that consume the upload/download API cross-origin.
11+
# `postguard.(eu|nl)` is the website; `addin.postguard.eu` is the Outlook
12+
# add-in (prod); `localhost:3000` is the Office add-in dev server.
13+
allowed_origins = "^https://(postguard\\.(eu|nl)|addin\\.postguard\\.eu|localhost:3000)$"
1114
pkg_url = "https://pkg.postguard.eu/"

src/main.rs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1250,7 +1250,7 @@ pub fn build_rocket(figment: Figment, vk: Parameters<VerifyingKey>) -> Rocket<Bu
12501250
let cors = CorsOptions::default()
12511251
.allowed_origins(AllowedOrigins::some_regex(&[config.allowed_origins()]))
12521252
.allowed_methods(
1253-
vec![Method::Get, Method::Post, Method::Put]
1253+
vec![Method::Get, Method::Post, Method::Put, Method::Delete]
12541254
.into_iter()
12551255
.map(From::from)
12561256
.collect(),
@@ -2578,6 +2578,112 @@ mod integration {
25782578
(client, dir)
25792579
}
25802580

2581+
/// Boot Rocket like [`test_client`] but with a caller-supplied
2582+
/// `allowed_origins` regex, so CORS-preflight behaviour can be exercised
2583+
/// against the exact regex shipped in `conf/config.toml`.
2584+
async fn cors_client(setup: &TestSetup, allowed_origins: &str) -> (Client, std::path::PathBuf) {
2585+
let (figment, dir) = test_figment();
2586+
let figment = figment.merge(("allowed_origins", allowed_origins.to_string()));
2587+
let vk = Parameters {
2588+
format_version: 0,
2589+
public_key: VerifyingKey(setup.ibs_pk.0.clone()),
2590+
};
2591+
let rocket = build_rocket(figment, vk);
2592+
let client = Client::tracked(rocket).await.expect("valid rocket");
2593+
(client, dir)
2594+
}
2595+
2596+
// A copy of the production CORS regex from `conf/config.toml`, used to
2597+
// assert the preflight shape (allowed origins, methods, headers) of the
2598+
// regex we actually ship for the Office add-in (encryption4all/postguard#154).
2599+
// This is a hand-maintained copy — the tests do NOT read `conf/config.toml`,
2600+
// so keep the two in sync when either changes.
2601+
const PROD_ALLOWED_ORIGINS: &str =
2602+
r"^https://(postguard\.(eu|nl)|addin\.postguard\.eu|localhost:3000)$";
2603+
2604+
#[rocket::async_test]
2605+
async fn cors_preflight_allows_addin_and_localhost_origins() {
2606+
let mut rng = rand08::thread_rng();
2607+
let setup = TestSetup::new(&mut rng);
2608+
let (client, dir) = cors_client(&setup, PROD_ALLOWED_ORIGINS).await;
2609+
2610+
for origin in ["https://addin.postguard.eu", "https://localhost:3000"] {
2611+
let res = client
2612+
.req(rocket::http::Method::Options, "/fileupload/init")
2613+
.header(Header::new("Origin", origin))
2614+
.header(Header::new("Access-Control-Request-Method", "POST"))
2615+
.header(Header::new(
2616+
"Access-Control-Request-Headers",
2617+
"Content-Type, Authorization",
2618+
))
2619+
.dispatch()
2620+
.await;
2621+
2622+
// rocket_cors answers a valid preflight with a 2xx.
2623+
assert!(
2624+
res.status().code < 400,
2625+
"preflight from {origin} should succeed, got {}",
2626+
res.status()
2627+
);
2628+
assert_eq!(
2629+
res.headers().get_one("Access-Control-Allow-Origin"),
2630+
Some(origin),
2631+
"Allow-Origin should echo {origin}"
2632+
);
2633+
2634+
let allow_methods = res
2635+
.headers()
2636+
.get_one("Access-Control-Allow-Methods")
2637+
.expect("Allow-Methods in preflight")
2638+
.to_ascii_uppercase();
2639+
for m in ["GET", "POST", "PUT", "DELETE"] {
2640+
assert!(
2641+
allow_methods.contains(m),
2642+
"Allow-Methods `{allow_methods}` should include {m}"
2643+
);
2644+
}
2645+
2646+
let allow_headers = res
2647+
.headers()
2648+
.get_one("Access-Control-Allow-Headers")
2649+
.expect("Allow-Headers in preflight")
2650+
.to_ascii_lowercase();
2651+
for h in ["content-type", "authorization"] {
2652+
assert!(
2653+
allow_headers.contains(h),
2654+
"Allow-Headers `{allow_headers}` should include {h}"
2655+
);
2656+
}
2657+
}
2658+
2659+
let _ = std::fs::remove_dir_all(dir);
2660+
}
2661+
2662+
#[rocket::async_test]
2663+
async fn cors_preflight_rejects_unlisted_origin() {
2664+
let mut rng = rand08::thread_rng();
2665+
let setup = TestSetup::new(&mut rng);
2666+
let (client, dir) = cors_client(&setup, PROD_ALLOWED_ORIGINS).await;
2667+
2668+
let res = client
2669+
.req(rocket::http::Method::Options, "/fileupload/init")
2670+
.header(Header::new("Origin", "https://evil.example.com"))
2671+
.header(Header::new("Access-Control-Request-Method", "POST"))
2672+
.dispatch()
2673+
.await;
2674+
2675+
// A non-matching origin must not be granted access: rocket_cors omits
2676+
// the Allow-Origin header entirely for a rejected preflight.
2677+
assert!(
2678+
res.headers()
2679+
.get_one("Access-Control-Allow-Origin")
2680+
.is_none(),
2681+
"unlisted origin must not receive an Access-Control-Allow-Origin header"
2682+
);
2683+
2684+
let _ = std::fs::remove_dir_all(dir);
2685+
}
2686+
25812687
fn init_body_json(recipient: &str) -> String {
25822688
serde_json::json!({
25832689
"recipient": recipient,

0 commit comments

Comments
 (0)