perimeterx-fastly-enforcer 2.2.2

PerimeterX Fastly Compute@Edge Rust Enforcer
Documentation
use crate::handlers::pxcredentials_intelligence;
use crate::modules::pxconstants::*;
use crate::modules::pxutils::{get_risk_headers, set_json_str};
use crate::pxconfig::PXConfig;
use crate::pxcontext::{
    BlockReason, CallReason, CookieOrigin, CookieVersion, PXContext, PXDataEnrichment, PassReason,
    S2sErrorReason,
};
use crate::{px_debug, px_error};
use fastly::{Error, Request, Response};
use serde_json::json;
use std::time::Instant;

fn send_risk_api(payload: String, conf: &PXConfig) -> Result<Response, Error> {
    let body = payload.as_bytes();
    let url = format!("https://{}{}", conf.human_sapi_host, RISK_API);
    let req = Request::post(url)
        .with_header("Authorization", format!("Bearer {}", conf.auth_token))
        .with_header("Content-Type", APPLICATION_JSON)
        .with_body(body);

    Ok(req.send(&conf.human_sapi_backend)?)
}

fn build_risk_payload(ctx: &mut PXContext, conf: &PXConfig) -> String {
    let mut payload = json!({
    "request": {
        "socket_ip": ctx.ip,
        "headers": get_risk_headers(&ctx.headers, &conf.sensitive_headers),
        "url": ctx.full_url,
    },
    "additional": {
            "s2s_call_reason": ctx
                .s2s_call_reason
                .as_ref()
                .map_or_else(|| CallReason::None.to_string(), ToString::to_string),
            "module_version": PX_MODULE_VERSION,
            "http_method": ctx.http_method,
            "http_version": ctx.http_version,
            "risk_mode": ctx.risk_mode,
            "cookie_origin": match ctx.cookie_origin {
                Some(CookieOrigin::Cookie) | None => "cookie",
                Some(CookieOrigin::Header) => "header",
            },
            "request_cookie_names": ctx.request_cookie_names,
            "is_sensitive_route": ctx.is_sensitive_route,
            "enforcer_start_time": ctx.enforcer_start_time
                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| d.as_secs()),
            "risk_start_time": std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH).ok()
                .map(|d| d.as_secs()),
        }
    });

    set_json_str!(&mut payload, "additional.px_cookie_hmac"; ctx.v2_cookie_hash.as_deref().unwrap_or_default());
    set_json_str!(&mut payload, "vid"; ctx.vid.as_deref().unwrap_or_default());
    set_json_str!(&mut payload, "pxhd"; ctx.get_pxhd().unwrap_or_default());
    set_json_str!(&mut payload, "vid_source"; ctx.vid_source.map(|v| v.to_string()).unwrap_or_default());
    set_json_str!(&mut payload, "client_uuid"; ctx.uuid.as_deref().unwrap_or_default());
    set_json_str!(&mut payload, "additional.cross_tab_session"; ctx.pxcts_cookie.as_deref().unwrap_or_default());
    if let Some(app_user_id) = &ctx.app_user_id {
        set_json_str!(&mut payload, "additional.app_user_id"; app_user_id);
    }
    if let Some(jwt_fields) = &ctx.jwt_additional_fields {
        if let Some(additional) = payload.get_mut("additional") {
            additional["jwt_additional_fields"] = serde_json::Value::Object(jwt_fields.clone());
        }
    }
    if let Some(cookie_json) = &ctx.cookie_json {
        set_json_str!(&mut payload, "additional.px_cookie"; cookie_json);
    }
    if let Some(orig_cookie_vid) = &ctx.orig_cookie_vid {
        set_json_str!(&mut payload, "additional.orig_cookie_vid"; orig_cookie_vid);
    }
    for (key, value) in &ctx.access_cookies {
        set_json_str!(&mut payload, &format!("additional.{}", key); value.to_string());
    }

    if ctx.s2s_call_reason == Some(CallReason::CookieDecryptionFailed) {
        if ctx.cookie_version == Some(CookieVersion::V2) {
            if let Some(cookie) = ctx.cookies.get("_px2") {
                set_json_str!(&mut payload, "additional.px_orig_cookie"; cookie);
            }
        } else if ctx.cookie_version == Some(CookieVersion::V3) {
            if let Some(cookie) = ctx.cookies.get("_px3") {
                set_json_str!(&mut payload, "additional.px_orig_cookie"; cookie);
            }
        }
    } else if ctx.s2s_call_reason == Some(CallReason::CookieExpired)
        || ctx.s2s_call_reason == Some(CallReason::CookieValidationFailed)
    {
        set_json_str!(&mut payload, "additional.px_cookie"; ctx.decoded_v2_cookie.as_deref().unwrap_or_default());
    }

    if let Some(err) = &ctx.original_token_error {
        set_json_str!(
            &mut payload, "additional.original_token_error";
            err
        );
    }
    set_json_str!(&mut payload, "additional.original_token"; ctx.original_token.as_deref().unwrap_or_default());

    set_json_str!(
        &mut payload, "additional.custom_param1";
        ctx.custom_params.custom_param1
    );
    set_json_str!(
        &mut payload, "additional.custom_param2";
        ctx.custom_params.custom_param2
    );
    set_json_str!(
        &mut payload, "additional.custom_param3";
        ctx.custom_params.custom_param3
    );
    set_json_str!(
        &mut payload, "additional.custom_param4";
        ctx.custom_params.custom_param4
    );
    set_json_str!(
        &mut payload, "additional.custom_param5";
        ctx.custom_params.custom_param5
    );
    set_json_str!(
        &mut payload, "additional.custom_param6";
        ctx.custom_params.custom_param6
    );
    set_json_str!(
        &mut payload, "additional.custom_param7";
        ctx.custom_params.custom_param7
    );
    set_json_str!(
        &mut payload, "additional.custom_param8";
        ctx.custom_params.custom_param8
    );
    set_json_str!(
        &mut payload, "additional.custom_param9";
        ctx.custom_params.custom_param9
    );
    set_json_str!(
        &mut payload, "additional.custom_param10";
        ctx.custom_params.custom_param10
    );

    if !ctx.graphql_extracted_items.is_empty() {
        let mut graphql_items = vec![];
        for item in &ctx.graphql_extracted_items {
            let graphql_item = json!({
                "type": item.op_type,
                "name": item.name,
                "sensitive": item.sensitive,
                "variables": item.variables,
                "keywords": item.keywords
            });
            graphql_items.push(graphql_item);
        }
        if let Some(additional) = payload.get_mut("additional") {
            additional["graphql_operations"] = json!(graphql_items);
        }
    }

    if let Some(at) = &ctx.agentic_trust_data {
        if let Some(v) = &at.mcp_method {
            set_json_str!(&mut payload, "additional.mcp_method"; v);
        }
        if let Some(v) = &at.mcp_tool_name {
            set_json_str!(&mut payload, "additional.mcp_tool_name"; v);
        }
        if let Some(v) = &at.mcp_tool_argument_keys {
            set_json_str!(&mut payload, "additional.mcp_tool_argument_keys"; v);
        }
        if let Some(v) = &at.mcp_session_id {
            set_json_str!(&mut payload, "additional.mcp_session_id"; v);
        }
        if let Some(v) = &at.mcp_http_method {
            set_json_str!(&mut payload, "additional.mcp_http_method"; v);
        }
    }

    set_json_str!(
        &mut payload, "additional.request_id";
        ctx.request_id.to_string()
    );

    if let Some(additional) = payload.get_mut("additional") {
        pxcredentials_intelligence::apply_ci_fields_to_details(additional, ctx, true, false);
    }

    payload.to_string()
}

pub(crate) fn verify_risk_response(
    ctx: &mut PXContext,
    conf: &PXConfig,
    parsed_risk_body: &serde_json::Value,
) -> bool {
    let action = parsed_risk_body["action"].as_str().unwrap_or("c");
    let uuid = parsed_risk_body["uuid"].as_str().unwrap_or_default();
    let score = parsed_risk_body["score"].as_u64().unwrap_or_default() as u8;
    ctx.telemetry_requested = parsed_risk_body["telemetry_requested"]
        .as_bool()
        .unwrap_or(false);

    if let Some(pxde) = parsed_risk_body.get("data_enrichment") {
        let data_enrichment: PXDataEnrichment =
            serde_json::from_value::<PXDataEnrichment>(pxde.clone()).unwrap_or_default();
        ctx.data_enrichment = Some(data_enrichment);
        ctx.pxde = Some(pxde.to_string());
        ctx.pxde_verified = true;
    }

    ctx.additional_risk_info = parsed_risk_body
        .get("additional_risk_info")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    if let Some(additional_token_info) = parsed_risk_body
        .get("additional_token_info")
        .and_then(|v| v.as_str())
    {
        ctx.additional_token_info = Some(additional_token_info.to_string());
    }

    ctx.pxhd_risk = parsed_risk_body
        .get("pxhd")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .filter(|s| !s.is_empty());

    ctx.pxhd_domain = parsed_risk_body
        .get("pxhdDomain")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .filter(|s| !s.is_empty());

    ctx.block_action = Some(action.to_string());
    ctx.uuid = Some(uuid.to_string());
    ctx.score = Some(score);

    // for v2 cookie: check if there is cookie_cfg_block_result field, then ignore score and block based on this field
    if ctx.cookie_version == Some(CookieVersion::V2)
        && parsed_risk_body.get("cookie_cfg_block_result").is_some()
    {
        let block_result = match parsed_risk_body["cookie_cfg_block_result"].as_str() {
            Some(c) => c.parse::<u8>().unwrap_or(0) == 1,
            None => false,
        };
        return block_result;
    };

    px_debug!(
        "score: {:?} blocking_score: {}",
        ctx.score,
        conf.blocking_score
    );
    // for v3 cookie use score to block
    ctx.score.unwrap_or(0) >= conf.blocking_score
}

pub fn verify_request(ctx: &mut PXContext, conf: &PXConfig) {
    px_debug!(
        "Evaluating Risk API request, call reason: {}",
        ctx.s2s_call_reason
            .as_ref()
            .map_or_else(|| CallReason::None.to_string(), ToString::to_string)
    );

    let payload = build_risk_payload(ctx, conf);
    px_debug!("Risk API payload: {}", payload);

    let start_risk_rtt = Instant::now();
    let risk_response = send_risk_api(payload, conf);
    ctx.risk_rtt = Some(start_risk_rtt.elapsed().as_millis() as i64);

    let resp = match risk_response {
        Ok(r) => r,
        Err(e) => {
            px_error!("Risk API failed: {}", e);
            ctx.pass_reason = Some(PassReason::Error);
            return;
        }
    };

    px_debug!("risk response status is {}", resp.get_status());
    if resp.get_status() == 200 {
        let parsed_risk_body: serde_json::Value =
            match serde_json::from_slice(&resp.into_body_bytes()) {
                Ok(body) => body,
                Err(e) => {
                    px_error!("Failed to parse Risk API response: {}", e);
                    ctx.pass_reason = Some(PassReason::Error);
                    ctx.s2s_error_reason = Some(S2sErrorReason::InvalidResponse);
                    return;
                }
            };
        if parsed_risk_body.is_object() && parsed_risk_body.get("status").is_some_and(|v| v == 0) {
            let block_result = verify_risk_response(ctx, conf, &parsed_risk_body);

            if block_result {
                px_debug!("Block based on risk response - {}", parsed_risk_body);
                ctx.block_reason = Some(BlockReason::ServerScore);
            } else {
                px_debug!("Risk response non block - {}", parsed_risk_body);
                ctx.pass_reason = Some(PassReason::S2s);
            }
        } else {
            ctx.pass_reason = Some(PassReason::Error);
            if let Some(message) = parsed_risk_body.get("message") {
                ctx.s2s_error_reason = Some(S2sErrorReason::FailedOnServer);
                if let Some(msg_str) = message.as_str() {
                    ctx.s2s_error_message = Some(String::from(msg_str));
                }
            } else {
                ctx.s2s_error_reason = Some(S2sErrorReason::InvalidResponse);
            }
            if let Some(uuid) = parsed_risk_body.get("uuid").and_then(|v| v.as_str()) {
                ctx.uuid = Some(String::from(uuid));
            }

            px_error!(
                "Unexpected exception in Risk API call: {}",
                parsed_risk_body
            );
        }
    } else {
        px_error!("Risk API failed with status: {}", resp.get_status());
        ctx.pass_reason = Some(PassReason::Error);
        ctx.s2s_error_http_status = Some(resp.get_status().as_u16());
        if 400 <= resp.get_status().as_u16() && resp.get_status().as_u16() < 500 {
            ctx.s2s_error_reason = Some(S2sErrorReason::BadRequest);
        } else if 500 <= resp.get_status().as_u16() && resp.get_status().as_u16() < 600 {
            ctx.s2s_error_reason = Some(S2sErrorReason::ServerError);
        } else {
            ctx.s2s_error_reason = Some(S2sErrorReason::Unknown);
        }
    }
}