3 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
2e934c8894 fix: QA audit fixes for 1.0/MVP readiness (#25)
All checks were successful
check / check (push) Successful in 5s
closes #24

## QA Audit Fixes

This PR addresses issues found during the 1.0/MVP QA audit.

### Changes

1. **TODO.md: Mark AVIF encoding as done** — AVIF encoding is fully implemented via govips in `processor.go` but was still listed as a TODO item.

2. **scripts/manual-test.sh: Fix form field names** — The manual test script was using wrong field names:
   - Login form: was sending `password=...`, should be `key=...` (matching the HTML form's `name="key"`)
   - Generator form: was sending `source_url`, `fit_mode` — should be `url`, `fit` (matching the handler's `r.FormValue()` calls)
   - This means **the manual test script never actually worked** — login always failed silently because the `key` field was empty.

### Full QA Audit Results

The comprehensive QA audit report has been posted as a comment on [issue #24](#24).

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #25
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-15 17:58:13 +01:00
2f15340f26 Split Dockerfile: pre-built golangci-lint stage for faster CI (#23)
All checks were successful
check / check (push) Successful in 5s
## Summary

Splits the Dockerfile into a dedicated lint stage using the pre-built `golangci/golangci-lint:v2.10.1-alpine` Docker image, replacing the manual binary download with curl/sha256 verification.

## Changes

- **Lint stage** (`AS lint`): Uses `golangci/golangci-lint:v2.10.1-alpine` pinned by sha256. Runs `make fmt-check` + `make lint`. Includes CGO deps (`build-base`, `vips-dev`, `libheif-dev`, `pkgconfig`) needed for type-checking govips imports.
- **Build stage** (`AS builder`): Depends on lint stage via `COPY --from=lint /src/go.sum /dev/null`. Runs `make test` + builds the binary. Removes `curl` (no longer needed) and the manual golangci-lint download block.
- **Runtime stage**: Unchanged.

## Benefits

- Eliminates slow multi-arch binary download + sha256 verification step
- Lint and build stages can potentially run in parallel with BuildKit
- Better Docker layer caching — lint deps cached separately from build deps
- All images remain pinned by sha256 with version+date comments

## Verification

- `docker build .` passes: fmt-check , lint (0 issues) , all tests pass , binary builds 

Closes [#18](#18)

<!-- session: agent:sdlc-manager:subagent:7aac9c54-81c8-4494-94ab-0843f97a1e62 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #23
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 21:09:51 +01:00
7 changed files with 130 additions and 138 deletions

View File

@@ -1,7 +1,29 @@
# Lint stage
# golangci/golangci-lint:v2.10.1-alpine, 2026-02-17
FROM golangci/golangci-lint:v2.10.1-alpine@sha256:33bc6b6156d4c7da87175f187090019769903d04dd408833b83083ed214b0ddf AS lint
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
WORKDIR /src
# Copy go mod files first for better layer caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Run formatting check and linter
RUN make fmt-check
RUN make lint
# Build stage # Build stage
# golang:1.25.4-alpine, 2026-02-25 # golang:1.25.4-alpine, 2026-02-25
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
# Depend on lint stage passing
COPY --from=lint /src/go.sum /dev/null
ARG VERSION=dev ARG VERSION=dev
# Install build dependencies for CGO image libraries # Install build dependencies for CGO image libraries
@@ -9,25 +31,7 @@ RUN apk add --no-cache \
build-base \ build-base \
vips-dev \ vips-dev \
libheif-dev \ libheif-dev \
pkgconfig \ pkgconfig
curl
# golangci-lint v2.10.1, 2026-02-25
# SHA-256 checksums per architecture (amd64 / arm64)
RUN set -e; \
ARCH="$(uname -m)"; \
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
GOARCH="arm64"; \
HASH="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8"; \
else \
GOARCH="amd64"; \
HASH="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99"; \
fi; \
curl -sSfL "https://github.com/golangci/golangci-lint/releases/download/v2.10.1/golangci-lint-2.10.1-linux-${GOARCH}.tar.gz" -o /tmp/golangci-lint.tar.gz && \
echo "${HASH} /tmp/golangci-lint.tar.gz" | sha256sum -c - && \
tar -xzf /tmp/golangci-lint.tar.gz -C /tmp && \
mv "/tmp/golangci-lint-2.10.1-linux-${GOARCH}/golangci-lint" /usr/local/bin/ && \
rm -rf /tmp/golangci-lint*
WORKDIR /src WORKDIR /src
@@ -38,8 +42,8 @@ RUN GOTOOLCHAIN=auto go mod download
# Copy source code # Copy source code
COPY . . COPY . .
# Run all checks (fmt-check, lint, test) # Run tests
RUN make check RUN make test
# Build with CGO enabled # Build with CGO enabled
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad

View File

@@ -96,11 +96,10 @@ 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:** **Whitelist entries** are exact host matches only (e.g.
`cdn.example.com`). Suffix/wildcard matching is not supported —
- **Exact match**: `cdn.example.com` — matches only that host signatures are per-URL, so each allowed host must be listed
- **Suffix match**: `.example.com` — matches `cdn.example.com`, explicitly.
`images.example.com`, and `example.com`
### Configuration ### Configuration

101
TODO.md
View File

@@ -1,64 +1,65 @@
# Status # Pixa 1.0 TODO
pre-1.0. No git tags. Full policy file set is present, but make check Remaining tasks sorted by priority for a working 1.0 release.
fails on main with 10 gosec lint findings, so main is not green.
# Next Step ## P0: Critical for 1.0
Fix the 10 gosec findings so make check passes on main. Fix each issue ### Image Processing
properly (no blanket nolint suppressions) and confirm lint, tests, and - [x] Add WebP encoding support (currently returns error)
CI are green. This restores the main-always-green policy. - [x] Add AVIF encoding support (implemented via govips)
# Completed Steps ### Manual Testing (verify auth/encrypted URLs work)
- [ ] Manual test: visit `/`, see login form
- [ ] Manual test: enter wrong key, see error
- [ ] Manual test: enter correct signing key, see generator form
- [ ] Manual test: generate encrypted URL, verify it works
- [ ] Manual test: wait for expiration or use short TTL, verify expired URL returns 410
- [ ] Manual test: logout, verify redirected to login
- 2026-02-25: Docker arm64 build fixed via architecture detection for ### Cache Management
golangci-lint download (#16) - [ ] Implement cache size management/eviction (prevent disk from filling up)
- 2026-02-25: full repo policy compliance pass (#14): LICENSE,
REPO_POLICIES.md, .editorconfig, Gitea Actions CI workflow, Docker
images pinned by hash, README restructured to policy sections, hooks
Makefile target, golangci-lint errors resolved
- 2026-02-20: stale local dev config files removed (#12)
- 2026-02-09: correctness fixes: negative cache checked in Service.Get()
before upstream fetch (#8); Stats() column scanning and HitRate
computation corrected (#9)
- 2026-02-08: AllowHTTP propagated to SourceURL() scheme selection (#6)
- WebP encoding support added (P0 item, checked off in TODO)
- 2026-01-08: initial implementation with Makefile and TODO checklist
# Future Steps ### Configuration
- [ ] Validate configuration on startup (fail fast on bad config)
Compliance: ## P1: Important for Production
- After the gosec fix lands, verify CI runs make check green on main ### Security
- [ ] Implement blocked networks configuration (extend SSRF protection)
- [ ] Add rate limiting global concurrent fetches (prevent resource exhaustion)
P0, critical for 1.0 (from existing TODO.md): ### Image Processing
- [ ] Implement EXIF/metadata stripping (privacy)
- Add AVIF encoding support (currently returns error) ## P2: Nice to Have
- Manual test pass for auth and encrypted URLs:
- visit /, see login form
- wrong key shows error; correct signing key shows generator form
- generated encrypted URL works
- expired URL (short TTL) returns 410
- logout redirects to login
- Implement cache size management and eviction (prevent disk fill)
- Validate configuration on startup (fail fast on bad config)
P1, important for production: ### Security
- [ ] Implement referer blacklist
- [ ] Add rate limiting per-IP
- [ ] Add rate limiting per-origin
- Blocked networks configuration (extend SSRF protection) ### HTTP Response Handling
- Rate limit global concurrent fetches (resource exhaustion) - [ ] Implement Last-Modified headers
- EXIF/metadata stripping (privacy) - [ ] Implement Vary header for content negotiation
- [ ] Implement X-Request-ID propagation
P2, nice to have: ### Additional Endpoints
- [ ] Implement auto-format selection (format=auto based on Accept header)
- Referer blacklist; per-IP and per-origin rate limiting ### Configuration
- Last-Modified headers; Vary header; X-Request-ID propagation - [ ] Add all configuration options from README
- Auto-format selection (format=auto based on Accept header) - [ ] Implement environment variable overrides
- All configuration options from README; env var overrides; YAML config - [ ] Implement YAML config file support
file support
- Sentry error reporting (optional); comprehensive request logging; ### Operational
Prometheus metrics - [ ] Implement Sentry error reporting (optional)
- Integration tests for the image proxy flow; load tests for the - [ ] Add comprehensive request logging
1-5k req/s target - [ ] Add performance metrics (Prometheus)
- Docs: configuration options, API endpoints, deployment guide, example - [ ] Write integration tests for image proxy flow
nginx/caddy reverse proxy config - [ ] Write load tests to verify 1-5k req/s target
### Documentation
- [ ] Document configuration options
- [ ] Document API endpoints
- [ ] Document deployment guide
- [ ] Add example nginx/caddy reverse proxy config

View File

@@ -13,8 +13,7 @@ state_dir: ./data
# 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 (exact match only)
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
whitelist_hosts: whitelist_hosts:
- s3.sneak.cloud - s3.sneak.cloud
- static.sneak.cloud - static.sneak.cloud

View File

@@ -5,23 +5,20 @@ import (
"strings" "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 { type HostWhitelist struct {
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com") // exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
exactHosts map[string]struct{} 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. // NewHostWhitelist creates a whitelist from a list of hostnames.
// Patterns starting with "." are treated as suffix matches. // Each entry is treated as an exact host match. Leading dots are
// Examples: // stripped so that legacy ".example.com" entries become "example.com".
// - "cdn.example.com" - exact match only
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
func NewHostWhitelist(patterns []string) *HostWhitelist { func NewHostWhitelist(patterns []string) *HostWhitelist {
w := &HostWhitelist{ w := &HostWhitelist{
exactHosts: make(map[string]struct{}), exactHosts: make(map[string]struct{}),
suffixHosts: make([]string, 0),
} }
for _, pattern := range patterns { for _, pattern := range patterns {
@@ -30,9 +27,11 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
continue continue
} }
if strings.HasPrefix(pattern, ".") { // Strip leading dot — suffix matching is no longer supported;
w.suffixHosts = append(w.suffixHosts, pattern) // ".example.com" is normalised to "example.com" as an exact entry.
} else { pattern = strings.TrimPrefix(pattern, ".")
if pattern != "" {
w.exactHosts[pattern] = struct{}{} w.exactHosts[pattern] = struct{}{}
} }
} }
@@ -40,7 +39,7 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
return w 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 { func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
if u == nil { if u == nil {
return false return false
@@ -51,32 +50,17 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
return false return false
} }
// Check exact match _, ok := w.exactHosts[host]
if _, ok := w.exactHosts[host]; ok {
return true
}
// Check suffix match return ok
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
} }
// IsEmpty returns true if the whitelist has no entries. // IsEmpty returns true if the whitelist has no entries.
func (w *HostWhitelist) IsEmpty() bool { 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. // Count returns the total number of whitelist entries.
func (w *HostWhitelist) Count() int { 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, want: false,
}, },
{ {
name: "suffix match", name: "no suffix matching for subdomains",
patterns: []string{".example.com"}, patterns: []string{"example.com"},
testURL: "https://cdn.example.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: true, want: false,
}, },
{ {
name: "suffix match deep subdomain", name: "leading dot stripped to exact match",
patterns: []string{".example.com"},
testURL: "https://cdn.images.example.com/image.jpg",
want: true,
},
{
name: "suffix match apex domain",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://example.com/image.jpg", testURL: "https://example.com/image.jpg",
want: true, want: true,
}, },
{ {
name: "suffix match not found", name: "leading dot does not enable suffix matching",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://notexample.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: false, want: false,
}, },
{ {
name: "suffix match partial not allowed", name: "leading dot does not match deep subdomain",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://fakeexample.com/image.jpg", testURL: "https://cdn.images.example.com/image.jpg",
want: false, want: false,
}, },
{ {
name: "multiple patterns", name: "multiple patterns exact only",
patterns: []string{"cdn.example.com", ".images.org", "static.test.net"}, patterns: []string{"cdn.example.com", "photos.images.org", "static.test.net"},
testURL: "https://photos.images.org/image.jpg", testURL: "https://photos.images.org/image.jpg",
want: true, 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", name: "empty whitelist",
patterns: []string{}, patterns: []string{},
@@ -86,7 +86,7 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
}, },
{ {
name: "whitespace in patterns", 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", testURL: "https://cdn.example.com/image.jpg",
want: true, want: true,
}, },
@@ -139,6 +139,11 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
patterns: []string{"example.com"}, patterns: []string{"example.com"},
want: false, want: false,
}, },
{
name: "leading dot normalised to entry",
patterns: []string{".example.com"},
want: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -168,14 +173,14 @@ func TestHostWhitelist_Count(t *testing.T) {
want: 3, want: 3,
}, },
{ {
name: "suffix hosts only", name: "leading dots normalised to exact",
patterns: []string{".a.com", ".b.com"}, patterns: []string{".a.com", ".b.com"},
want: 2, want: 2,
}, },
{ {
name: "mixed", name: "mixed deduplication",
patterns: []string{"exact.com", ".suffix.com"}, patterns: []string{"example.com", ".example.com"},
want: 2, want: 1,
}, },
} }

View File

@@ -48,7 +48,7 @@ fi
# Test 3: Wrong password shows error # Test 3: Wrong password shows error
echo "--- Test 3: Login with wrong password ---" echo "--- Test 3: Login with wrong password ---"
WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "password=wrong-key" -c "$COOKIE_JAR") WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "key=wrong-key" -c "$COOKIE_JAR")
if echo "$WRONG_LOGIN" | grep -qi "invalid\|error\|incorrect\|wrong"; then if echo "$WRONG_LOGIN" | grep -qi "invalid\|error\|incorrect\|wrong"; then
pass "Wrong password shows error message" pass "Wrong password shows error message"
else else
@@ -57,7 +57,7 @@ fi
# Test 4: Correct password redirects to generator # Test 4: Correct password redirects to generator
echo "--- Test 4: Login with correct signing key ---" echo "--- Test 4: Login with correct signing key ---"
curl -sf -X POST "$BASE_URL/" -d "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
GENERATOR_PAGE=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR") GENERATOR_PAGE=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR")
if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then
pass "Correct password shows generator page" pass "Correct password shows generator page"
@@ -68,12 +68,12 @@ fi
# Test 5: Generate encrypted URL # Test 5: Generate encrypted URL
echo "--- Test 5: Generate encrypted URL ---" echo "--- Test 5: Generate encrypted URL ---"
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \ GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
-d "source_url=$TEST_IMAGE_URL" \ -d "url=$TEST_IMAGE_URL" \
-d "width=800" \ -d "width=800" \
-d "height=600" \ -d "height=600" \
-d "format=jpeg" \ -d "format=jpeg" \
-d "quality=85" \ -d "quality=85" \
-d "fit_mode=cover" \ -d "fit=cover" \
-d "ttl=3600") -d "ttl=3600")
if echo "$GEN_RESULT" | grep -q "/v1/e/"; then if echo "$GEN_RESULT" | grep -q "/v1/e/"; then
pass "Encrypted URL generated" pass "Encrypted URL generated"
@@ -121,10 +121,10 @@ fi
# Test 9: Generate short-TTL URL and verify expiration # Test 9: Generate short-TTL URL and verify expiration
echo "--- Test 9: Expired URL returns 410 ---" echo "--- Test 9: Expired URL returns 410 ---"
# Login again # Login again
curl -sf -X POST "$BASE_URL/" -d "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
# Generate URL with 1 second TTL # Generate URL with 1 second TTL
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \ GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
-d "source_url=$TEST_IMAGE_URL" \ -d "url=$TEST_IMAGE_URL" \
-d "width=100" \ -d "width=100" \
-d "height=100" \ -d "height=100" \
-d "format=jpeg" \ -d "format=jpeg" \