parse_rust_server/cors.rs
1//! `allowCrossDomain`: the CORS headers every response carries, and the preflight answer.
2//!
3//! Upstream mounts this as the **first** middleware on the API router (`ParseServer.ts:312`), and
4//! the ordering matters: the headers are set on every response, including error responses, and the
5//! `OPTIONS` short-circuit happens before anything else can reject the request.
6//!
7//! **Why this is not optional, given that the JavaScript SDK never triggers a preflight.** The SDK
8//! sends everything as a `text/plain` `POST` precisely to stay inside the CORS "simple request"
9//! rules, so no `OPTIONS` is ever issued. That is the fact that makes it tempting to skip this
10//! layer, and it is only half the mechanism: a simple request is *sent* without a preflight, but
11//! the browser still refuses to hand the **response** to the page unless
12//! `Access-Control-Allow-Origin` is on it. Without these headers a browser-hosted SDK sees a
13//! network error on every call while the server log shows 200s. Any client that does preflight,
14//! which includes parse-dashboard and anything sending `X-Parse-*` headers directly, gets a 405
15//! from the router instead, because `OPTIONS` is registered on no route.
16//!
17//! The four headers and the exact `OPTIONS` behavior are reproduced from `middlewares.js:399-422`.
18
19use axum::extract::Request;
20use axum::middleware::Next;
21use axum::response::{IntoResponse, Response};
22use http::header::HeaderValue;
23use http::StatusCode;
24
25use crate::state::AppState;
26
27/// `DEFAULT_ALLOWED_HEADERS` (`middlewares.js:18-19`), verbatim and in upstream's order.
28///
29/// **The order and spelling are contract in practice even though CORS is case-insensitive**, since
30/// this string is echoed to the browser and some proxies match on it literally. `X-Requested-With`
31/// is in the list and is not a Parse header; it is there because older clients send it.
32pub const DEFAULT_ALLOWED_HEADERS: &str = "X-Parse-Master-Key, X-Parse-REST-API-Key, \
33 X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, \
34 X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, X-Parse-Request-Id, \
35 Content-Type, Pragma, Cache-Control";
36
37/// `Access-Control-Allow-Methods` (`middlewares.js:413`).
38///
39/// Upstream's literal, which notably does **not** include `PATCH`. parse-rust serves no `PATCH`
40/// route either, so advertising one would be a promise the router does not keep.
41const ALLOW_METHODS: &str = "GET,PUT,POST,DELETE,OPTIONS";
42
43/// `Access-Control-Expose-Headers` (`middlewares.js:415`).
44///
45/// Both headers belong to subsystems parse-rust does not have yet, and both are listed anyway.
46/// This value tells a browser which response headers a page may *read*; a client that stops seeing
47/// a header it used to see is a broken client, so the list is the one place where advertising
48/// ahead of the implementation is the compatible choice rather than a false claim.
49const EXPOSE_HEADERS: &str = "X-Parse-Job-Status-Id, X-Parse-Push-Status-Id";
50
51/// Set the CORS headers on every response, and answer a preflight directly.
52///
53/// `Access-Control-Allow-Origin` follows upstream's rule exactly (`middlewares.js:407-412`): the
54/// configured list defaults to `["*"]`, and if the request's `Origin` is in the list it is echoed
55/// back, otherwise the **first** configured entry is sent. Echoing the request origin rather than
56/// sending the list is required, because the header takes one value and a browser compares it to
57/// its own origin.
58///
59/// Note what this does not do: there is no `Access-Control-Allow-Credentials`, because upstream
60/// does not send one. Parse carries its credentials in headers or in the body rather than in
61/// cookies, so the browser never needs to be told to attach them, and sending it would make `*` an
62/// illegal origin value.
63pub async fn layer(
64 axum::extract::State(state): axum::extract::State<AppState>,
65 request: Request,
66 next: Next,
67) -> Response {
68 let config = state.config();
69 let allow_origin = resolve_origin(
70 &config.allow_origin,
71 request.headers().get(http::header::ORIGIN),
72 );
73 let allow_headers = join_allowed_headers(&config.allow_headers);
74
75 let mut response = if request.method() == http::Method::OPTIONS {
76 // `res.sendStatus(200)` (`middlewares.js:418`): the preflight is answered here and never
77 // reaches the router. Without this it would 405, because no route registers `OPTIONS`.
78 //
79 // `sendStatus` writes the status's reason phrase as the body, so upstream's preflight
80 // carries `OK` rather than nothing. No browser reads a preflight body, but a differential
81 // runner comparing bytes does.
82 (StatusCode::OK, "OK".to_string()).into_response()
83 } else {
84 next.run(request).await
85 };
86
87 let headers = response.headers_mut();
88 for (name, value) in [
89 (http::header::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin),
90 (
91 http::header::ACCESS_CONTROL_ALLOW_METHODS,
92 ALLOW_METHODS.to_string(),
93 ),
94 (http::header::ACCESS_CONTROL_ALLOW_HEADERS, allow_headers),
95 (
96 http::header::ACCESS_CONTROL_EXPOSE_HEADERS,
97 EXPOSE_HEADERS.to_string(),
98 ),
99 ] {
100 // A configured value that cannot be a header value is dropped rather than panicking. This
101 // is a request path, and a bad `allowOrigin` in the config must not take a worker down.
102 if let Ok(value) = HeaderValue::from_str(&value) {
103 headers.insert(name, value);
104 }
105 }
106 response
107}
108
109/// Upstream's origin selection (`middlewares.js:407-412`).
110///
111/// The `unwrap_or("*")` is the unconfigured default only. A configured list containing one empty
112/// string is **not** an empty list: it echoes back an empty origin, which matches no browser, and
113/// that is how an operator turns browser access off. See `list` in the CLI, which must not drop
114/// that entry.
115fn resolve_origin(configured: &[String], request_origin: Option<&HeaderValue>) -> String {
116 // **An empty list is not an absent one.** Upstream's `config?.allowOrigin ?? ['*']` falls back
117 // only on null or undefined, so an explicit `allowOrigin: []` stays empty and `baseOrigins[0]`
118 // is `undefined`: the header does not name an origin, and no browser matches it. Defaulting an
119 // empty list to `*` here reverses an operator's closed configuration into an open one, which is
120 // the one direction a CORS bug must never fail in. The *unconfigured* default is still `*`, and
121 // it is carried by `ServerConfig`'s `vec!["*"]` rather than by this fallback.
122 let first = configured.first().map(String::as_str).unwrap_or("");
123 let Some(origin) = request_origin.and_then(|v| v.to_str().ok()) else {
124 return first.to_string();
125 };
126 if configured.iter().any(|allowed| allowed == origin) {
127 origin.to_string()
128 } else {
129 first.to_string()
130 }
131}
132
133/// `DEFAULT_ALLOWED_HEADERS` plus the configured additions (`middlewares.js:402-405`).
134fn join_allowed_headers(extra: &[String]) -> String {
135 if extra.is_empty() {
136 return DEFAULT_ALLOWED_HEADERS.to_string();
137 }
138 format!("{DEFAULT_ALLOWED_HEADERS}, {}", extra.join(", "))
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn origin(value: &str) -> HeaderValue {
146 HeaderValue::from_str(value).expect("test literal")
147 }
148
149 #[test]
150 fn the_default_list_is_upstreams_twelve_headers() {
151 let names: Vec<&str> = DEFAULT_ALLOWED_HEADERS.split(", ").collect();
152 assert_eq!(names.len(), 12, "{DEFAULT_ALLOWED_HEADERS}");
153 // The four an SDK cannot work without, spelled as upstream spells them.
154 for required in [
155 "X-Parse-Application-Id",
156 "X-Parse-Session-Token",
157 "X-Parse-Master-Key",
158 "Content-Type",
159 ] {
160 assert!(names.contains(&required), "missing {required}");
161 }
162 // `Javascript`, not `JavaScript`. Upstream's casing, and a client matching the string
163 // literally would see the difference.
164 assert!(names.contains(&"X-Parse-Javascript-Key"));
165 }
166
167 #[test]
168 fn an_unconfigured_server_allows_every_origin() {
169 // The wildcard default is carried by `ServerConfig`'s `vec!["*"]`, not by this function.
170 assert_eq!(
171 resolve_origin(&["*".to_string()], Some(&origin("https://app.example"))),
172 "*"
173 );
174 assert_eq!(resolve_origin(&["*".to_string()], None), "*");
175 }
176
177 /// An **explicitly empty** allowlist is a closed one, not an unconfigured one.
178 ///
179 /// This asserted `*` until a review, which is the fail-open direction: an operator who sets
180 /// `allowOrigin: []` to shut browsers out got every origin allowed instead. Upstream's
181 /// `?? ['*']` fires only on null or undefined, so an explicit `[]` leaves the header naming no
182 /// origin.
183 #[test]
184 fn an_explicitly_empty_allowlist_is_closed_not_open() {
185 assert_eq!(resolve_origin(&[], None), "");
186 assert_eq!(
187 resolve_origin(&[], Some(&origin("https://app.example"))),
188 ""
189 );
190 }
191
192 /// The rule that makes a configured allowlist work: a browser compares the header against its
193 /// own origin, so a listed origin has to be echoed rather than the list returned.
194 #[test]
195 fn a_listed_origin_is_echoed_and_an_unlisted_one_gets_the_first_entry() {
196 let configured = vec![
197 "https://a.example".to_string(),
198 "https://b.example".to_string(),
199 ];
200 assert_eq!(
201 resolve_origin(&configured, Some(&origin("https://b.example"))),
202 "https://b.example"
203 );
204 assert_eq!(
205 resolve_origin(&configured, Some(&origin("https://evil.example"))),
206 "https://a.example",
207 "an unlisted origin must not be echoed back"
208 );
209 assert_eq!(resolve_origin(&configured, None), "https://a.example");
210 }
211
212 #[test]
213 fn configured_headers_append_to_the_defaults_rather_than_replacing_them() {
214 let joined = join_allowed_headers(&["X-Custom".to_string(), "X-Other".to_string()]);
215 assert!(joined.starts_with(DEFAULT_ALLOWED_HEADERS));
216 assert!(joined.ends_with("X-Custom, X-Other"));
217 assert_eq!(join_allowed_headers(&[]), DEFAULT_ALLOWED_HEADERS);
218 }
219}