Compare commits
6
Commits
e1044e9037
...
11c8a96766
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c8a96766 | ||
|
|
22293d733f | ||
|
|
62a0beaea8 | ||
|
|
fa7f28c62b | ||
|
|
83869f221a | ||
|
|
b4e5300feb |
+3
-2
@@ -67,8 +67,9 @@ RUN adduser -D -H -s /sbin/nologin pixad && \
|
||||
mkdir -p /var/lib/pixa /etc/pixa && \
|
||||
chown pixad:pixad /var/lib/pixa
|
||||
|
||||
# Copy default config (edit signing_key before use)
|
||||
COPY config.example.yml /etc/pixa/config.yml
|
||||
# Copy the image config; signing_key comes from PIXA_SIGNING_KEY.
|
||||
# Mount a file over /etc/pixa/config.yml to override anything else.
|
||||
COPY config.docker.yml /etc/pixa/config.yml
|
||||
|
||||
USER pixad
|
||||
WORKDIR /var/lib/pixa
|
||||
|
||||
@@ -15,14 +15,25 @@ git clone https://git.eeqj.de/sneak/pixa.git
|
||||
cd pixa
|
||||
make build
|
||||
|
||||
# run with a config file
|
||||
./bin/pixad --config config.example.yml
|
||||
# run with a config file: copy the example and set a real signing key
|
||||
# (the example placeholder is refused at startup), e.g. with
|
||||
# openssl rand -base64 32
|
||||
cp config.example.yml config.yml
|
||||
$EDITOR config.yml # replace the signing_key placeholder
|
||||
./bin/pixad --config config.yml
|
||||
|
||||
# or build and run via Docker
|
||||
make docker
|
||||
docker run -p 8080:8080 pixad:latest
|
||||
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixad:latest
|
||||
```
|
||||
|
||||
A container is configured two ways. The signing key comes from the
|
||||
`PIXA_SIGNING_KEY` environment variable, which the baked-in config
|
||||
reads; if it is unset the container exits at startup naming the
|
||||
variable. Everything else uses built-in defaults, so to change any
|
||||
other setting mount your own file over `/etc/pixa/config.yml` (see
|
||||
`config.example.yml` for the full set of keys).
|
||||
|
||||
## Rationale
|
||||
|
||||
Image-heavy web applications need a fast, caching reverse proxy that
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Pixa configuration baked into the Docker image.
|
||||
#
|
||||
# The signing key is read from the PIXA_SIGNING_KEY environment
|
||||
# variable; startup aborts naming it when it is unset. Every other key
|
||||
# is omitted so its default applies. Operators who need more (an
|
||||
# allowlist, metrics, and so on) mount their own file over
|
||||
# /etc/pixa/config.yml.
|
||||
|
||||
signing_key: "${ENV:PIXA_SIGNING_KEY}"
|
||||
state_dir: /var/lib/pixa
|
||||
port: 8080
|
||||
@@ -44,6 +44,12 @@ const (
|
||||
keyCacheMaxBytes = "cache_max_bytes"
|
||||
)
|
||||
|
||||
// placeholderSigningKey is the dummy signing_key shipped in
|
||||
// config.example.yml. It is 45 characters, so it passes the length
|
||||
// check, but it is public in this repository and must be rejected at
|
||||
// startup so no deployment ever signs URLs with it.
|
||||
const placeholderSigningKey = "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||
|
||||
// Static validation errors. Each use site attaches the offending key
|
||||
// and value by wrapping these with fmt.Errorf and %w.
|
||||
var (
|
||||
@@ -61,6 +67,9 @@ var (
|
||||
errPortOutOfRange = errors.New("outside the valid port range")
|
||||
errTooFewConnections = errors.New("must be at least 1")
|
||||
errValueTooShort = errors.New("value too short")
|
||||
errPlaceholderKey = errors.New(
|
||||
"is the placeholder from config.example.yml; " +
|
||||
"generate a real key with: openssl rand -base64 32")
|
||||
errMustBeSetTogether = errors.New("must be set together")
|
||||
errMustNotBeNegative = errors.New("must not be negative")
|
||||
errOverflowsInt64 = errors.New("overflows a 64-bit integer")
|
||||
@@ -341,10 +350,10 @@ func (c *Config) ensureStateDirWritable() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate checks that all required configuration values are set and
|
||||
// that every value is within its valid range.
|
||||
func (c *Config) validate() error {
|
||||
// The signing key value is never echoed in error messages.
|
||||
// validateSigningKey checks that the signing key is present, long
|
||||
// enough, and not the public placeholder from config.example.yml. The
|
||||
// key value itself is never echoed in error messages.
|
||||
func (c *Config) validateSigningKey() error {
|
||||
if c.SigningKey == "" {
|
||||
return fmt.Errorf("config key %q: %w", keySigningKey, errValueRequired)
|
||||
}
|
||||
@@ -356,6 +365,21 @@ func (c *Config) validate() error {
|
||||
keySigningKey, errValueTooShort, minKeyLength, len(c.SigningKey))
|
||||
}
|
||||
|
||||
if c.SigningKey == placeholderSigningKey {
|
||||
return fmt.Errorf("config key %q: %w", keySigningKey, errPlaceholderKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate checks that all required configuration values are set and
|
||||
// that every value is within its valid range.
|
||||
func (c *Config) validate() error {
|
||||
err := c.validateSigningKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const maxPort = 65535
|
||||
if c.Port < 1 || c.Port > maxPort {
|
||||
return fmt.Errorf("config key %q: value %d is %w 1-%d",
|
||||
|
||||
@@ -303,6 +303,11 @@ func invalidHostAndCredentialCases() []abortCase {
|
||||
yaml: "signing_key: short\n",
|
||||
wantErrSubstrings: []string{keySigningKey},
|
||||
},
|
||||
{
|
||||
name: "signing_key is the documented placeholder",
|
||||
yaml: "signing_key: " + placeholderSigningKey + "\n",
|
||||
wantErrSubstrings: []string{keySigningKey},
|
||||
},
|
||||
{
|
||||
name: "signing_key missing",
|
||||
yaml: "port: 8080\n",
|
||||
|
||||
@@ -21,6 +21,33 @@ import (
|
||||
// CORSMaxAgeSeconds is the max age for CORS preflight cache (24 hours).
|
||||
const CORSMaxAgeSeconds = 86400
|
||||
|
||||
// HSTSValue is the Strict-Transport-Security header value: one year with
|
||||
// includeSubDomains. Emitted unconditionally even though pixa listens plain
|
||||
// HTTP behind a TLS-terminating proxy; browsers ignore an HSTS header received
|
||||
// over plaintext (RFC 6797 section 8.1), so it never lies about the connection,
|
||||
// and emitting it here avoids trusting a forwarded-proto header.
|
||||
const HSTSValue = "max-age=31536000; includeSubDomains"
|
||||
|
||||
// ContentSecurityPolicyValue is the Content-Security-Policy header value.
|
||||
// default-src 'self' is the baseline and frame-ancestors 'none' is the primary
|
||||
// clickjacking control. 'unsafe-inline' is required in script-src and style-src
|
||||
// because the served templates carry inline onclick handlers (generator page)
|
||||
// and the bundled Tailwind asset injects a runtime <style> element; dropping it
|
||||
// needs template changes outside this issue's scope.
|
||||
const ContentSecurityPolicyValue = "default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none'"
|
||||
|
||||
// PermissionsPolicyValue is the Permissions-Policy header value. Every listed
|
||||
// feature is denied because pixa uses none of them.
|
||||
const PermissionsPolicyValue = "accelerometer=(), autoplay=(), camera=(), " +
|
||||
"display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), " +
|
||||
"microphone=(), payment=(), usb=()"
|
||||
|
||||
// Params defines dependencies for Middleware.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -164,6 +191,16 @@ func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||
// Disable XSS filtering (modern browsers don't need it, can cause issues)
|
||||
w.Header().Set("X-XSS-Protection", "0")
|
||||
|
||||
// Force HTTPS on future visits (ignored by browsers over plaintext)
|
||||
w.Header().Set("Strict-Transport-Security", HSTSValue)
|
||||
|
||||
// Restrict content sources; frame-ancestors is the primary
|
||||
// clickjacking control, X-Frame-Options the legacy fallback
|
||||
w.Header().Set("Content-Security-Policy", ContentSecurityPolicyValue)
|
||||
|
||||
// Deny browser features pixa does not use
|
||||
w.Header().Set("Permissions-Policy", PermissionsPolicyValue)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,61 @@ func TestSecurityHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &config.Config{}
|
||||
mw := &Middleware{
|
||||
log: slog.Default(),
|
||||
config: cfg,
|
||||
}
|
||||
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := mw.SecurityHeaders()(testHandler)
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
tests := []struct {
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"Strict-Transport-Security", "max-age=31536000; includeSubDomains"},
|
||||
{
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none'",
|
||||
},
|
||||
{
|
||||
"Permissions-Policy",
|
||||
"accelerometer=(), autoplay=(), camera=(), " +
|
||||
"display-capture=(), geolocation=(), gyroscope=(), " +
|
||||
"magnetometer=(), microphone=(), payment=(), usb=()",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.header, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := rec.Header().Get(tt.header)
|
||||
if got != tt.want {
|
||||
t.Errorf("%s = %q, want %q", tt.header, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user