perimeterx-fastly-enforcer 2.2.2

PerimeterX Fastly Compute@Edge Rust Enforcer
Documentation
use std::collections::HashMap;

use aes::cipher::{BlockModeEncrypt, KeyIvInit, block_padding::Pkcs7};
use base64::{Engine as _, engine::general_purpose};
use cbc::Encryptor;
use pbkdf2::pbkdf2_hmac_array;
use serde_json::json;
use sha2::Sha256;

use crate::handlers::pxcookievalidator::verify_cookie;
use crate::handlers::pxcrypto;
use crate::modules::pxconstants::{IV_LEN, KEY_LEN};
use crate::pxconfig::PXConfig;
use crate::pxcontext::{
    CallReason, CookieOrigin, CookieVersion, PXContext, PassReason, TokenVersion,
};

type Aes256CbcEnc = Encryptor<aes::Aes256>;

fn mobile_error_ctx(s2s_call_reason: CallReason, original_token: Option<String>) -> PXContext {
    PXContext {
        cookie_origin: Some(CookieOrigin::Header),
        original_token,
        s2s_call_reason: Some(s2s_call_reason),
        ..Default::default()
    }
}

fn test_conf(cookie_secret: &str) -> PXConfig {
    PXConfig {
        cookie_secret: cookie_secret.to_owned(),
        ..Default::default()
    }
}

fn test_conf_v2(cookie_secret: &str) -> PXConfig {
    PXConfig {
        cookie_secret: cookie_secret.to_owned(),
        token_version: TokenVersion::V2,
        ..Default::default()
    }
}

fn build_valid_original_token_v2(cookie_secret: &str) -> String {
    let mut token_json = json!({
        "t": 1_900_000_000_000_i64,
        "u": "550e8400-e29b-41d4-a716-446655440000",
        "v": "550e8400-e29b-41d4-a716-446655440001",
    });

    let Some(hmac) = pxcrypto::get_cookie_hmac(&token_json, "0", cookie_secret) else {
        return String::new();
    };
    if let Some(obj) = token_json.as_object_mut() {
        obj.insert("h".to_owned(), json!(hmac));
    }

    general_purpose::STANDARD.encode(token_json.to_string())
}

fn encrypt_cookie_v3_payload(
    cookie_secret: &str,
    salt: &[u8],
    iterations: u32,
    plaintext: &[u8],
) -> Option<Vec<u8>> {
    const LEN: usize = KEY_LEN + IV_LEN;
    let decoded_key = pbkdf2_hmac_array::<Sha256, LEN>(cookie_secret.as_bytes(), salt, iterations);
    let key: &[u8; KEY_LEN] = decoded_key.get(0..KEY_LEN)?.try_into().ok()?;
    let iv: &[u8; IV_LEN] = decoded_key.get(KEY_LEN..)?.try_into().ok()?;

    let mut buf = plaintext.to_vec();
    let msg_len = buf.len();
    buf.resize(msg_len + IV_LEN, 0);
    let cipher = Aes256CbcEnc::new(key.into(), iv.into());
    let ciphertext = cipher.encrypt_padded::<Pkcs7>(&mut buf, msg_len).ok()?;
    Some(ciphertext.to_vec())
}

fn build_valid_original_token_v3(cookie_secret: &str) -> String {
    let salt = vec![7_u8, 8, 9, 10, 11, 12, 13, 14];
    let iterations = 500_u32;
    let cookie_json = json!({
        "t": 1_900_000_000_000_i64,
        "u": "550e8400-e29b-41d4-a716-446655440000",
        "v": "550e8400-e29b-41d4-a716-446655440001",
        "s": 0,
        "a": "c",
        "x": "",
    });
    let plaintext = cookie_json.to_string();
    let encrypted =
        encrypt_cookie_v3_payload(cookie_secret, &salt, iterations, plaintext.as_bytes())
            .unwrap_or_default();
    if encrypted.is_empty() {
        return String::new();
    }

    let salt_b64 = general_purpose::STANDARD.encode(&salt);
    let payload_b64 = general_purpose::STANDARD.encode(&encrypted);
    let cookie_to_sign = format!("{salt_b64}:{iterations}:{payload_b64}");
    let signing_fields = String::new();
    let ehmac = pxcrypto::create_hmac(&(cookie_to_sign.clone() + &signing_fields), cookie_secret)
        .unwrap_or_default();

    format!("{ehmac}:{cookie_to_sign}")
}

#[test]
fn mobile_error_validates_original_token_and_preserves_reason() {
    let cookie_secret = "secret";
    let original_token = build_valid_original_token_v2(cookie_secret);
    assert!(!original_token.is_empty());

    let mut ctx = mobile_error_ctx(
        CallReason::MobileError1,
        Some(format!("2:{original_token}")),
    );
    let conf = test_conf(cookie_secret);

    assert!(!verify_cookie(&mut ctx, &conf));
    assert_eq!(ctx.s2s_call_reason, Some(CallReason::MobileError1));
    assert_eq!(ctx.original_token_error, None);
    assert!(ctx.cookie_version.is_none());
}

#[test]
fn mobile_error_invalid_v2_original_token_sets_validation_failed() {
    let cookie_secret = "secret";
    let mut original_token = build_valid_original_token_v2(cookie_secret);
    assert!(!original_token.is_empty());
    original_token.push('x');

    let mut ctx = mobile_error_ctx(
        CallReason::MobileError1,
        Some(format!("2:{original_token}")),
    );
    let conf = test_conf(cookie_secret);

    assert!(!verify_cookie(&mut ctx, &conf));
    assert_eq!(ctx.s2s_call_reason, Some(CallReason::MobileError1));
    assert_eq!(
        ctx.original_token_error,
        Some(CallReason::CookieValidationFailed)
    );
}

#[test]
fn mobile_error_valid_v3_original_token_preserves_reason() {
    let cookie_secret = "secret";
    let original_token = build_valid_original_token_v3(cookie_secret);
    assert!(!original_token.is_empty());

    let mut ctx = mobile_error_ctx(
        CallReason::MobileError1,
        Some(format!("3:{original_token}")),
    );
    let conf = test_conf(cookie_secret);

    assert!(!verify_cookie(&mut ctx, &conf));
    assert_eq!(ctx.s2s_call_reason, Some(CallReason::MobileError1));
    assert_eq!(ctx.original_token_error, None);
    assert!(ctx.cookie_version.is_none());
}

#[test]
fn mobile_error_invalid_v3_original_token_sets_decryption_failed() {
    let cookie_secret = "secret";
    let mut original_token = build_valid_original_token_v3(cookie_secret);
    assert!(!original_token.is_empty());
    original_token.push('x');

    let mut ctx = mobile_error_ctx(
        CallReason::MobileError1,
        Some(format!("3:{original_token}")),
    );
    let conf = test_conf(cookie_secret);

    assert!(!verify_cookie(&mut ctx, &conf));
    assert_eq!(ctx.s2s_call_reason, Some(CallReason::MobileError1));
    assert_eq!(
        ctx.original_token_error,
        Some(CallReason::CookieDecryptionFailed)
    );
}

#[test]
fn mobile_error_invalid_v3_original_token_hmac_sets_validation_failed() {
    let cookie_secret = "secret";
    let original_token = build_valid_original_token_v3(cookie_secret);
    assert!(!original_token.is_empty());
    let tampered = if let Some((_, rest)) = original_token.split_once(':') {
        format!("deadbeef:{rest}")
    } else {
        original_token
    };

    let mut ctx = mobile_error_ctx(CallReason::MobileError1, Some(format!("3:{tampered}")));
    let conf = test_conf(cookie_secret);

    assert!(!verify_cookie(&mut ctx, &conf));
    assert_eq!(ctx.s2s_call_reason, Some(CallReason::MobileError1));
    assert_eq!(
        ctx.original_token_error,
        Some(CallReason::CookieValidationFailed)
    );
}

#[test]
fn v3_config_validates_v3_cookie_when_present() {
    let cookie_secret = "secret";
    let px3 = build_valid_original_token_v3(cookie_secret);
    assert!(!px3.is_empty());

    let mut ctx = PXContext {
        cookies: HashMap::from([("_px3".to_owned(), px3)]),
        ..Default::default()
    };
    let conf = test_conf(cookie_secret);

    assert!(verify_cookie(&mut ctx, &conf));
    assert!(ctx.cookie_version == Some(CookieVersion::V3));
    assert!(ctx.pass_reason == Some(PassReason::Cookie));
}

#[test]
fn v2_sensitive_route_without_cookie_uses_no_cookie_reason() {
    let mut ctx = PXContext {
        is_sensitive_route: true,
        ..Default::default()
    };
    let conf = test_conf_v2("secret");

    assert!(!verify_cookie(&mut ctx, &conf));
    assert_eq!(ctx.s2s_call_reason, Some(CallReason::NoCookie));
}

#[test]
fn v2_sensitive_route_without_cookie_with_pxhd_vid_uses_no_cookie_w_vid() {
    let mut ctx = PXContext {
        is_sensitive_route: true,
        pxhd_cookie: Some("some-vid".to_owned()),
        ..Default::default()
    };
    let conf = test_conf_v2("secret");

    assert!(!verify_cookie(&mut ctx, &conf));
    assert_eq!(ctx.s2s_call_reason, Some(CallReason::NoCookieWVid));
}

#[test]
fn v3_sensitive_route_without_cookie_uses_no_cookie_reason() {
    let mut ctx = PXContext {
        is_sensitive_route: true,
        ..Default::default()
    };
    let conf = test_conf("secret");

    assert!(!verify_cookie(&mut ctx, &conf));
    assert_eq!(ctx.s2s_call_reason, Some(CallReason::NoCookie));
}

#[test]
fn v3_config_rejects_v2_when_v3_absent() {
    let cookie_secret = "secret";
    let px2 = build_valid_original_token_v2(cookie_secret);
    assert!(!px2.is_empty());

    let mut ctx = PXContext {
        cookies: HashMap::from([("_px2".to_owned(), px2)]),
        ..Default::default()
    };
    let conf = test_conf(cookie_secret);

    assert!(!verify_cookie(&mut ctx, &conf));
    assert_eq!(ctx.s2s_call_reason, Some(CallReason::NoCookie));
    assert!(ctx.pass_reason != Some(PassReason::Cookie));
}

#[test]
fn v3_config_does_not_fall_back_to_v2_when_v3_present_and_invalid() {
    let cookie_secret = "secret";
    let px2 = build_valid_original_token_v2(cookie_secret);
    let mut invalid_px3 = build_valid_original_token_v3(cookie_secret);
    assert!(!px2.is_empty());
    assert!(!invalid_px3.is_empty());
    invalid_px3.push('x');

    let mut ctx = PXContext {
        cookies: HashMap::from([("_px3".to_owned(), invalid_px3), ("_px2".to_owned(), px2)]),
        ..Default::default()
    };
    let conf = test_conf(cookie_secret);

    assert!(!verify_cookie(&mut ctx, &conf));
    assert!(ctx.cookie_version == Some(CookieVersion::V3));
    assert!(ctx.pass_reason != Some(PassReason::Cookie));
    assert_eq!(
        ctx.s2s_call_reason,
        Some(CallReason::CookieDecryptionFailed)
    );
}