Skip to content

Commit

Permalink
bug: enforce VAPID aud (#225)
Browse files Browse the repository at this point in the history
Issue: bug 1663922
  • Loading branch information
jrconlin authored Oct 16, 2020
1 parent 9b93817 commit e396326
Show file tree
Hide file tree
Showing 8 changed files with 550 additions and 1,720 deletions.
3 changes: 2 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ jobs:
command: |
rustc --version
cargo install cargo-audit
# pending https://github.com/bodil/sized-chunks/issues/11
- run:
command: cargo audit
command: cargo audit --ignore RUSTSEC-2020-0041 --ignore RUSTSEC-2020-0049

test:
docker:
Expand Down
487 changes: 329 additions & 158 deletions Cargo.lock

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions autoendpoint/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,21 @@ edition = "2018"
# The `autoendpoint` branch merges these three PRs together.
# The version of a2 at the time of the fork is v0.5.3.
a2 = { git = "https://github.com/Mcat12/a2.git", branch = "autoendpoint" }
actix-web = "2.0"
actix-rt = "1.0"
actix-cors = "0.2.0"
actix-web = "3.1"
actix-rt = "1.1"
actix-cors = "0.4.0"
again = { version = "0.1.2", default-features = false, features = ["log"] }
async-trait = "0.1.36"
async-trait = "0.1"
autopush_common = { path = "../autopush-common" }
backtrace = "0.3"
base64 = "0.12.1"
cadence = "0.20"
base64 = "0.13"
cadence = "0.21"
config = "0.10.1"
docopt = "1.1.0"
fernet = "0.1.3"
futures = "0.3"
hex = "0.4.2"
jsonwebtoken = "7.1.1"
jsonwebtoken = "7.2"
lazy_static = "1.4.0"
log = "0.4"
openssl = "0.10"
Expand All @@ -35,23 +35,23 @@ reqwest = "0.10.6"
rusoto_core = "0.44.0"
rusoto_dynamodb = "0.44.0"
# Using debug-logs avoids https://github.com/getsentry/sentry-rust/issues/237
sentry = { version = "0.19", features = ["debug-logs"] }
sentry = { version = "0.20", features = ["debug-logs"] }
serde = { version = "1.0", features = ["derive"] }
serde_dynamodb = "0.5.1"
serde_json = "1.0"
slog = { version = "2.5", features = ["max_level_trace", "release_max_level_error", "dynamic-keys"] }
slog-async = "2.4"
slog-async = "2.5"
slog-envlogger = "2.2.0"
slog-mozlog-json = "0.1"
slog-scope = "4.3"
slog-stdlog = "4.0"
slog-term = "2.5"
slog-term = "2.6"
tokio = { version = "0.2", features = ["fs"] }
thiserror = "1.0"
url = "2.1"
uuid = { version = "0.8.1", features = ["serde", "v4"] }
validator = "0.10.0"
validator_derive = "0.10.0"
validator = "0.11"
validator_derive = "0.11"
yup-oauth2 = "4.1.2"

[dev-dependencies]
Expand Down
125 changes: 113 additions & 12 deletions autoendpoint/src/extractors/subscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ use futures::future::LocalBoxFuture;
use futures::FutureExt;
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use openssl::hash::MessageDigest;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::str::FromStr;
use url::Url;
use uuid::Uuid;

const ONE_DAY_IN_SECONDS: u64 = 60 * 60 * 24;

/// Extracts subscription data from `TokenInfo` and verifies auth/crypto headers
#[derive(Clone, Debug)]
pub struct Subscription {
Expand All @@ -27,6 +32,23 @@ pub struct Subscription {
pub vapid: Option<VapidHeaderWithKey>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct VapidClaims {
exp: u64,
aud: String,
sub: String,
}

impl Default for VapidClaims {
fn default() -> Self {
Self {
exp: sec_since_epoch() + ONE_DAY_IN_SECONDS,
aud: "No audience".to_owned(),
sub: "No sub".to_owned(),
}
}
}

impl FromRequest for Subscription {
type Error = ApiError;
type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>;
Expand Down Expand Up @@ -71,7 +93,7 @@ impl FromRequest for Subscription {

// Validate the VAPID JWT token and record the version
if let Some(vapid) = &vapid {
validate_vapid_jwt(vapid)?;
validate_vapid_jwt(vapid, &state.settings.endpoint_url())?;

state
.metrics
Expand Down Expand Up @@ -192,38 +214,51 @@ fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> Api
/// - Make sure the expiration isn't too far into the future
///
/// This is mostly taken care of by the jsonwebtoken library
fn validate_vapid_jwt(vapid: &VapidHeaderWithKey) -> ApiResult<()> {
fn validate_vapid_jwt(vapid: &VapidHeaderWithKey, domain: &Url) -> ApiResult<()> {
let VapidHeaderWithKey { vapid, public_key } = vapid;

#[derive(serde::Deserialize)]
struct Claims {
exp: u64,
}

// Check the signature and make sure the expiration is in the future
let public_key = base64::decode_config(public_key, base64::URL_SAFE_NO_PAD)
.map_err(|_| VapidError::InvalidKey)?;
let token_data = jsonwebtoken::decode::<Claims>(
// NOTE: This will fail if `exp` is specified as a string instead of a numeric.
let token_data = jsonwebtoken::decode::<VapidClaims>(
&vapid.token,
&DecodingKey::from_ec_der(&public_key),
&Validation::new(Algorithm::ES256),
)?;

// Make sure the expiration isn't too far into the future
let now = sec_since_epoch();
const ONE_DAY_IN_SECONDS: u64 = 60 * 60 * 24;

if token_data.claims.exp - now > ONE_DAY_IN_SECONDS {
if token_data.claims.exp > (sec_since_epoch() + ONE_DAY_IN_SECONDS) {
// The expiration is too far in the future
return Err(VapidError::FutureExpirationToken.into());
}

let aud = match Url::from_str(&token_data.claims.aud) {
Ok(v) => v,
Err(_) => {
error!("Bad Aud: Invalid audience {:?}", &token_data.claims.aud);
return Err(VapidError::InvalidAudience.into());
}
};

if domain != &aud {
error!("Bad Aud: I am{:?}, asked for {:?} ", domain, aud);
return Err(VapidError::InvalidAudience.into());
}

Ok(())
}

#[cfg(test)]
mod tests {
use super::{validate_vapid_jwt, VapidClaims};
use crate::error::ApiErrorKind;
use crate::extractors::subscription::repad_base64;
use crate::headers::vapid::{VapidError, VapidHeader, VapidHeaderWithKey, VapidVersionData};
use autopush_common::util::sec_since_epoch;
use base64;
use std::str::FromStr;
use url::Url;

#[test]
fn repad_base64_1_padding() {
Expand All @@ -234,4 +269,70 @@ mod tests {
fn repad_base64_2_padding() {
assert_eq!(repad_base64("Zm9vYg"), "Zm9vYg==")
}

#[test]
fn vapid_aud_valid() {
let priv_key = base64::decode_config(
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZImOgpRszunnU3j1\
oX5UQiX8KU4X2OdbENuvc/t8wpmhRANCAATN21Y1v8LmQueGpSG6o022gTbbYa4l\
bXWZXITsjknW1WHmELtouYpyXX7e41FiAMuDvcRwW2Nfehn/taHW/IXb",
base64::STANDARD,
)
.unwrap();
let public_key = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds".to_owned();
let domain = "https://push.services.mozilla.org";
let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&priv_key);
let claims = VapidClaims {
exp: sec_since_epoch() + super::ONE_DAY_IN_SECONDS - 100,
aud: domain.to_owned(),
sub: "mailto:[email protected]".to_owned(),
};
let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();

let header = VapidHeaderWithKey {
public_key,
vapid: VapidHeader {
scheme: "vapid".to_string(),
token,
version_data: VapidVersionData::Version1,
},
};
assert!(validate_vapid_jwt(&header, &Url::from_str(domain).unwrap()).is_ok());
}

#[test]
fn vapid_aud_invalid() {
let priv_key = base64::decode_config(
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZImOgpRszunnU3j1\
oX5UQiX8KU4X2OdbENuvc/t8wpmhRANCAATN21Y1v8LmQueGpSG6o022gTbbYa4l\
bXWZXITsjknW1WHmELtouYpyXX7e41FiAMuDvcRwW2Nfehn/taHW/IXb",
base64::STANDARD,
)
.unwrap();
let public_key = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds".to_owned();
let domain = "https://push.services.mozilla.org";
let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&priv_key);
let claims = VapidClaims {
exp: sec_since_epoch() + super::ONE_DAY_IN_SECONDS - 100,
aud: domain.to_owned(),
sub: "mailto:[email protected]".to_owned(),
};
let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
let header = VapidHeaderWithKey {
public_key,
vapid: VapidHeader {
scheme: "vapid".to_string(),
token,
version_data: VapidVersionData::Version1,
},
};
assert!(matches!(
validate_vapid_jwt(&header, &Url::from_str("http://example.org").unwrap())
.unwrap_err()
.kind,
ApiErrorKind::VapidError(VapidError::InvalidAudience)
));
}
}
4 changes: 4 additions & 0 deletions autoendpoint/src/headers/vapid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ pub enum VapidError {
MissingKey,
#[error("Invalid VAPID public key")]
InvalidKey,
#[error("Invalid VAPID audience")]
InvalidAudience,
#[error("Invalid VAPID expiry")]
InvalidExpiry,
#[error("VAPID public key mismatch")]
KeyMismatch,
#[error("The VAPID token expiration is too long")]
Expand Down
12 changes: 6 additions & 6 deletions autopush/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ path = "src/main.rs"

[dependencies]
autopush_common = { path = "../autopush-common" }
base64 = "0.12.1"
base64 = "0.13"
# XXX: pin to < 0.5 for hyper 0.12
bytes = "0.4.12"
cadence = "0.20.0"
crossbeam-channel = "0.4.2"
chrono = "0.4.11"
bytes = "0.4"
cadence = "0.20"
crossbeam-channel = "0.4"
chrono = "0.4"
config = "0.10.1"
docopt = "1.1.0"
env_logger = "0.7.1"
Expand Down Expand Up @@ -63,4 +63,4 @@ tokio-service = "0.1.0"
tokio-tungstenite = { version = "0.9.0", default-features = false }
tungstenite = { version = "0.9.2", default-features = false }
uuid = { version = "0.8.1", features = ["serde", "v4"] }
woothee = "0.10.0"
woothee = "0.11"
Loading

0 comments on commit e396326

Please sign in to comment.