secure_json_parse/lib.rs
1//! Parse JSON while blocking prototype-poisoning keys.
2//!
3//! This crate parses untrusted JSON and detects two key patterns that pollute a
4//! JavaScript object's prototype once the parsed value is copied, merged, or
5//! iterated:
6//!
7//! 1. a key literally named `__proto__`
8//! 2. a key named `constructor` whose value is an object that contains a
9//! `prototype` key (a `constructor.prototype` nesting)
10//!
11//! For each pattern you choose an [`Action`]: error out, remove the key, or
12//! ignore it and behave like a plain JSON parse. A `safe` flag turns any
13//! detected violation into `Ok(None)` instead of an error.
14//!
15//! The parsed value is a [`serde_json::Value`]. In a `Value` tree `__proto__`
16//! and `constructor` are ordinary map keys, so the danger is latent rather than
17//! immediate. The job here is to detect, remove, or reject those keys before the
18//! value reaches code that would treat them as a prototype.
19//!
20//! # Quick start
21//!
22//! ```
23//! use secure_json_parse::{parse, Action, Options, Error};
24//!
25//! // Default options error on a forbidden key.
26//! let err = parse(r#"{"__proto__": {"x": 7}}"#, &Options::default());
27//! assert!(matches!(err, Err(Error::ForbiddenProperty)));
28//!
29//! // Remove the key instead.
30//! let opts = Options::default().proto_action(Action::Remove);
31//! let value = parse(r#"{"a": 5, "__proto__": {"x": 7}}"#, &opts).unwrap().unwrap();
32//! assert_eq!(value, serde_json::json!({"a": 5}));
33//! ```
34//!
35//! # Safe parsing
36//!
37//! [`safe_parse`] folds every outcome into one three-valued result:
38//!
39//! ```
40//! use secure_json_parse::{safe_parse, SafeOutcome};
41//!
42//! // Clean input parses to a value.
43//! assert!(matches!(safe_parse(r#"{"a": 1}"#), SafeOutcome::Value(_)));
44//! // A forbidden key yields Violation.
45//! assert!(matches!(safe_parse(r#"{"__proto__": {}}"#), SafeOutcome::Violation));
46//! // Malformed JSON yields Malformed.
47//! assert!(matches!(safe_parse(r#"{"a": "#), SafeOutcome::Malformed));
48//! ```
49
50#![forbid(unsafe_code)]
51#![warn(missing_docs)]
52
53mod scan;
54
55pub use scan::scan;
56
57use serde_json::Value;
58
59/// What to do when a forbidden key is found.
60///
61/// `proto_action` and `constructor_action` each take one of these. The default
62/// is [`Action::Error`].
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
64pub enum Action {
65 /// Reject the input with [`Error::ForbiddenProperty`].
66 #[default]
67 Error,
68 /// Delete the offending key from the result.
69 Remove,
70 /// Skip the check for this key and keep it.
71 Ignore,
72}
73
74/// Configuration for [`parse`], [`parse_bytes`], and [`scan`].
75///
76/// Build one with [`Options::default`] and the chained setters. The defaults
77/// match a strict parser: both actions are [`Action::Error`] and `safe` is off.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub struct Options {
80 /// Action for a `__proto__` key.
81 pub proto_action: Action,
82 /// Action for a `constructor.prototype` nesting.
83 pub constructor_action: Action,
84 /// When true, a detected violation returns `Ok(None)` instead of an error.
85 pub safe: bool,
86}
87
88impl Default for Options {
89 fn default() -> Self {
90 Options {
91 proto_action: Action::Error,
92 constructor_action: Action::Error,
93 safe: false,
94 }
95 }
96}
97
98impl Options {
99 /// Set the action for `__proto__` keys.
100 #[must_use]
101 pub fn proto_action(mut self, action: Action) -> Self {
102 self.proto_action = action;
103 self
104 }
105
106 /// Set the action for `constructor.prototype` nestings.
107 #[must_use]
108 pub fn constructor_action(mut self, action: Action) -> Self {
109 self.constructor_action = action;
110 self
111 }
112
113 /// Set the `safe` flag. When true a violation returns `Ok(None)`.
114 #[must_use]
115 pub fn safe(mut self, safe: bool) -> Self {
116 self.safe = safe;
117 self
118 }
119}
120
121/// Why a parse failed.
122///
123/// [`Error::Syntax`] wraps a malformed-JSON error from the underlying parser.
124/// [`Error::ForbiddenProperty`] reports a `__proto__` or `constructor.prototype`
125/// violation when the relevant [`Action`] is [`Action::Error`] and `safe` is
126/// off. Both violation kinds share one variant and one message.
127#[derive(Debug)]
128pub enum Error {
129 /// The JSON text was malformed.
130 Syntax(serde_json::Error),
131 /// The value held a forbidden prototype property.
132 ForbiddenProperty,
133}
134
135impl std::fmt::Display for Error {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 match self {
138 Error::Syntax(e) => write!(f, "{e}"),
139 Error::ForbiddenProperty => write!(f, "Object contains forbidden prototype property"),
140 }
141 }
142}
143
144impl std::error::Error for Error {
145 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
146 match self {
147 Error::Syntax(e) => Some(e),
148 Error::ForbiddenProperty => None,
149 }
150 }
151}
152
153/// The three outcomes of [`safe_parse`].
154///
155/// The variant names state the outcome. The JavaScript `safeParse` maps the
156/// same three cases to a value, `null` for a violation, and `undefined` for a
157/// parse error.
158#[derive(Debug)]
159pub enum SafeOutcome {
160 /// Parsed cleanly. Holds the value.
161 Value(Value),
162 /// A forbidden prototype property was found. JavaScript returns `null`.
163 Violation,
164 /// The JSON text was malformed. JavaScript returns `undefined`.
165 Malformed,
166}
167
168/// Drop a leading `U+FEFF` byte order mark from a string.
169///
170/// The UTF-8 BOM is `EF BB BF`, which decodes to `U+FEFF`. Removing it before
171/// parsing matches a JavaScript parser that strips a leading BOM.
172fn strip_bom(text: &str) -> &str {
173 text.strip_prefix('\u{FEFF}').unwrap_or(text)
174}
175
176/// Parse JSON text and apply prototype-poisoning checks.
177///
178/// On success returns `Ok(Some(value))`. When `safe` is on and a violation is
179/// found, returns `Ok(None)`. A malformed input gives [`Error::Syntax`]; a
180/// violation under [`Action::Error`] gives [`Error::ForbiddenProperty`].
181///
182/// Scalars and `null` skip scanning and are returned as is. Objects and arrays
183/// are walked.
184///
185/// # Errors
186///
187/// Returns [`Error::Syntax`] if `text` is not valid JSON, or
188/// [`Error::ForbiddenProperty`] if a forbidden key is found and the matching
189/// [`Action`] is [`Action::Error`] with `safe` off.
190///
191/// # Examples
192///
193/// ```
194/// use secure_json_parse::{parse, Action, Options};
195///
196/// let opts = Options::default().constructor_action(Action::Remove);
197/// let v = parse(r#"{"constructor": {"prototype": {}}, "a": 1}"#, &opts)
198/// .unwrap()
199/// .unwrap();
200/// assert_eq!(v, serde_json::json!({"a": 1}));
201/// ```
202pub fn parse(text: &str, options: &Options) -> Result<Option<Value>, Error> {
203 let text = strip_bom(text);
204 let value: Value = serde_json::from_str(text).map_err(Error::Syntax)?;
205 scan(value, options)
206}
207
208/// Parse JSON bytes and apply prototype-poisoning checks.
209///
210/// Same as [`parse`] but takes UTF-8 bytes. A leading UTF-8 byte order mark
211/// (`EF BB BF`) is stripped before parsing.
212///
213/// # Errors
214///
215/// Returns [`Error::Syntax`] if the bytes are not valid UTF-8 JSON, or
216/// [`Error::ForbiddenProperty`] under the same rules as [`parse`].
217pub fn parse_bytes(bytes: &[u8], options: &Options) -> Result<Option<Value>, Error> {
218 let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
219 let value: Value = serde_json::from_slice(bytes).map_err(Error::Syntax)?;
220 scan(value, options)
221}
222
223/// Parse JSON text and fold every outcome into a [`SafeOutcome`].
224///
225/// Runs with both actions at [`Action::Error`] and `safe` on. A clean parse
226/// returns [`SafeOutcome::Value`]. A forbidden key returns
227/// [`SafeOutcome::Violation`]. Malformed JSON returns [`SafeOutcome::Malformed`].
228/// This never returns an error type.
229///
230/// # Examples
231///
232/// ```
233/// use secure_json_parse::{safe_parse, SafeOutcome};
234///
235/// match safe_parse(r#"{"a": 1}"#) {
236/// SafeOutcome::Value(v) => assert_eq!(v, serde_json::json!({"a": 1})),
237/// _ => panic!("expected a value"),
238/// }
239/// ```
240pub fn safe_parse(text: &str) -> SafeOutcome {
241 let opts = Options::default().safe(true);
242 fold(parse(text, &opts))
243}
244
245/// Parse JSON bytes and fold every outcome into a [`SafeOutcome`].
246///
247/// The byte counterpart to [`safe_parse`]. A leading UTF-8 byte order mark is
248/// stripped before parsing.
249pub fn safe_parse_bytes(bytes: &[u8]) -> SafeOutcome {
250 let opts = Options::default().safe(true);
251 fold(parse_bytes(bytes, &opts))
252}
253
254/// Collapse a parse result into a [`SafeOutcome`].
255fn fold(result: Result<Option<Value>, Error>) -> SafeOutcome {
256 match result {
257 Ok(Some(value)) => SafeOutcome::Value(value),
258 Ok(None) => SafeOutcome::Violation,
259 Err(_) => SafeOutcome::Malformed,
260 }
261}