1 Commits

Author SHA1 Message Date
user
e241b99d22 remove suffix matching from host whitelist
All checks were successful
check / check (push) Successful in 1m50s
Signatures are per-URL, so the whitelist should only support exact host
matches. Remove the suffix/wildcard matching that allowed patterns like
'.example.com' to bypass signature requirements for entire domain trees.

Leading dots in existing config entries are now stripped, so '.example.com'
becomes 'example.com' as an exact match (backwards-compatible normalisation).
2026-03-17 01:55:19 -07:00
7 changed files with 48 additions and 154 deletions

View File

@@ -6,4 +6,3 @@
node_modules
bin/
data/
deploy/

View File

@@ -75,7 +75,4 @@ WORKDIR /var/lib/pixa
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost:8080/.well-known/healthcheck.json
ENTRYPOINT ["/usr/local/bin/pixad", "--config", "/etc/pixa/config.yml"]

View File

@@ -96,11 +96,10 @@ expiration 1704067200:
4. URL:
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
**Whitelist patterns:**
- **Exact match**: `cdn.example.com` — matches only that host
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
`images.example.com`, and `example.com`
**Whitelist entries** are exact host matches only (e.g.
`cdn.example.com`). Suffix/wildcard matching is not supported —
signatures are per-URL, so each allowed host must be listed
explicitly.
### Configuration
@@ -125,17 +124,6 @@ See `config.example.yml` for all options with defaults.
- **Metrics**: Prometheus
- **Logging**: stdlib slog
## Deployment
Pixa is deployed via
[µPaaS](https://git.eeqj.de/sneak/upaas) on `fsn1app1`
(paas.datavi.be). Pushes to `main` trigger automatic builds and
deployments. The Dockerfile includes a `HEALTHCHECK` that probes
`/.well-known/healthcheck.json`.
See [deploy/README.md](deploy/README.md) for the full µPaaS app
configuration, volume mounts, and production setup instructions.
## TODO
See [TODO.md](TODO.md) for the full prioritized task list.

View File

@@ -13,8 +13,7 @@ state_dir: ./data
# Generate with: openssl rand -base64 32
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
# Hosts that don't require signatures
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
# Hosts that don't require signatures (exact match only)
whitelist_hosts:
- s3.sneak.cloud
- static.sneak.cloud

View File

@@ -1,78 +0,0 @@
# Pixa Deployment via µPaaS
Pixa is deployed on `fsn1app1` via
[µPaaS](https://git.eeqj.de/sneak/upaas) (paas.datavi.be).
## µPaaS App Configuration
Create the app in the µPaaS web UI with these settings:
| Setting | Value |
| --- | --- |
| **App name** | `pixa` |
| **Repo URL** | `git@git.eeqj.de:sneak/pixa.git` |
| **Branch** | `main` |
| **Dockerfile path** | `Dockerfile` |
### Environment Variables
| Variable | Description | Required |
| --- | --- | --- |
| `PORT` | HTTP listen port (default: 8080) | No |
Configuration is provided via the config file baked into the Docker
image at `/etc/pixa/config.yml`. To override it, mount a custom
config file as a volume (see below).
### Volumes
| Host Path | Container Path | Description |
| --- | --- | --- |
| `/srv/pixa/data` | `/var/lib/pixa` | SQLite database and image cache |
| `/srv/pixa/config.yml` | `/etc/pixa/config.yml` | Production config (signing key, whitelist, etc.) |
### Ports
| Host Port | Container Port | Protocol |
| --- | --- | --- |
| (assigned) | 8080 | TCP |
### Docker Network
Attach to the shared reverse-proxy network if using Caddy/Traefik
for TLS termination.
## Production Configuration
Copy `config.example.yml` from the repo root and customize for
production:
```yaml
port: 8080
debug: false
maintenance_mode: false
state_dir: /var/lib/pixa
signing_key: "<generate with: openssl rand -base64 32>"
whitelist_hosts:
- s3.sneak.cloud
- static.sneak.cloud
- sneak.berlin
allow_http: false
```
**Important:** Generate a unique `signing_key` for production. Never
use the default placeholder value.
## Health Check
The Dockerfile includes a `HEALTHCHECK` instruction that probes
`/.well-known/healthcheck.json` every 30 seconds. µPaaS verifies
container health 60 seconds after deployment.
## Deployment Flow
1. Push to `main` triggers the Gitea webhook
2. µPaaS clones the repo and runs `docker build .`
3. The Dockerfile runs `make check` (format, lint, test) during build
4. On success, µPaaS stops the old container and starts the new one
5. After 60 seconds, µPaaS checks container health

View File

@@ -5,23 +5,20 @@ import (
"strings"
)
// HostWhitelist implements the Whitelist interface for checking allowed source hosts.
// HostWhitelist checks whether a source host is allowed without a signature.
// Only exact host matches are supported. Signatures are per-URL, so
// wildcard/suffix matching is intentionally not provided.
type HostWhitelist struct {
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
exactHosts map[string]struct{}
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com")
suffixHosts []string
}
// NewHostWhitelist creates a whitelist from a list of host patterns.
// Patterns starting with "." are treated as suffix matches.
// Examples:
// - "cdn.example.com" - exact match only
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
// NewHostWhitelist creates a whitelist from a list of hostnames.
// Each entry is treated as an exact host match. Leading dots are
// stripped so that legacy ".example.com" entries become "example.com".
func NewHostWhitelist(patterns []string) *HostWhitelist {
w := &HostWhitelist{
exactHosts: make(map[string]struct{}),
suffixHosts: make([]string, 0),
exactHosts: make(map[string]struct{}),
}
for _, pattern := range patterns {
@@ -30,9 +27,11 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
continue
}
if strings.HasPrefix(pattern, ".") {
w.suffixHosts = append(w.suffixHosts, pattern)
} else {
// Strip leading dot — suffix matching is no longer supported;
// ".example.com" is normalised to "example.com" as an exact entry.
pattern = strings.TrimPrefix(pattern, ".")
if pattern != "" {
w.exactHosts[pattern] = struct{}{}
}
}
@@ -40,7 +39,7 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
return w
}
// IsWhitelisted checks if a URL's host is in the whitelist.
// IsWhitelisted checks if a URL's host is in the whitelist (exact match only).
func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
if u == nil {
return false
@@ -51,32 +50,17 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
return false
}
// Check exact match
if _, ok := w.exactHosts[host]; ok {
return true
}
_, ok := w.exactHosts[host]
// Check suffix match
for _, suffix := range w.suffixHosts {
if strings.HasSuffix(host, suffix) {
return true
}
// Also match if host equals the suffix without the leading dot
// e.g., pattern ".example.com" should match "example.com"
if host == strings.TrimPrefix(suffix, ".") {
return true
}
}
return false
return ok
}
// IsEmpty returns true if the whitelist has no entries.
func (w *HostWhitelist) IsEmpty() bool {
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
return len(w.exactHosts) == 0
}
// Count returns the total number of whitelist entries.
func (w *HostWhitelist) Count() int {
return len(w.exactHosts) + len(w.suffixHosts)
return len(w.exactHosts)
}

View File

@@ -31,41 +31,41 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
want: false,
},
{
name: "suffix match",
patterns: []string{".example.com"},
name: "no suffix matching for subdomains",
patterns: []string{"example.com"},
testURL: "https://cdn.example.com/image.jpg",
want: true,
want: false,
},
{
name: "suffix match deep subdomain",
patterns: []string{".example.com"},
testURL: "https://cdn.images.example.com/image.jpg",
want: true,
},
{
name: "suffix match apex domain",
name: "leading dot stripped to exact match",
patterns: []string{".example.com"},
testURL: "https://example.com/image.jpg",
want: true,
},
{
name: "suffix match not found",
name: "leading dot does not enable suffix matching",
patterns: []string{".example.com"},
testURL: "https://notexample.com/image.jpg",
testURL: "https://cdn.example.com/image.jpg",
want: false,
},
{
name: "suffix match partial not allowed",
name: "leading dot does not match deep subdomain",
patterns: []string{".example.com"},
testURL: "https://fakeexample.com/image.jpg",
testURL: "https://cdn.images.example.com/image.jpg",
want: false,
},
{
name: "multiple patterns",
patterns: []string{"cdn.example.com", ".images.org", "static.test.net"},
name: "multiple patterns exact only",
patterns: []string{"cdn.example.com", "photos.images.org", "static.test.net"},
testURL: "https://photos.images.org/image.jpg",
want: true,
},
{
name: "multiple patterns no suffix leak",
patterns: []string{"cdn.example.com", "images.org"},
testURL: "https://photos.images.org/image.jpg",
want: false,
},
{
name: "empty whitelist",
patterns: []string{},
@@ -86,7 +86,7 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
},
{
name: "whitespace in patterns",
patterns: []string{" cdn.example.com ", " .other.com "},
patterns: []string{" cdn.example.com ", " other.com "},
testURL: "https://cdn.example.com/image.jpg",
want: true,
},
@@ -139,6 +139,11 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
patterns: []string{"example.com"},
want: false,
},
{
name: "leading dot normalised to entry",
patterns: []string{".example.com"},
want: false,
},
}
for _, tt := range tests {
@@ -168,14 +173,14 @@ func TestHostWhitelist_Count(t *testing.T) {
want: 3,
},
{
name: "suffix hosts only",
name: "leading dots normalised to exact",
patterns: []string{".a.com", ".b.com"},
want: 2,
},
{
name: "mixed",
patterns: []string{"exact.com", ".suffix.com"},
want: 2,
name: "mixed deduplication",
patterns: []string{"example.com", ".example.com"},
want: 1,
},
}