Skip to main content

parse_rust_rest/
guard.rs

1//! Guards applied to a client-supplied body before it reaches the pipeline.
2
3use parse_rust_core::{ParseError, ParseMap, ParseValue};
4
5/// Refuse a body that carries a server-internal column.
6///
7/// Server-internal columns are `_`-prefixed: `_hashed_password`, `_rperm`, `_wperm`,
8/// `_session_token`, `_perishable_token`. The schema layer deliberately does not validate them,
9/// because the server sets them itself. That makes this guard the only thing standing between a
10/// client and, for example, writing its own `_rperm` to grant itself read access, or supplying a
11/// `_hashed_password` it chose.
12///
13/// Applied at the REST boundary rather than in the schema layer so that the server's own writes,
14/// which legitimately carry these keys, do not have to route around their own validation.
15pub fn reject_reserved_keys(body: &ParseMap) -> Result<(), ParseError> {
16    reject_reserved_keys_in(body.keys().map(String::as_str))
17}
18
19/// The same guard over any key sequence, for a body that has already been decoded into
20/// [`crate::WriteBody`] and no longer has a `ParseMap` to hand.
21pub fn reject_reserved_keys_in<'a>(
22    keys: impl IntoIterator<Item = &'a str>,
23) -> Result<(), ParseError> {
24    for key in keys {
25        if key.starts_with('_') {
26            return Err(ParseError::invalid_key_name(format!(
27                "Invalid field name: {key}."
28            )));
29        }
30    }
31    Ok(())
32}
33
34#[cfg(test)]
35mod tests {
36    use super::*;
37    use parse_rust_core::ParseValue;
38
39    fn m(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
40        let mut map = ParseMap::new();
41        for (k, v) in pairs {
42            map.insert(k.to_string(), v);
43        }
44        map
45    }
46
47    #[test]
48    fn ordinary_fields_pass() {
49        assert!(reject_reserved_keys(&m(vec![
50            ("title", ParseValue::String("x".into())),
51            ("ACL", ParseValue::Null),
52        ]))
53        .is_ok());
54    }
55
56    /// The privilege-escalation cases this exists to stop.
57    #[test]
58    fn a_client_cannot_supply_a_server_internal_column() {
59        for key in [
60            "_hashed_password",
61            "_rperm",
62            "_wperm",
63            "_session_token",
64            "_perishable_token",
65            "_anything",
66        ] {
67            let e =
68                reject_reserved_keys(&m(vec![(key, ParseValue::String("x".into()))])).unwrap_err();
69            assert_eq!(e.code, parse_rust_core::ErrorCode::InvalidKeyName, "{key}");
70            assert!(e.message.contains(key));
71        }
72    }
73}
74
75/// Remove every `_`-prefixed key from a row before it becomes a response.
76///
77/// Upstream does the same unconditionally in `filterSensitiveData`
78/// (`DatabaseController.js:288-292`), for every class rather than just `_User`. It is what keeps
79/// `_rperm`, `_wperm`, `_hashed_password` and anything else internal off the wire even when an
80/// earlier step forgot.
81///
82/// Applied at the response boundary rather than deep in the pipeline, so there is exactly one
83/// place to audit and no path that reaches a client without passing through it.
84pub fn strip_internal_keys(row: &mut ParseMap) {
85    row.retain(|k, _| !k.starts_with('_'));
86}
87
88/// Timestamp fields that are **bare ISO strings** at the top level of an object.
89///
90/// The same logical Date has two wire forms depending on position: a user-defined Date field is
91/// `{"__type":"Date","iso":"..."}`, but `createdAt` and `updatedAt` at the top level are plain
92/// strings (`MongoTransform.js:1174-1186`). Conflating them is a real bug that a well-known Rust
93/// Parse client shipped, and it is invisible until an SDK tries to parse the response.
94///
95/// `expiresAt` is deliberately absent: it encodes as a full Date object even at the top level,
96/// which is the asymmetry inside the asymmetry.
97const BARE_ISO_FIELDS: [&str; 3] = ["createdAt", "updatedAt", "lastUsed"];
98
99/// Rewrite top-level timestamp fields into their bare-string wire form.
100pub fn flatten_top_level_dates(row: &mut ParseMap) {
101    for key in BARE_ISO_FIELDS {
102        if let Some(ParseValue::Date(d)) = row.get(key) {
103            let iso = d.to_iso();
104            row.insert(key.to_string(), ParseValue::String(iso));
105        }
106    }
107}
108
109/// Everything a row needs before it becomes a response body.
110///
111/// One function so there is exactly one place to audit, and no route can apply half of it.
112pub fn to_response_body(row: &ParseMap) -> ParseMap {
113    let mut out = row.clone();
114    strip_internal_keys(&mut out);
115    flatten_top_level_dates(&mut out);
116    out
117}
118
119#[cfg(test)]
120mod strip_tests {
121    use super::*;
122    use parse_rust_core::ParseValue;
123
124    #[test]
125    fn every_underscore_key_is_removed() {
126        let mut row = ParseMap::new();
127        row.insert("title".into(), ParseValue::String("x".into()));
128        row.insert("_hashed_password".into(), ParseValue::String("h".into()));
129        row.insert("_rperm".into(), ParseValue::Array(vec![]));
130        row.insert("_anything".into(), ParseValue::Null);
131        strip_internal_keys(&mut row);
132        assert_eq!(row.len(), 1);
133        assert!(row.contains_key("title"));
134    }
135
136    #[test]
137    fn the_acl_field_survives_because_it_is_not_underscore_prefixed() {
138        let mut row = ParseMap::new();
139        row.insert("ACL".into(), ParseValue::Object(ParseMap::new()));
140        strip_internal_keys(&mut row);
141        assert!(row.contains_key("ACL"), "ACL is a client-visible field");
142    }
143}
144
145#[cfg(test)]
146mod response_tests {
147    use super::*;
148    use parse_rust_core::ParseDate;
149
150    #[test]
151    fn top_level_timestamps_become_bare_strings() {
152        let mut row = ParseMap::new();
153        let d = ParseDate::parse_iso("2026-08-14T13:34:33.581Z").expect("date");
154        row.insert("createdAt".into(), ParseValue::Date(d));
155        row.insert("updatedAt".into(), ParseValue::Date(d));
156        // A user-defined Date field keeps its envelope.
157        row.insert("dueDate".into(), ParseValue::Date(d));
158
159        let out = to_response_body(&row);
160        assert!(
161            matches!(out.get("createdAt"), Some(ParseValue::String(s)) if s == "2026-08-14T13:34:33.581Z"),
162            "createdAt must be a bare ISO string at the top level"
163        );
164        assert!(matches!(out.get("updatedAt"), Some(ParseValue::String(_))));
165        assert!(
166            matches!(out.get("dueDate"), Some(ParseValue::Date(_))),
167            "a user-defined Date keeps its __type envelope"
168        );
169    }
170
171    #[test]
172    fn the_response_body_is_stripped_and_flattened_together() {
173        let mut row = ParseMap::new();
174        row.insert("_hashed_password".into(), ParseValue::String("h".into()));
175        row.insert(
176            "createdAt".into(),
177            ParseValue::Date(ParseDate::parse_iso("2026-01-01T00:00:00.000Z").expect("d")),
178        );
179        let out = to_response_body(&row);
180        assert!(out.get("_hashed_password").is_none());
181        assert!(matches!(out.get("createdAt"), Some(ParseValue::String(_))));
182    }
183}