Skip to main content

autoendpoint/extractors/
notification_headers.rs

1use crate::error::{ApiError, ApiErrorKind, ApiResult};
2use crate::headers::crypto_key::CryptoKeyHeader;
3use crate::headers::util::{get_header, get_owned_header};
4use actix_web::HttpRequest;
5use autopush_common::util::InsertOpt;
6use lazy_static::lazy_static;
7use regex::Regex;
8use std::cmp::min;
9use std::collections::HashMap;
10use std::time::Duration;
11use validator::Validate;
12use validator_derive::Validate;
13
14lazy_static! {
15    static ref VALID_BASE64_URL: Regex = Regex::new(r"^[0-9A-Za-z\-_]+=*$").unwrap();
16    static ref STRIP_PADDING: Regex =
17        Regex::new(r"(?P<head>[0-9A-Za-z\-_]+)=+(?P<tail>[,;]|$)").unwrap();
18}
19
20/// Extractor and validator for notification headers
21#[derive(Clone, Debug, Eq, PartialEq, Validate)]
22pub struct NotificationHeaders {
23    // TTL is a signed value so that validation can catch negative inputs
24    #[validate(range(min = 0, message = "TTL must be greater than 0", code = "114"))]
25    pub ttl: i64,
26
27    #[validate(
28        length(
29            max = 32,
30            message = "Topic must be no greater than 32 characters",
31            code = "113"
32        ),
33        regex(
34            path = *VALID_BASE64_URL,
35            message = "Topic must be URL and Filename safe Base64 alphabet",
36            code = "113"
37        )
38    )]
39    pub topic: Option<String>,
40
41    // These fields are validated separately, because the validation is complex
42    // and based upon the content encoding
43    pub encoding: Option<String>,
44    pub encryption: Option<String>,
45    pub crypto_key: Option<String>,
46}
47
48impl From<NotificationHeaders> for HashMap<String, String> {
49    fn from(headers: NotificationHeaders) -> Self {
50        let mut map = HashMap::new();
51
52        map.insert_opt("encoding", headers.encoding);
53        map.insert_opt("encryption", headers.encryption);
54        map.insert_opt("crypto_key", headers.crypto_key);
55
56        map
57    }
58}
59
60impl NotificationHeaders {
61    /// Extract the notification headers from a request.
62    /// This can not be implemented as a `FromRequest` impl because we need to
63    /// know if the payload has data, without actually advancing the payload
64    /// stream.
65    pub fn from_request(
66        req: &HttpRequest,
67        has_data: bool,
68        max_notification_ttl: Duration,
69    ) -> ApiResult<Self> {
70        // Collect raw headers
71        let ttl: i64 = get_header(req, "ttl")
72            .and_then(|ttl| ttl.parse().ok())
73            // Enforce a maximum TTL, but don't error
74            // NOTE: In order to trap for negative TTLs, this should be a
75            // signed value, otherwise we will error out with NO_TTL.
76            .map(|ttl: i64| min(ttl, max_notification_ttl.as_secs() as i64))
77            .ok_or(ApiErrorKind::NoTTL)?;
78
79        let topic = get_owned_header(req, "topic");
80
81        let headers = if has_data {
82            NotificationHeaders {
83                ttl,
84                topic,
85                encoding: get_owned_header(req, "content-encoding"),
86                encryption: get_owned_header(req, "encryption").map(Self::strip_header),
87                crypto_key: get_owned_header(req, "crypto-key").map(Self::strip_header),
88            }
89        } else {
90            // Messages without a body shouldn't pass along unnecessary headers
91            NotificationHeaders {
92                ttl,
93                topic,
94                encoding: None,
95                encryption: None,
96                crypto_key: None,
97            }
98        };
99
100        // Validate encryption if there is a message body
101        if has_data {
102            headers.validate_encryption()?;
103        }
104
105        // Validate the other headers
106        match headers.validate() {
107            Ok(_) => Ok(headers),
108            Err(e) => Err(ApiError::from(e)),
109        }
110    }
111
112    /// Remove Base64 padding and double-quotes
113    fn strip_header(header: String) -> String {
114        let header = header.replace('"', "");
115        STRIP_PADDING.replace_all(&header, "$head$tail").to_string()
116    }
117
118    /// Validate the encryption headers according to the various WebPush
119    /// standard versions
120    fn validate_encryption(&self) -> ApiResult<()> {
121        let encoding = self.encoding.as_deref().ok_or_else(|| {
122            ApiErrorKind::InvalidEncryption("Missing Content-Encoding header".to_string())
123        })?;
124
125        match encoding {
126            "aesgcm" => self.validate_encryption_04_rules()?,
127            "aes128gcm" => self.validate_encryption_06_rules()?,
128            _ => {
129                return Err(ApiErrorKind::InvalidEncryption(
130                    "Unknown Content-Encoding header".to_string(),
131                )
132                .into());
133            }
134        }
135
136        Ok(())
137    }
138
139    /// Validates encryption headers according to
140    /// draft-ietf-webpush-encryption-04
141    fn validate_encryption_04_rules(&self) -> ApiResult<()> {
142        Self::assert_base64_item_exists("Encryption", self.encryption.as_deref(), "salt")?;
143
144        if self.crypto_key.is_some() {
145            Self::assert_base64_item_exists("Crypto-Key", self.crypto_key.as_deref(), "dh")?;
146        }
147
148        Ok(())
149    }
150
151    /// Validates encryption headers according to
152    /// draft-ietf-httpbis-encryption-encoding-06
153    /// (the encryption values are in the payload, so there shouldn't be any in
154    /// the headers)
155    fn validate_encryption_06_rules(&self) -> ApiResult<()> {
156        Self::assert_not_exists("aes128gcm Encryption", self.encryption.as_deref(), "salt")?;
157        Self::assert_not_exists("aes128gcm Crypto-Key", self.crypto_key.as_deref(), "dh")?;
158
159        Ok(())
160    }
161
162    /// Assert that the given item exists in the header and is valid base64.
163    fn assert_base64_item_exists(
164        header_name: &str,
165        header: Option<&str>,
166        key: &str,
167    ) -> ApiResult<()> {
168        let header = header.ok_or_else(|| {
169            ApiErrorKind::InvalidEncryption(format!("Missing {header_name} header"))
170        })?;
171        let header_data = CryptoKeyHeader::parse(header).ok_or_else(|| {
172            ApiErrorKind::InvalidEncryption(format!("Invalid {header_name} header"))
173        })?;
174        let value = header_data.get_by_key(key).ok_or_else(|| {
175            ApiErrorKind::InvalidEncryption(format!("Missing {key} value in {header_name} header"))
176        })?;
177
178        if !VALID_BASE64_URL.is_match(value) {
179            return Err(ApiErrorKind::InvalidEncryption(format!(
180                "Invalid {key} value in {header_name} header",
181            ))
182            .into());
183        }
184
185        Ok(())
186    }
187
188    /// Assert that the given key does not exist in the header.
189    fn assert_not_exists(header_name: &str, header: Option<&str>, key: &str) -> ApiResult<()> {
190        let header = match header {
191            Some(header) => header,
192            None => return Ok(()),
193        };
194
195        let header_data = CryptoKeyHeader::parse(header).ok_or_else(|| {
196            ApiErrorKind::InvalidEncryption(format!("Invalid {header_name} header"))
197        })?;
198
199        if header_data.get_by_key(key).is_some() {
200            return Err(ApiErrorKind::InvalidEncryption(format!(
201                "Do not include '{key}' header in {header_name} header"
202            ))
203            .into());
204        }
205
206        Ok(())
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use std::time::Duration;
213
214    use super::NotificationHeaders;
215    use crate::error::{ApiErrorKind, ApiResult};
216    use actix_web::test::TestRequest;
217    use autopush_common::MAX_NOTIFICATION_TTL_SECS;
218
219    /// Assert that a result is a validation error and check its serialization
220    /// against the JSON value.
221    fn assert_validation_error(
222        result: ApiResult<NotificationHeaders>,
223        expected_json: serde_json::Value,
224    ) {
225        assert!(result.is_err());
226        let errors = match result.unwrap_err().kind {
227            ApiErrorKind::Validation(errors) => errors,
228            _ => panic!("Expected a validation error"),
229        };
230
231        assert_eq!(serde_json::to_value(errors).unwrap(), expected_json);
232    }
233
234    /// Assert that a result is a specific encryption error
235    fn assert_encryption_error(result: ApiResult<NotificationHeaders>, expected_error: &str) {
236        assert!(result.is_err());
237        let error = match result.unwrap_err().kind {
238            ApiErrorKind::InvalidEncryption(error) => error,
239            _ => panic!("Expected an encryption error"),
240        };
241
242        assert_eq!(error, expected_error);
243    }
244
245    /// A valid TTL results in no errors or adjustment
246    #[test]
247    fn valid_ttl() {
248        let req = TestRequest::post()
249            .insert_header(("TTL", "10"))
250            .to_http_request();
251        let result = NotificationHeaders::from_request(
252            &req,
253            false,
254            Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
255        );
256
257        assert!(result.is_ok());
258        assert_eq!(result.unwrap().ttl, 10);
259    }
260
261    /// Negative TTL values are not allowed
262    #[test]
263    fn negative_ttl() {
264        let req = TestRequest::post()
265            .insert_header(("TTL", "-1"))
266            .to_http_request();
267        let result = NotificationHeaders::from_request(
268            &req,
269            false,
270            Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
271        );
272        assert_validation_error(
273            result,
274            serde_json::json!({
275                "ttl": [{
276                    "code": "114",
277                    "message": "TTL must be greater than 0",
278                    "params": {
279                        "min": 0,
280                        "value": -1
281                    }
282                }]
283            }),
284        );
285    }
286
287    /// TTL values above the max are silently reduced to the max
288    #[test]
289    fn maximum_ttl() {
290        let req = TestRequest::post()
291            .insert_header(("TTL", (MAX_NOTIFICATION_TTL_SECS + 1).to_string()))
292            .to_http_request();
293        let result = NotificationHeaders::from_request(
294            &req,
295            false,
296            Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
297        );
298
299        assert!(result.is_ok());
300        assert_eq!(result.unwrap().ttl, MAX_NOTIFICATION_TTL_SECS as i64);
301    }
302
303    /// A valid topic results in no errors
304    #[test]
305    fn valid_topic() {
306        let req = TestRequest::post()
307            .insert_header(("TTL", "10"))
308            .insert_header(("TOPIC", "a-test-topic-which-is-just-right"))
309            .to_http_request();
310        let result = NotificationHeaders::from_request(
311            &req,
312            false,
313            Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
314        );
315
316        assert!(result.is_ok());
317        assert_eq!(
318            result.unwrap().topic,
319            Some("a-test-topic-which-is-just-right".to_string())
320        );
321    }
322
323    /// Topic names which are too long return an error
324    #[test]
325    fn too_long_topic() {
326        let req = TestRequest::post()
327            .insert_header(("TTL", "10"))
328            .insert_header(("TOPIC", "test-topic-which-is-too-long-1234"))
329            .to_http_request();
330        let result = NotificationHeaders::from_request(
331            &req,
332            false,
333            Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
334        );
335
336        assert_validation_error(
337            result,
338            serde_json::json!({
339                "topic": [{
340                    "code": "113",
341                    "message": "Topic must be no greater than 32 characters",
342                    "params": {
343                        "max": 32,
344                        "value": "test-topic-which-is-too-long-1234"
345                    }
346                }]
347            }),
348        );
349    }
350
351    /// If there is a payload, there must be a content encoding header
352    #[test]
353    fn payload_without_content_encoding() {
354        let req = TestRequest::post()
355            .insert_header(("TTL", "10"))
356            .to_http_request();
357        let result = NotificationHeaders::from_request(
358            &req,
359            true,
360            Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
361        );
362
363        assert_encryption_error(result, "Missing Content-Encoding header");
364    }
365
366    /// Valid 04 draft encryption passes validation
367    #[test]
368    fn valid_04_encryption() {
369        let req = TestRequest::post()
370            .insert_header(("TTL", "10"))
371            .insert_header(("Content-Encoding", "aesgcm"))
372            .insert_header(("Encryption", "salt=foo"))
373            .insert_header(("Crypto-Key", "dh=bar"))
374            .to_http_request();
375        let result = NotificationHeaders::from_request(
376            &req,
377            true,
378            Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
379        );
380
381        assert!(result.is_ok());
382        assert_eq!(
383            result.unwrap(),
384            NotificationHeaders {
385                ttl: 10,
386                topic: None,
387                encoding: Some("aesgcm".to_string()),
388                encryption: Some("salt=foo".to_string()),
389                crypto_key: Some("dh=bar".to_string())
390            }
391        );
392    }
393
394    /// Valid 06 draft encryption passes validation
395    #[test]
396    fn valid_06_encryption() {
397        let req = TestRequest::post()
398            .insert_header(("TTL", "10"))
399            .insert_header(("Content-Encoding", "aes128gcm"))
400            .insert_header(("Encryption", "notsalt=foo"))
401            .insert_header(("Crypto-Key", "notdh=bar"))
402            .to_http_request();
403        let result = NotificationHeaders::from_request(
404            &req,
405            true,
406            Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
407        );
408
409        assert!(result.is_ok());
410        assert_eq!(
411            result.unwrap(),
412            NotificationHeaders {
413                ttl: 10,
414                topic: None,
415                encoding: Some("aes128gcm".to_string()),
416                encryption: Some("notsalt=foo".to_string()),
417                crypto_key: Some("notdh=bar".to_string())
418            }
419        );
420    }
421
422    /// The encryption and crypto-key headers are stripped of Base64 padding and
423    /// double-quotes.
424    #[test]
425    fn strip_headers() {
426        let req = TestRequest::post()
427            .insert_header(("TTL", "10"))
428            .insert_header(("Content-Encoding", "aesgcm"))
429            .insert_header(("Encryption", "salt=\"foo\""))
430            .insert_header(("Crypto-Key", "keyid=\"p256dh\";dh=\"deadbeef==\""))
431            .to_http_request();
432        let result = NotificationHeaders::from_request(
433            &req,
434            true,
435            Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
436        );
437
438        assert!(result.is_ok());
439        assert_eq!(
440            result.unwrap(),
441            NotificationHeaders {
442                ttl: 10,
443                topic: None,
444                encoding: Some("aesgcm".to_string()),
445                encryption: Some("salt=foo".to_string()),
446                crypto_key: Some("keyid=p256dh;dh=deadbeef".to_string())
447            }
448        );
449    }
450
451    // TODO: Add negative test cases for encryption validation?
452}