Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 18 additions & 26 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.1] - 2026-02-09

### Fixed
- Fixed infinite redirect loop when Akkoma has `force_ssl: [rewrite_on: [:x_forwarded_proto]]` enabled

### Added
- Initial release of Akkoma Media Proxy
- Caching reverse proxy for Akkoma/Pleroma media
- Automatic image format conversion (AVIF, WebP)
- Content negotiation based on Accept headers
- Path filtering for `/media` and `/proxy` endpoints
- TOML-based configuration with sensible defaults
- Environment variable configuration support
- Docker support with multi-platform builds
- GitHub Actions CI/CD pipeline
- Health check endpoint (`/health`)
- Metrics endpoint (`/metrics`)
- Comprehensive documentation
- Example configuration files
- Docker Compose example

### Features
- High-performance async I/O with Tokio
- Intelligent caching with TTL and size limits
- Image quality and dimension controls
- Configurable Via header
- Connection pooling for upstream requests
- CORS support
- Gzip/Brotli compression
- Security hardening (path restrictions, timeouts)
- **Secure X-Forwarded headers support**: Opt-in forwarding of `X-Forwarded-Proto`, `X-Forwarded-For`, and `X-Forwarded-Host` headers with trusted proxy validation
- Configuration options `forward_headers_enabled` and `trusted_proxies` for controlling header forwarding behavior
- IP address and CIDR range matching for trusted proxy verification
- Automatic header derivation from actual connection for untrusted sources
- Comprehensive test suite for header forwarding and trusted proxy functionality

### Security
- X-Forwarded headers are now only honored from explicitly trusted proxy sources
- Prevents header spoofing attacks by validating client IP against configured trusted proxies
- Headers from untrusted sources are ignored or overwritten with actual connection information

## [0.1.0] - 2024-12-06

### Added
- Initial implementation

[Unreleased]: https://github.com/BlockG-ws/fantastic-computing-machine/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/BlockG-ws/fantastic-computing-machine/releases/tag/v0.1.0
[Unreleased]: https://github.com/BlockG-ws/akkoproxy/compare/v0.1.1...HEAD
[0.1.1]: https://github.com/BlockG-ws/akkoproxy/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/BlockG-ws/akkoproxy/releases/tag/v0.1.0
12 changes: 11 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "akkoproxy"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
authors = ["Akkoproxy Contributors"]
description = "A fast caching and optimization media proxy for Akkoma/Pleroma"
Expand Down Expand Up @@ -40,6 +40,7 @@ futures = "0.3"
http-body-util = "0.1"
url = "2.5"
clap = { version = "4.5", features = ["derive"] }
ipnetwork = "0.20"

[profile.release]
opt-level = 3
Expand Down
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ A fast caching and optimization media proxy for Akkoma/Pleroma, built in Rust.
## Features

- **Caching Reverse Proxy**: Caches media and proxy requests to reduce load on upstream servers
- **Secure X-Forwarded Headers**: Opt-in forwarding of `X-Forwarded-Proto`, `X-Forwarded-For`, and `X-Forwarded-Host` headers with trusted proxy validation, ensuring compatibility with Akkoma's `force_ssl` configuration
- **Header Preservation**: Preserves all upstream headers by default, including redirects (302) with Location headers
- **Image Format Conversion**: Automatically converts images to modern formats (AVIF, WebP) based on client `Accept` headers
- **Path Filtering**: Only handles `/media` and `/proxy` endpoints for security
Expand Down Expand Up @@ -93,9 +94,51 @@ timeout = 30 # Request timeout in seconds
```toml
[server]
bind = "0.0.0.0:3000" # Bind address
via_header = "akkoma-media-proxy/0.1.0" # Via header value
via_header = "akkoma-media-proxy/0.1.1" # Via header value
preserve_upstream_headers = true # Preserve all headers from upstream (default: true)
behind_cloudflare_free = false # Enable Cloudflare Free plan compatibility (default: false)

# X-Forwarded headers configuration (for SSL/TLS detection)
forward_headers_enabled = false # Enable X-Forwarded-* header forwarding (default: false)
trusted_proxies = ["192.168.1.1", "10.0.0.0/8"] # Trusted proxy IPs/CIDRs (default: empty)
```

#### X-Forwarded Headers and Trusted Proxies

**Important for Akkoma's `force_ssl` configuration:**

When Akkoma has `force_ssl: [rewrite_on: [:x_forwarded_proto]]` enabled, it relies on the `X-Forwarded-Proto` header to detect HTTPS connections. To prevent infinite redirect loops, you need to configure header forwarding:

1. **Enable header forwarding**: Set `forward_headers_enabled = true`
2. **Configure trusted proxies**: List the IP addresses or CIDR ranges of your reverse proxy/load balancer in `trusted_proxies`

**Security considerations:**
- Only enable `forward_headers_enabled` if you're behind a reverse proxy (nginx, Cloudflare, etc.)
- **Must** configure `trusted_proxies` with your proxy IPs - an empty list will prevent all header forwarding
- Only requests from trusted IPs will have their `X-Forwarded-*` headers honored
- Requests from untrusted sources will have headers derived from the actual connection

**Example configurations:**

```toml
# Behind nginx on the same host
[server]
forward_headers_enabled = true
trusted_proxies = ["127.0.0.1", "::1"]

# Behind Cloudflare (use Cloudflare's IP ranges)
[server]
forward_headers_enabled = true
trusted_proxies = [
"173.245.48.0/20",
"103.21.244.0/22",
# ... other Cloudflare ranges
]

# Behind a local reverse proxy
[server]
forward_headers_enabled = true
trusted_proxies = ["192.168.1.1", "10.0.0.0/8"]
```

#### Cloudflare Free Plan Compatibility
Expand Down Expand Up @@ -153,6 +196,9 @@ max_dimension = 4096 # Maximum image dimension
1. **Request Filtering**: Only `/media` and `/proxy` paths are allowed
2. **Cache Check**: Looks for cached response with the requested format
3. **Upstream Fetch**: If not cached, fetches from upstream server
- **Secure header forwarding**: When enabled, forwards `X-Forwarded-Proto`, `X-Forwarded-For`, and `X-Forwarded-Host` headers only from trusted proxy sources
- **Untrusted protection**: Headers from untrusted sources are ignored or derived from the actual connection
- This ensures proper SSL/TLS detection when Akkoma has `force_ssl` enabled while preventing header spoofing
4. **Header Preservation**: All upstream headers (including Location for redirects) are preserved by default
5. **Image Conversion**: For images, converts to the best format based on `Accept` header:
- Prefers AVIF if `image/avif` is accepted
Expand Down
17 changes: 16 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ timeout = 30
bind = "0.0.0.0:3000"

# Custom Via header value (default: akkoproxy/{version})
via_header = "akkoproxy/0.1.0"
via_header = "akkoproxy/0.1.1"

# Preserve all headers from upstream when responding (default: true)
preserve_upstream_headers = true
Expand All @@ -27,6 +27,21 @@ preserve_upstream_headers = true
# - Then: Add query parameter "format=avif"
behind_cloudflare_free = false

# Enable forwarding of X-Forwarded-* headers to upstream (default: false)
# When enabled, X-Forwarded-Proto, X-Forwarded-For, and X-Forwarded-Host headers
# will be forwarded to the upstream server based on the trusted_proxies configuration.
# This is essential for proper SSL/TLS detection when Akkoma has force_ssl enabled.
forward_headers_enabled = false

# List of trusted proxy IP addresses or CIDR ranges (default: empty)
# Only requests from these IPs will have their X-Forwarded-* headers honored.
# If empty and forward_headers_enabled is true, no headers will be forwarded (secure default).
# For untrusted sources, X-Forwarded-For will be set to the actual client IP.
# Examples:
# trusted_proxies = ["192.168.1.1", "10.0.0.0/8", "172.16.0.0/12"]
# trusted_proxies = ["127.0.0.1", "::1"] # localhost only
trusted_proxies = []

[cache]
# Maximum number of cached items (default: 10000)
max_capacity = 10000
Expand Down
47 changes: 22 additions & 25 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,20 @@ impl ResponseCache {
.time_to_live(ttl)
.initial_capacity(100)
.build();

Self { cache }
}

/// Get a cached response
pub async fn get(&self, key: &CacheKey) -> Option<Arc<CachedResponse>> {
self.cache.get(key).await
}

/// Store a response in the cache
pub async fn put(&self, key: CacheKey, response: CachedResponse) {
self.cache.insert(key, Arc::new(response)).await;
}

/// Get cache statistics
pub fn stats(&self) -> CacheStats {
CacheStats {
Expand All @@ -77,16 +77,16 @@ mod tests {
#[tokio::test]
async fn test_cache_put_and_get() {
let cache = ResponseCache::new(100, Duration::from_secs(60), 1024 * 1024);

let key = CacheKey::new("/media/test.jpg".to_string(), "avif".to_string());
let response = CachedResponse {
data: Bytes::from("test data"),
content_type: "image/avif".to_string(),
upstream_headers: None,
};

cache.put(key.clone(), response.clone()).await;

let cached = cache.get(&key).await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().content_type, "image/avif");
Expand All @@ -95,67 +95,64 @@ mod tests {
#[tokio::test]
async fn test_cache_miss() {
let cache = ResponseCache::new(100, Duration::from_secs(60), 1024 * 1024);

let key = CacheKey::new("/media/nonexistent.jpg".to_string(), "webp".to_string());
let cached = cache.get(&key).await;

assert!(cached.is_none());
}

#[tokio::test]
async fn test_cache_with_upstream_headers() {
let cache = ResponseCache::new(100, Duration::from_secs(60), 1024 * 1024);

// Create headers to cache
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("x-custom-header"),
HeaderValue::from_static("test-value"),
);

let key = CacheKey::new("/media/test.jpg".to_string(), "avif".to_string());
let response = CachedResponse {
data: Bytes::from("test data"),
content_type: "image/avif".to_string(),
upstream_headers: Some(headers.clone()),
};

cache.put(key.clone(), response.clone()).await;

let cached = cache.get(&key).await;
assert!(cached.is_some());
let cached = cached.unwrap();
assert_eq!(cached.content_type, "image/avif");
assert!(cached.upstream_headers.is_some());

let cached_headers = cached.upstream_headers.as_ref().unwrap();
assert_eq!(
cached_headers.get("x-custom-header").unwrap(),
"test-value"
);
assert_eq!(cached_headers.get("x-custom-header").unwrap(), "test-value");
}

#[tokio::test]
async fn test_cache_ttl() {
// Create cache with 1 second TTL
let cache = ResponseCache::new(100, Duration::from_secs(1), 1024 * 1024);

let key = CacheKey::new("/media/test.jpg".to_string(), "avif".to_string());
let response = CachedResponse {
data: Bytes::from("test data"),
content_type: "image/avif".to_string(),
upstream_headers: None,
};

cache.put(key.clone(), response.clone()).await;

// Should be in cache immediately
let cached = cache.get(&key).await;
assert!(cached.is_some());

// Wait for TTL to expire
tokio::time::sleep(Duration::from_secs(2)).await;

// Should be gone after TTL
let cached = cache.get(&key).await;
assert!(cached.is_none());
Expand Down
Loading