refactor: extract signature package from imgcache #46
@@ -29,7 +29,7 @@ Image-heavy web applications need a fast, caching reverse proxy that
|
|||||||
can resize and transcode images on the fly. pixa fills that role as a
|
can resize and transcode images on the fly. pixa fills that role as a
|
||||||
single, self-contained binary with no external runtime dependencies
|
single, self-contained binary with no external runtime dependencies
|
||||||
beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
|
beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
|
||||||
prevent abuse, and whitelisted source hosts for open access.
|
prevent abuse, and allowlisted source hosts for open access.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ Images are only fetched from origins using TLS with valid certificates.
|
|||||||
|
|
||||||
### Source Hosts
|
### Source Hosts
|
||||||
|
|
||||||
Source hosts may be whitelisted in the configuration. Non-whitelisted
|
Source hosts may be allowlisted in the configuration. Non-allowlisted
|
||||||
hosts require an HMAC-SHA256 signature.
|
hosts require an HMAC-SHA256 signature.
|
||||||
|
|
||||||
#### Signature Specification
|
#### Signature Specification
|
||||||
@@ -99,7 +99,7 @@ expiration 1704067200:
|
|||||||
4. URL:
|
4. URL:
|
||||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
|
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
|
||||||
|
|
||||||
**Whitelist patterns:**
|
**Allowlist patterns:**
|
||||||
|
|
||||||
- **Exact match**: `cdn.example.com` — matches only that host
|
- **Exact match**: `cdn.example.com` — matches only that host
|
||||||
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
||||||
@@ -110,7 +110,7 @@ expiration 1704067200:
|
|||||||
Configured via YAML file (`--config`). Key settings:
|
Configured via YAML file (`--config`). Key settings:
|
||||||
|
|
||||||
- `access_control_allow_origin` — CORS origin
|
- `access_control_allow_origin` — CORS origin
|
||||||
- `source_host_whitelist` — list of allowed upstream hosts
|
- `allowlist_hosts` — list of allowed upstream hosts
|
||||||
- `upstream_fetch_timeout` — timeout for origin requests
|
- `upstream_fetch_timeout` — timeout for origin requests
|
||||||
- `upstream_max_response_size` — max origin response size
|
- `upstream_max_response_size` — max origin response size
|
||||||
- `downstream_timeout` — client response timeout
|
- `downstream_timeout` — client response timeout
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ maintenance_mode: false
|
|||||||
state_dir: ./data
|
state_dir: ./data
|
||||||
|
|
||||||
# Image proxy settings
|
# Image proxy settings
|
||||||
# HMAC signing key for URL signatures (leave empty to require whitelist for all requests)
|
# HMAC signing key for URL signatures (leave empty to require allowlist for all requests)
|
||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||||
|
|
||||||
# Hosts that don't require signatures
|
# Hosts that don't require signatures
|
||||||
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
||||||
whitelist_hosts:
|
allowlist_hosts:
|
||||||
- s3.sneak.cloud
|
- s3.sneak.cloud
|
||||||
- static.sneak.cloud
|
- static.sneak.cloud
|
||||||
- sneak.berlin
|
- sneak.berlin
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ type Config struct {
|
|||||||
|
|
||||||
// Image proxy settings
|
// Image proxy settings
|
||||||
SigningKey string // HMAC signing key for URL signatures
|
SigningKey string // HMAC signing key for URL signatures
|
||||||
WhitelistHosts []string // Hosts that don't require signatures
|
AllowlistHosts []string // Hosts that don't require signatures
|
||||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
MetricsUsername: getString(sc, "metrics.username", ""),
|
MetricsUsername: getString(sc, "metrics.username", ""),
|
||||||
MetricsPassword: getString(sc, "metrics.password", ""),
|
MetricsPassword: getString(sc, "metrics.password", ""),
|
||||||
SigningKey: getString(sc, "signing_key", ""),
|
SigningKey: getString(sc, "signing_key", ""),
|
||||||
WhitelistHosts: getStringSlice(sc, "whitelist_hosts"),
|
AllowlistHosts: getStringSlice(sc, "allowlist_hosts"),
|
||||||
AllowHTTP: getBool(sc, "allow_http", false),
|
AllowHTTP: getBool(sc, "allow_http", false),
|
||||||
UpstreamConnectionsPerHost: getInt(sc, "upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
UpstreamConnectionsPerHost: getInt(sc, "upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestGetStringSlice_YAMLList(t *testing.T) {
|
|||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
yamlContent := `
|
yamlContent := `
|
||||||
whitelist_hosts:
|
allowlist_hosts:
|
||||||
- static.sneak.cloud
|
- static.sneak.cloud
|
||||||
- sneak.berlin
|
- sneak.berlin
|
||||||
- s3.sneak.cloud
|
- s3.sneak.cloud
|
||||||
@@ -31,7 +31,7 @@ whitelist_hosts:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test that getStringSlice correctly parses YAML list
|
// Test that getStringSlice correctly parses YAML list
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
hosts := getStringSlice(sc, "allowlist_hosts")
|
||||||
|
|
||||||
if len(hosts) != 3 {
|
if len(hosts) != 3 {
|
||||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||||
@@ -54,7 +54,7 @@ func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
yamlContent := `whitelist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
||||||
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -66,7 +66,7 @@ func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
|||||||
t.Fatalf("failed to load config: %v", err)
|
t.Fatalf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
hosts := getStringSlice(sc, "allowlist_hosts")
|
||||||
|
|
||||||
if len(hosts) != 3 {
|
if len(hosts) != 3 {
|
||||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||||
@@ -100,7 +100,7 @@ func TestGetStringSlice_Empty(t *testing.T) {
|
|||||||
t.Fatalf("failed to load config: %v", err)
|
t.Fatalf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
hosts := getStringSlice(sc, "allowlist_hosts")
|
||||||
|
|
||||||
if hosts != nil && len(hosts) != 0 {
|
if hosts != nil && len(hosts) != 0 {
|
||||||
t.Errorf("expected nil or empty slice, got %v", hosts)
|
t.Errorf("expected nil or empty slice, got %v", hosts)
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func (s *Handlers) initImageService() error {
|
|||||||
Cache: cache,
|
Cache: cache,
|
||||||
FetcherConfig: fetcherCfg,
|
FetcherConfig: fetcherCfg,
|
||||||
SigningKey: s.config.SigningKey,
|
SigningKey: s.config.SigningKey,
|
||||||
Allowlist: s.config.WhitelistHosts,
|
Allowlist: s.config.AllowlistHosts,
|
||||||
Logger: s.log,
|
Logger: s.log,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ else
|
|||||||
fail "No encrypted URL to test"
|
fail "No encrypted URL to test"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 7: Fetch image via whitelisted host (direct proxy)
|
# Test 7: Fetch image via allowlisted host (direct proxy)
|
||||||
echo "--- Test 7: Fetch image via direct proxy (whitelisted host) ---"
|
echo "--- Test 7: Fetch image via direct proxy (allowlisted host) ---"
|
||||||
# URL format: /v1/image/<host>/<path>/<WxH>.<format>
|
# URL format: /v1/image/<host>/<path>/<WxH>.<format>
|
||||||
PROXY_PATH="/v1/image/s3.sneak.cloud/sneak-public/2021/2021-04-18.untitled.a7r4.07723.jpg/400x300.jpeg"
|
PROXY_PATH="/v1/image/s3.sneak.cloud/sneak-public/2021/2021-04-18.untitled.a7r4.07723.jpg/400x300.jpeg"
|
||||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL$PROXY_PATH")
|
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL$PROXY_PATH")
|
||||||
|
|||||||
Reference in New Issue
Block a user