Compare commits
3 Commits
c7d90314fd
...
remove-whi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e241b99d22 | ||
| 2e934c8894 | |||
| 2f15340f26 |
46
Dockerfile
46
Dockerfile
@@ -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
|
||||
# golang:1.25.4-alpine, 2026-02-25
|
||||
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
|
||||
|
||||
# Install build dependencies for CGO image libraries
|
||||
@@ -9,25 +31,7 @@ RUN apk add --no-cache \
|
||||
build-base \
|
||||
vips-dev \
|
||||
libheif-dev \
|
||||
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*
|
||||
pkgconfig
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -38,8 +42,8 @@ RUN GOTOOLCHAIN=auto go mod download
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Run all checks (fmt-check, lint, test)
|
||||
RUN make check
|
||||
# Run tests
|
||||
RUN make test
|
||||
|
||||
# Build with CGO enabled
|
||||
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
101
TODO.md
101
TODO.md
@@ -1,64 +1,65 @@
|
||||
# Status
|
||||
# Pixa 1.0 TODO
|
||||
|
||||
pre-1.0. No git tags. Full policy file set is present, but make check
|
||||
fails on main with 10 gosec lint findings, so main is not green.
|
||||
Remaining tasks sorted by priority for a working 1.0 release.
|
||||
|
||||
# Next Step
|
||||
## P0: Critical for 1.0
|
||||
|
||||
Fix the 10 gosec findings so make check passes on main. Fix each issue
|
||||
properly (no blanket nolint suppressions) and confirm lint, tests, and
|
||||
CI are green. This restores the main-always-green policy.
|
||||
### Image Processing
|
||||
- [x] Add WebP encoding support (currently returns error)
|
||||
- [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
|
||||
golangci-lint download (#16)
|
||||
- 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
|
||||
### Cache Management
|
||||
- [ ] Implement cache size management/eviction (prevent disk from filling up)
|
||||
|
||||
# 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)
|
||||
- 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)
|
||||
## P2: Nice to Have
|
||||
|
||||
P1, important for production:
|
||||
### Security
|
||||
- [ ] Implement referer blacklist
|
||||
- [ ] Add rate limiting per-IP
|
||||
- [ ] Add rate limiting per-origin
|
||||
|
||||
- Blocked networks configuration (extend SSRF protection)
|
||||
- Rate limit global concurrent fetches (resource exhaustion)
|
||||
- EXIF/metadata stripping (privacy)
|
||||
### HTTP Response Handling
|
||||
- [ ] Implement Last-Modified headers
|
||||
- [ ] 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
|
||||
- Last-Modified headers; Vary header; X-Request-ID propagation
|
||||
- Auto-format selection (format=auto based on Accept header)
|
||||
- All configuration options from README; env var overrides; YAML config
|
||||
file support
|
||||
- Sentry error reporting (optional); comprehensive request logging;
|
||||
Prometheus metrics
|
||||
- Integration tests for the image proxy flow; load tests for the
|
||||
1-5k req/s target
|
||||
- Docs: configuration options, API endpoints, deployment guide, example
|
||||
nginx/caddy reverse proxy config
|
||||
### Configuration
|
||||
- [ ] Add all configuration options from README
|
||||
- [ ] Implement environment variable overrides
|
||||
- [ ] Implement YAML config file support
|
||||
|
||||
### Operational
|
||||
- [ ] Implement Sentry error reporting (optional)
|
||||
- [ ] Add comprehensive request logging
|
||||
- [ ] Add performance metrics (Prometheus)
|
||||
- [ ] Write integration tests for image proxy flow
|
||||
- [ ] 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ fi
|
||||
|
||||
# Test 3: Wrong password shows error
|
||||
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
|
||||
pass "Wrong password shows error message"
|
||||
else
|
||||
@@ -57,7 +57,7 @@ fi
|
||||
|
||||
# Test 4: Correct password redirects to generator
|
||||
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")
|
||||
if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then
|
||||
pass "Correct password shows generator page"
|
||||
@@ -68,12 +68,12 @@ fi
|
||||
# Test 5: Generate encrypted URL
|
||||
echo "--- Test 5: Generate encrypted URL ---"
|
||||
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 "height=600" \
|
||||
-d "format=jpeg" \
|
||||
-d "quality=85" \
|
||||
-d "fit_mode=cover" \
|
||||
-d "fit=cover" \
|
||||
-d "ttl=3600")
|
||||
if echo "$GEN_RESULT" | grep -q "/v1/e/"; then
|
||||
pass "Encrypted URL generated"
|
||||
@@ -121,10 +121,10 @@ fi
|
||||
# Test 9: Generate short-TTL URL and verify expiration
|
||||
echo "--- Test 9: Expired URL returns 410 ---"
|
||||
# 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
|
||||
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 "height=100" \
|
||||
-d "format=jpeg" \
|
||||
|
||||
Reference in New Issue
Block a user