multistore 0.6.4

Runtime-agnostic core library for the S3 proxy gateway
Documentation
//! Backend URL building and request signing for raw signed (non-presigned)
//! S3 operations.
//!
//! These helpers build the backend URL and sign the request for the operations
//! that go through [`ProxyBackend::send_raw`](crate::backend::ProxyBackend::send_raw):
//! the multipart operations (CreateMultipartUpload, UploadPart,
//! CompleteMultipartUpload, AbortMultipartUpload) and batch delete
//! (DeleteObjects).

use crate::backend::request_signer::S3RequestSigner;
use crate::error::ProxyError;
use crate::types::{BucketConfig, S3Operation};
use http::{HeaderMap, Method};
use url::Url;

/// SigV4 canonical path encoding: everything but RFC 3986 unreserved
/// characters is percent-encoded; `/` stays raw as the segment separator.
///
/// AWS (and MinIO) reconstruct the canonical URI by strict-encoding the
/// decoded request path, and `url::Url` leaves `=` and friends literal in
/// paths — so the key must be encoded with this exact set before it is
/// spliced into the URL, or the backend signature won't match
/// (`SignatureDoesNotMatch`). This is the same strict set `object_store`
/// applies when building presigned URLs for the CRUD operations.
pub(crate) const S3_PATH_ENCODE_SET: &percent_encoding::AsciiSet =
    &percent_encoding::NON_ALPHANUMERIC
        .remove(b'-')
        .remove(b'.')
        .remove(b'_')
        .remove(b'~')
        .remove(b'/');

/// Build the backend URL for an S3 operation.
///
/// Used for multipart operations that go through raw signed HTTP.
pub fn build_backend_url(
    config: &BucketConfig,
    operation: &S3Operation,
) -> Result<String, ProxyError> {
    let endpoint = config.option("endpoint").unwrap_or("");
    let base = endpoint.trim_end_matches('/');
    let bucket = config.option("bucket_name").unwrap_or("");
    let bucket_is_empty = bucket.is_empty();

    // Batch delete targets the bucket, not a key: `{base}[/{bucket}]?delete`.
    if matches!(operation, S3Operation::DeleteObjects { .. }) {
        return Ok(if bucket_is_empty {
            format!("{base}?delete")
        } else {
            format!("{base}/{bucket}?delete")
        });
    }

    // Backstop: keys are validated at operation-parse time
    // (`build_s3_operation`); enforce here too so a hand-built operation can
    // never splice a degenerate key (`a//b`, `..`) into a URL that
    // normalizes to a different — possibly cross-bucket — target.
    crate::api::request::validate_key(operation.key())?;

    let mut key = String::new();
    if let Some(prefix) = &config.backend_prefix {
        key.push_str(prefix.trim_end_matches('/'));
        key.push('/');
    }
    key.push_str(operation.key());
    let key = percent_encoding::utf8_percent_encode(&key, S3_PATH_ENCODE_SET).to_string();

    let mut url = if bucket_is_empty {
        format!("{}/{}", base, key)
    } else {
        format!("{}/{}/{}", base, bucket, key)
    };

    match operation {
        S3Operation::CreateMultipartUpload { .. } => {
            url.push_str("?uploads");
        }
        S3Operation::UploadPart {
            upload_id,
            part_number,
            ..
        } => {
            let qs = url::form_urlencoded::Serializer::new(String::new())
                .append_pair("partNumber", &part_number.to_string())
                .append_pair("uploadId", upload_id)
                .finish();
            url.push('?');
            url.push_str(&qs);
        }
        S3Operation::CompleteMultipartUpload { upload_id, .. }
        | S3Operation::AbortMultipartUpload { upload_id, .. } => {
            let qs = url::form_urlencoded::Serializer::new(String::new())
                .append_pair("uploadId", upload_id)
                .finish();
            url.push('?');
            url.push_str(&qs);
        }
        _ => {}
    }

    Ok(url)
}

/// Sign an outbound S3 request using credentials from the bucket config.
///
/// Used for multipart operations only. CRUD operations use presigned URLs.
pub(crate) fn sign_s3_request(
    method: &Method,
    url: &str,
    headers: &mut HeaderMap,
    config: &BucketConfig,
    payload_hash: &str,
) -> Result<(), ProxyError> {
    let access_key = config.option("access_key_id").unwrap_or("");
    let secret_key = config.option("secret_access_key").unwrap_or("");
    let region = config.option("region").unwrap_or("us-east-1");
    let has_credentials = !access_key.is_empty() && !secret_key.is_empty();

    let parsed_url =
        Url::parse(url).map_err(|e| ProxyError::Internal(format!("invalid backend URL: {}", e)))?;

    if has_credentials {
        let session_token = config.option("token").map(|s| s.to_string());
        let signer = S3RequestSigner::new(
            access_key.to_string(),
            secret_key.to_string(),
            region.to_string(),
            session_token,
        );
        signer.sign_request(method, &parsed_url, headers, payload_hash)?;
    } else {
        let host = parsed_url
            .host_str()
            .ok_or_else(|| ProxyError::Internal("no host in URL".into()))?;
        let host_header = if let Some(port) = parsed_url.port() {
            format!("{}:{}", host, port)
        } else {
            host.to_string()
        };
        headers.insert("host", host_header.parse().unwrap());
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    fn test_bucket_config() -> BucketConfig {
        let mut backend_options = HashMap::new();
        backend_options.insert(
            "endpoint".into(),
            "https://s3.us-east-1.amazonaws.com".into(),
        );
        backend_options.insert("bucket_name".into(), "my-backend-bucket".into());
        BucketConfig {
            name: "test".into(),
            backend_type: "s3".into(),
            backend_prefix: None,
            anonymous_access: false,
            allowed_roles: vec![],
            backend_options,
        }
    }

    #[test]
    fn degenerate_keys_are_rejected_before_url_build() {
        let config = test_bucket_config();
        for key in ["a//b.txt", "a/../b.txt"] {
            let op = S3Operation::CreateMultipartUpload {
                bucket: "test".into(),
                key: key.into(),
            };
            assert!(
                build_backend_url(&config, &op).is_err(),
                "hand-built op with key {key:?} must not reach the URL"
            );
        }
    }

    #[test]
    fn upload_id_with_special_chars_is_encoded() {
        let config = test_bucket_config();
        let malicious_upload_id = "abc&x-amz-acl=public-read&foo=bar";
        let op = S3Operation::UploadPart {
            bucket: "test".into(),
            key: "file.bin".into(),
            upload_id: malicious_upload_id.into(),
            part_number: 1,
        };

        let url = build_backend_url(&config, &op).unwrap();

        // The & and = characters in upload_id must be percent-encoded so they
        // cannot act as query parameter separators/assignments.
        let query = url.split_once('?').unwrap().1;
        let params: Vec<(String, String)> = url::form_urlencoded::parse(query.as_bytes())
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect();

        // Should be exactly 2 params: partNumber and uploadId
        assert_eq!(
            params.len(),
            2,
            "expected 2 query params, got: {:?}",
            params
        );
        assert!(params.iter().any(|(k, v)| k == "partNumber" && v == "1"));
        assert!(params
            .iter()
            .any(|(k, v)| k == "uploadId" && v == malicious_upload_id));
    }

    #[test]
    fn upload_id_encoded_in_complete_multipart() {
        let config = test_bucket_config();
        let op = S3Operation::CompleteMultipartUpload {
            bucket: "test".into(),
            key: "file.bin".into(),
            upload_id: "id&injected=true".into(),
        };

        let url = build_backend_url(&config, &op).unwrap();

        assert!(
            !url.contains("injected=true"),
            "upload_id was not encoded: {}",
            url
        );
    }

    #[test]
    fn key_special_chars_percent_encoded_in_backend_path() {
        let config = test_bucket_config();
        let op = S3Operation::CreateMultipartUpload {
            bucket: "test".into(),
            key: "by_country/country_iso=ETH/ETH file.pmtiles".into(),
        };

        let url = build_backend_url(&config, &op).unwrap();

        // `=` and space are outside the AWS strict set and must be encoded;
        // `/` stays raw as the segment separator.
        assert_eq!(
            url,
            "https://s3.us-east-1.amazonaws.com/my-backend-bucket/by_country/country_iso%3DETH/ETH%20file.pmtiles?uploads"
        );
        // Url::parse must not re-encode the result: what sign_request signs
        // (`url.path()`) is byte-identical to the wire path.
        assert_eq!(
            Url::parse(&url).unwrap().path(),
            "/my-backend-bucket/by_country/country_iso%3DETH/ETH%20file.pmtiles"
        );
    }

    #[test]
    fn key_aws_special_chars_encoded_slash_preserved() {
        let config = test_bucket_config();
        let op = S3Operation::UploadPart {
            bucket: "test".into(),
            key: "p!('*'):@+,;$&/f.bin".into(),
            upload_id: "uid".into(),
            part_number: 1,
        };

        let url = build_backend_url(&config, &op).unwrap();

        let path = url.split_once('?').unwrap().0;
        assert_eq!(
            path,
            "https://s3.us-east-1.amazonaws.com/my-backend-bucket/p%21%28%27%2A%27%29%3A%40%2B%2C%3B%24%26/f.bin"
        );
    }

    #[test]
    fn key_with_literal_percent_triplet_is_double_encoded() {
        // A key that literally contains `%3D` (already decoded upstream) must
        // go out as `%253D`, not be mistaken for an encoded `=`.
        let config = test_bucket_config();
        let op = S3Operation::CreateMultipartUpload {
            bucket: "test".into(),
            key: "weird/%3D-literal.txt".into(),
        };

        let url = build_backend_url(&config, &op).unwrap();

        assert_eq!(
            url,
            "https://s3.us-east-1.amazonaws.com/my-backend-bucket/weird/%253D-literal.txt?uploads"
        );
    }

    #[test]
    fn normal_upload_id_works() {
        let config = test_bucket_config();
        let op = S3Operation::UploadPart {
            bucket: "test".into(),
            key: "file.bin".into(),
            upload_id: "2~abcdef1234567890".into(),
            part_number: 3,
        };

        let url = build_backend_url(&config, &op).unwrap();

        assert!(url.starts_with("https://s3.us-east-1.amazonaws.com/my-backend-bucket/file.bin?"));
        assert!(url.contains("partNumber=3"));
        assert!(
            url.contains("uploadId=2~abcdef1234567890")
                || url.contains("uploadId=2%7Eabcdef1234567890")
        );
    }
}