Author SHA1 Message Date
clawbot 62f46c3a49 Send CORS headers only from the image routes (closes #98)
check / check (push) Canceled after 0s
The CORS middleware, with the access_control_allow_origin origin, moved
from the router root onto a /v1 subrouter holding /v1/image/ and /v1/e/.
The login and URL generator pages, /metrics, the health check,
robots.txt and /static/ no longer send Access-Control-Allow-Origin, so
their safety no longer rests on the CORS options chosen for the image
routes.

It is a subrouter rather than a route group because a group's
middleware runs only for a request that matches one of its routes, and
a preflight OPTIONS request matches none. The maintenance mode group
moved inside it unchanged.

README.md and config.example.yml say the setting covers the image
routes only.

Model: opus-5-5
2026-09-29 11:00:22 +00:00
clawbot 6748bbd637 Test that only the image routes send CORS headers (closes #98)
check / check (push) Failing after 1m56s
A new server test sends requests with an Origin header through the
server's routes and expects Access-Control-Allow-Origin, set to the
configured origin, on both image routes, a preflight OPTIONS request
included, and no such header on the login and URL generator pages.
It fails for now: the CORS middleware still wraps every route.

The encrypted image path the maintenance tests use is now a named
constant, shared with the new test; what they check is unchanged.

Model: opus-5-5
2026-09-29 10:54:50 +00:00
10 changed files with 135 additions and 65 deletions
+1 -2
View File
@@ -1,5 +1,4 @@
# .git is sent so the build can stamp the version, without its config. .git
.git/config
.gitignore .gitignore
.DS_Store .DS_Store
.env* .env*
+20 -12
View File
@@ -112,13 +112,15 @@ import (
) )
var ( var (
Appname string = "CHANGEME" Appname string = "CHANGEME"
Version string Version string
Buildarch string
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
fx.New( fx.New(
fx.Provide( fx.Provide(
@@ -857,7 +859,7 @@ func (l *Logger) Identify() {
l.log.Info("starting", l.log.Info("starting",
"appname", l.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", l.params.Globals.Version, "version", l.params.Globals.Version,
"arch", runtime.GOARCH, "buildarch", l.params.Globals.Buildarch,
) )
} }
``` ```
@@ -977,20 +979,23 @@ import "go.uber.org/fx"
// Package-level variables (set from main) // Package-level variables (set from main)
var ( var (
Appname string Appname string
Version string Version string
Buildarch string
) )
// Struct for DI // Struct for DI
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Buildarch string
} }
func New(lc fx.Lifecycle) (*Globals, error) { func New(lc fx.Lifecycle) (*Globals, error) {
n := &Globals{ n := &Globals{
Appname: Appname, Appname: Appname,
Version: Version, Buildarch: Buildarch,
Version: Version,
} }
return n, nil return n, nil
} }
@@ -1001,13 +1006,15 @@ func New(lc fx.Lifecycle) (*Globals, error) {
```go ```go
// cmd/httpd/main.go // cmd/httpd/main.go
var ( var (
Appname string = "CHANGEME" // Default, overridden by build Appname string = "CHANGEME" // Default, overridden by build
Version string // Set at build time Version string // Set at build time
Buildarch string // Set at build time
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
// ... // ...
} }
``` ```
@@ -1018,9 +1025,10 @@ Use ldflags to inject version information at build time:
```makefile ```makefile
VERSION := $(shell git describe --tags --always) VERSION := $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH)
build: build:
go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd
``` ```
--- ---
+7 -18
View File
@@ -43,24 +43,13 @@ COPY . .
RUN make test RUN make test
# VERSION is declared here, not earlier: a new value reruns only the # VERSION is declared here, not earlier: a new value reruns only the
# build, not script/bootstrap or the tests. Given none, the version is # build, not script/bootstrap or the tests. CGO stays enabled for
# `git describe --tags --always` of the .git in the build context (git # govips; -trimpath keeps build paths out of the binary, and -s -w
# comes from script/bootstrap): the tag on a tagged commit, tag-N-gHASH # leave out the symbol table and debug information.
# after one, the short commit when no tag is reachable. A context that ARG VERSION=dev
# carries .git and still yields no version fails the build; one without RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
# .git, as from a source tarball, stamps an empty version. CGO stays -ldflags "-s -w -X main.Version=${VERSION}" \
# enabled for govips; -trimpath keeps build paths out of the binary, and -o /pixad ./cmd/pixad
# -s -w leave out the symbol table and debug information.
ARG VERSION
RUN version="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
[ "$version" = unknown ]; }; then \
echo "the build context carries .git but yields no version" >&2; \
exit 1; \
fi; \
CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
-ldflags "-s -w -X main.Version=${version}" \
-o /pixad ./cmd/pixad
# Runtime stage # Runtime stage
# alpine:3.21, 2026-02-25 # alpine:3.21, 2026-02-25
+4 -3
View File
@@ -238,7 +238,7 @@ variables set by the file's `env:` section are checked the same way.
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` | | `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB | | `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` | | `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read responses: `*` or one origin; default `*` | | `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read image responses: `*` or one origin; default `*` |
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set | | `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username | | `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it | | `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
@@ -247,8 +247,9 @@ variables set by the file's `env:` section are checked the same way.
Key settings in more detail: Key settings in more detail:
- `access_control_allow_origin` — the origin a browser lets read pixa's - `access_control_allow_origin` — the origin a browser lets read the responses
responses, sent as the CORS `Access-Control-Allow-Origin` header: `*`, the of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS
`Access-Control-Allow-Origin` header; no other route sends it. `*`, the
default, is any site; otherwise one `http` or `https` origin such as default, is any site; otherwise one `http` or `https` origin such as
`https://example.com`, whose host is a lowercase host name (letters, `https://example.com`, whose host is a lowercase host name (letters,
digits, hyphens and dots, with a letter in its last part) or an IP address digits, hyphens and dots, with a letter in its last part) or an IP address
+6 -7
View File
@@ -29,13 +29,12 @@ P2: security: referer blacklist
# Completed Steps # Completed Steps
- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not - 2026-09-29 only the image routes send CORS headers (closes #98): the CORS
`dev` (closes #166): `.dockerignore` lets `.git` into the build context, middleware, with the `access_control_allow_origin` origin, moved from the
without `.git/config`; with no `VERSION` build argument the `Dockerfile` router root onto a `/v1` subrouter holding `/v1/image/` and `/v1/e/`, where it
takes the version from `git describe --tags --always`, and fails the build if still answers a preflight `OPTIONS` request; the login and URL generator
the context carries `.git` and no version comes out; `ARG VERSION` has no pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`;
default; pixad logs its version, with its name and architecture, as its first documented in `README.md` and `config.example.yml`.
log line at startup.
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes - 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes
#159): `deploy/docker-entrypoint.sh` creates the directory if it is missing, #159): `deploy/docker-entrypoint.sh` creates the directory if it is missing,
gives the directory and everything in it to `pixad` when the directory or one gives the directory and everything in it to `pixad` when the directory or one
+1 -4
View File
@@ -56,9 +56,6 @@ func run(_ *cobra.Command, _ []string) {
middleware.New, middleware.New,
healthcheck.New, healthcheck.New,
), ),
fx.Invoke( fx.Invoke(func(*server.Server) {}),
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
),
).Run() ).Run()
} }
+3 -2
View File
@@ -103,8 +103,9 @@ upstream_max_response_size: 52428800
# longer than upstream_fetch_timeout plus 20 seconds. # longer than upstream_fetch_timeout plus 20 seconds.
downstream_timeout: 60s downstream_timeout: 60s
# The origin a browser lets read pixa's responses, sent as the CORS # The origin a browser lets read the responses of the image routes,
# Access-Control-Allow-Origin header: "*" (the default) is any site; # /v1/image/ and /v1/e/, sent as the CORS Access-Control-Allow-Origin
# header; no other route sends it. "*" (the default) is any site;
# otherwise one http or https origin such as https://example.com, whose # otherwise one http or https origin such as https://example.com, whose
# host is a lowercase host name (letters, digits, hyphens and dots, with a # host is a lowercase host name (letters, digits, hyphens and dots, with a
# letter in its last part) or an IP address (IPv6 in brackets, in its # letter in its last part) or an IP address (IPv6 in brackets, in its
+64
View File
@@ -0,0 +1,64 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestCORSOnlyOnImageRoutes verifies that the image routes answer with the
// configured access_control_allow_origin, a preflight request included, and
// that the login and URL generator pages send no Access-Control-Allow-Origin,
// so no other site can read them. /metrics is left out: its middleware
// registers with the process-wide Prometheus registry, which only one test
// in this package can do.
func TestCORSOnlyOnImageRoutes(t *testing.T) {
t.Parallel()
const appOrigin = "https://app.example.com"
s := newTestServer(t)
s.config.AccessControlAllowOrigin = appOrigin
s.SetupRoutes()
requests := []struct {
method string
path string
want string
}{
{http.MethodGet, unsignedImagePath, appOrigin},
{http.MethodHead, unsignedImagePath, appOrigin},
{http.MethodOptions, unsignedImagePath, appOrigin},
{http.MethodGet, encryptedImagePath, appOrigin},
{http.MethodGet, "/", ""},
{http.MethodOptions, "/", ""},
{http.MethodPost, "/generate", ""},
{http.MethodGet, "/logout", ""},
}
for _, tc := range requests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(
t.Context(), tc.method, tc.path, nil)
req.Header.Set("Origin", appOrigin)
// An OPTIONS request naming the method it asks about is the
// preflight a browser sends before some cross-origin requests.
if tc.method == http.MethodOptions {
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
}
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
t.Logf("status %d", rec.Code)
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != tc.want {
t.Errorf("Access-Control-Allow-Origin = %q, want %q",
got, tc.want)
}
})
}
}
+6 -2
View File
@@ -13,6 +13,10 @@ import (
// unsignedImagePath is an image URL that carries no signature. // unsignedImagePath is an image URL that carries no signature.
const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg" const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg"
// encryptedImagePath is an encrypted image URL whose token cannot be
// decrypted.
const encryptedImagePath = "/v1/e/token/cat.jpg"
// TestMaintenanceModeRefusesImageRequests verifies that while maintenance // TestMaintenanceModeRefusesImageRequests verifies that while maintenance
// mode is on, both image routes answer 503 Service Unavailable with a // mode is on, both image routes answer 503 Service Unavailable with a
// Retry-After header and the JSON error body the image handlers send. // Retry-After header and the JSON error body the image handlers send.
@@ -28,7 +32,7 @@ func TestMaintenanceModeRefusesImageRequests(t *testing.T) {
}{ }{
{http.MethodGet, unsignedImagePath}, {http.MethodGet, unsignedImagePath},
{http.MethodHead, unsignedImagePath}, {http.MethodHead, unsignedImagePath},
{http.MethodGet, "/v1/e/token/cat.jpg"}, {http.MethodGet, encryptedImagePath},
} }
for _, tc := range requests { for _, tc := range requests {
@@ -95,7 +99,7 @@ func TestImageRequestsServedWithoutMaintenanceMode(t *testing.T) {
}{ }{
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized}, {http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized}, {http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
{http.MethodGet, "/v1/e/token/cat.jpg", http.StatusBadRequest}, {http.MethodGet, encryptedImagePath, http.StatusBadRequest},
} }
for _, tc := range requests { for _, tc := range requests {
+23 -15
View File
@@ -38,7 +38,6 @@ func (s *Server) SetupRoutes() {
s.router.Use(s.mw.Metrics()) s.router.Use(s.mw.Metrics())
} }
s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(s.config.DownstreamTimeout)) s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
if s.sentryEnabled { if s.sentryEnabled {
@@ -74,22 +73,31 @@ func (s *Server) SetupRoutes() {
s.router.Get("/logout", s.h.HandleLogout()) s.router.Get("/logout", s.h.HandleLogout())
// Image routes, refused while maintenance mode is on. Only these: the // Image routes, the only ones that send CORS headers, as pages on other
// image's Docker HEALTHCHECK requests the health check, a 503 there // sites read them. They are a subrouter rather than a group: a group's
// would make the container unhealthy, and upaas marks a deploy failed // middleware runs only for a request that matches one of its routes,
// when its container is unhealthy. // and a browser's preflight OPTIONS request matches none, so the CORS
s.router.Group(func(r chi.Router) { // middleware could not answer it.
r.Use(s.refuseDuringMaintenance) s.router.Route("/v1", func(r chi.Router) {
r.Use(s.mw.CORS())
// Main image proxy route // Refused while maintenance mode is on. Only these: the image's
// /v1/image/<host>/<path>/<width>x<height>.<format> // Docker HEALTHCHECK requests the health check, a 503 there would
r.Get("/v1/image/*", s.h.HandleImage()) // make the container unhealthy, and upaas marks a deploy failed
r.Head("/v1/image/*", s.h.HandleImage()) // when its container is unhealthy.
r.Group(func(r chi.Router) {
r.Use(s.refuseDuringMaintenance)
// Encrypted image URL route // Main image proxy route
// The trailing filename (e.g., /img.jpg) is ignored but helps // /v1/image/<host>/<path>/<width>x<height>.<format>
// browsers with content type r.Get("/image/*", s.h.HandleImage())
r.Get("/v1/e/{token}/*", s.h.HandleImageEnc()) r.Head("/image/*", s.h.HandleImage())
// Encrypted image URL route
// The trailing filename (e.g., /img.jpg) is ignored but helps
// browsers with content type
r.Get("/e/{token}/*", s.h.HandleImageEnc())
})
}) })
// Metrics endpoint with auth // Metrics endpoint with auth