Skip to main content

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}