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