Server: Add HTTP security hardening config options #5471

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2026-03-04 10:12:57 +01:00
parent 80338bdebc
commit ecdc49ad33
14 changed files with 285 additions and 20 deletions

View file

@ -12,4 +12,7 @@ func TestShowConfigOptionsCommand(t *testing.T) {
assert.NoError(t, err)
assert.Contains(t, output, "PHOTOPRISM_IMPORT_PATH")
assert.Contains(t, output, "PHOTOPRISM_HTTP_HEADER_TIMEOUT")
assert.Contains(t, output, "PHOTOPRISM_HTTP_HEADER_BYTES")
assert.Contains(t, output, "PHOTOPRISM_HTTP_IDLE_TIMEOUT")
}

View file

@ -240,6 +240,82 @@ func TestShowConfigYaml_MarkdownServicesCIDRSectionOrder(t *testing.T) {
}
}
func TestShowConfigOptions_MarkdownHttpHeaderSettingsOrder(t *testing.T) {
out, err := RunWithTestContext(ShowConfigOptionsCommand, []string{"config-options", "--md"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
webServerHeading := strings.Index(out, "### Web Server")
httpCompression := strings.Index(out, "PHOTOPRISM_HTTP_COMPRESSION")
httpHeaderTimeout := strings.Index(out, "PHOTOPRISM_HTTP_HEADER_TIMEOUT")
httpHeaderBytes := strings.Index(out, "PHOTOPRISM_HTTP_HEADER_BYTES")
httpIdleTimeout := strings.Index(out, "PHOTOPRISM_HTTP_IDLE_TIMEOUT")
httpCachePublic := strings.Index(out, "PHOTOPRISM_HTTP_CACHE_PUBLIC")
if webServerHeading < 0 || httpCompression < 0 || httpHeaderTimeout < 0 || httpHeaderBytes < 0 || httpIdleTimeout < 0 || httpCachePublic < 0 {
t.Fatalf("expected web server heading and related rows in output")
}
if httpHeaderTimeout < webServerHeading {
t.Fatalf("expected http-header-timeout row to appear in Web Server section")
}
if httpHeaderTimeout < httpCompression {
t.Fatalf("expected http-header-timeout row to appear after http-compression")
}
if httpHeaderBytes < httpHeaderTimeout {
t.Fatalf("expected http-header-bytes row to appear after http-header-timeout")
}
if httpIdleTimeout < httpHeaderBytes {
t.Fatalf("expected http-idle-timeout row to appear after http-header-bytes")
}
if httpIdleTimeout > httpCachePublic {
t.Fatalf("expected http-idle-timeout row to appear before http-cache-public")
}
}
func TestShowConfigYaml_MarkdownHttpHeaderSettingsOrder(t *testing.T) {
out, err := RunWithTestContext(ShowConfigYamlCommand, []string{"config-yaml", "--md"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
webServerHeading := strings.Index(out, "### Web Server")
httpCompression := strings.Index(out, "HttpCompression")
httpHeaderTimeout := strings.Index(out, "HttpHeaderTimeout")
httpHeaderBytes := strings.Index(out, "HttpHeaderBytes")
httpIdleTimeout := strings.Index(out, "HttpIdleTimeout")
httpCachePublic := strings.Index(out, "HttpCachePublic")
if webServerHeading < 0 || httpCompression < 0 || httpHeaderTimeout < 0 || httpHeaderBytes < 0 || httpIdleTimeout < 0 || httpCachePublic < 0 {
t.Fatalf("expected web server heading and related rows in output")
}
if httpHeaderTimeout < webServerHeading {
t.Fatalf("expected HttpHeaderTimeout row to appear in Web Server section")
}
if httpHeaderTimeout < httpCompression {
t.Fatalf("expected HttpHeaderTimeout row to appear after HttpCompression")
}
if httpHeaderBytes < httpHeaderTimeout {
t.Fatalf("expected HttpHeaderBytes row to appear after HttpHeaderTimeout")
}
if httpIdleTimeout < httpHeaderBytes {
t.Fatalf("expected HttpIdleTimeout row to appear after HttpHeaderBytes")
}
if httpIdleTimeout > httpCachePublic {
t.Fatalf("expected HttpIdleTimeout row to appear before HttpCachePublic")
}
}
func TestShowFileFormats_JSON(t *testing.T) {
out, err := RunWithTestContext(ShowFileFormatsCommand, []string{"file-formats", "--json"})

View file

@ -65,6 +65,9 @@ func startAction(ctx *cli.Context) error {
{"detach-server", fmt.Sprintf("%t", conf.DetachServer())},
{"http-mode", conf.HttpMode()},
{"http-compression", conf.HttpCompression()},
{"http-header-timeout", conf.HttpHeaderTimeout().String()},
{"http-header-bytes", fmt.Sprintf("%d", conf.HttpHeaderBytes())},
{"http-idle-timeout", conf.HttpIdleTimeout().String()},
{"http-cache-public", fmt.Sprintf("%t", conf.HttpCachePublic())},
{"http-cache-maxage", fmt.Sprintf("%d", conf.HttpCacheMaxAge())},
{"http-video-maxage", fmt.Sprintf("%d", conf.HttpVideoMaxAge())},

View file

@ -1,6 +1,6 @@
## PhotoPrism — Config Package
**Last Updated:** March 2, 2026
**Last Updated:** March 4, 2026
### Overview
@ -30,6 +30,13 @@ The `PHOTOPRISM_CONFIG_PATH` variable controls where PhotoPrism looks for YAML f
> Any change to configuration (flags, env vars, YAML files) requires a restart. The Go process reads options during startup and does not watch for changes.
### HTTP Hardening Defaults
- `PHOTOPRISM_HTTP_HEADER_TIMEOUT` / `--http-header-timeout` configures `http.Server.ReadHeaderTimeout` and defaults to `15s`.
- `PHOTOPRISM_HTTP_HEADER_BYTES` / `--http-header-bytes` configures `http.Server.MaxHeaderBytes` and defaults to `1048576` (1 MiB).
- `PHOTOPRISM_HTTP_IDLE_TIMEOUT` / `--http-idle-timeout` configures `http.Server.IdleTimeout` and defaults to `180s`.
- `ReadTimeout` and `WriteTimeout` remain disabled globally so large uploads/downloads are not interrupted by a one-size-fits-all timeout.
### Inspect Before Editing
Before changing environment variables or YAML files, run `photoprism config | grep -i <flag>` to confirm the current value of a flag, such as `site-url`, or `site` to show all related values:

View file

@ -44,6 +44,15 @@ const DefaultWakeupIntervalSeconds = int(15 * 60) // 15 Minutes
// DefaultWakeupInterval is the default worker interval as a duration.
const DefaultWakeupInterval = time.Second * time.Duration(DefaultWakeupIntervalSeconds)
// DefaultHttpHeaderTimeout is the default timeout for reading request headers.
const DefaultHttpHeaderTimeout = 15 * time.Second
// DefaultHttpHeaderBytes is the default limit for HTTP request header size.
const DefaultHttpHeaderBytes = 1 << 20 // 1 MiB
// DefaultHttpIdleTimeout is the default timeout for idle keep-alive connections.
const DefaultHttpIdleTimeout = 180 * time.Second
// MegaByte defines a megabyte in bytes.
const MegaByte = 1000 * 1000 // 1,000,000 Bytes

View file

@ -5,6 +5,7 @@ import (
"path/filepath"
"regexp"
"strings"
"time"
"github.com/photoprism/photoprism/internal/config/ttl"
"github.com/photoprism/photoprism/internal/server/limiter"
@ -103,6 +104,33 @@ func (c *Config) HttpCompression() string {
return strings.ToLower(strings.TrimSpace(c.options.HttpCompression))
}
// HttpHeaderTimeout returns the timeout for reading HTTP request headers.
func (c *Config) HttpHeaderTimeout() time.Duration {
if c.options.HttpHeaderTimeout <= 0 {
return DefaultHttpHeaderTimeout
}
return c.options.HttpHeaderTimeout
}
// HttpHeaderBytes returns the maximum size of HTTP request headers in bytes.
func (c *Config) HttpHeaderBytes() int {
if c.options.HttpHeaderBytes <= 0 {
return DefaultHttpHeaderBytes
}
return c.options.HttpHeaderBytes
}
// HttpIdleTimeout returns the timeout for idle keep-alive connections.
func (c *Config) HttpIdleTimeout() time.Duration {
if c.options.HttpIdleTimeout <= 0 {
return DefaultHttpIdleTimeout
}
return c.options.HttpIdleTimeout
}
// HttpCachePublic checks whether static content may be cached by a CDN or caching proxy.
func (c *Config) HttpCachePublic() bool {
if c.options.HttpCachePublic {

View file

@ -3,6 +3,7 @@ package config
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
@ -118,6 +119,42 @@ func TestConfig_HttpCompression(t *testing.T) {
assert.Equal(t, "", c.HttpCompression())
}
func TestConfig_HttpHeaderTimeout(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, DefaultHttpHeaderTimeout, c.HttpHeaderTimeout())
c.Options().HttpHeaderTimeout = 17 * time.Second
assert.Equal(t, 17*time.Second, c.HttpHeaderTimeout())
c.Options().HttpHeaderTimeout = -1 * time.Second
assert.Equal(t, DefaultHttpHeaderTimeout, c.HttpHeaderTimeout())
}
func TestConfig_HttpHeaderBytes(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, DefaultHttpHeaderBytes, c.HttpHeaderBytes())
c.Options().HttpHeaderBytes = 2048
assert.Equal(t, 2048, c.HttpHeaderBytes())
c.Options().HttpHeaderBytes = 0
assert.Equal(t, DefaultHttpHeaderBytes, c.HttpHeaderBytes())
}
func TestConfig_HttpIdleTimeout(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, DefaultHttpIdleTimeout, c.HttpIdleTimeout())
c.Options().HttpIdleTimeout = 2 * time.Minute
assert.Equal(t, 2*time.Minute, c.HttpIdleTimeout())
c.Options().HttpIdleTimeout = -1 * time.Second
assert.Equal(t, DefaultHttpIdleTimeout, c.HttpIdleTimeout())
}
func TestConfig_HttpCachePublic(t *testing.T) {
c := NewConfig(CliTestContext())

View file

@ -863,6 +863,24 @@ var Flags = CliFlags{
Usage: "Web server compression `METHOD` (gzip, none)",
EnvVars: EnvVars("HTTP_COMPRESSION"),
}}, {
Flag: &cli.StringFlag{
Name: "http-header-timeout",
Usage: "timeout for reading request headers as `DURATION`",
Value: DefaultHttpHeaderTimeout.String(),
EnvVars: EnvVars("HTTP_HEADER_TIMEOUT"),
}}, {
Flag: &cli.IntFlag{
Name: "http-header-bytes",
Usage: "maximum request header size in `BYTES`",
Value: DefaultHttpHeaderBytes,
EnvVars: EnvVars("HTTP_HEADER_BYTES"),
}}, {
Flag: &cli.StringFlag{
Name: "http-idle-timeout",
Usage: "timeout for idle keep-alive connections as `DURATION`",
Value: DefaultHttpIdleTimeout.String(),
EnvVars: EnvVars("HTTP_IDLE_TIMEOUT"),
}}, {
Flag: &cli.BoolFlag{
Name: "http-cache-public",
Usage: "allows static content to be cached by a CDN or caching proxy",

View file

@ -180,6 +180,9 @@ type Options struct {
TLSKey string `yaml:"TLSKey" json:"TLSKey" flag:"tls-key"`
HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"`
HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"`
HttpHeaderTimeout time.Duration `yaml:"HttpHeaderTimeout" json:"-" flag:"http-header-timeout"`
HttpHeaderBytes int `yaml:"HttpHeaderBytes" json:"-" flag:"http-header-bytes"`
HttpIdleTimeout time.Duration `yaml:"HttpIdleTimeout" json:"-" flag:"http-idle-timeout"`
HttpCachePublic bool `yaml:"HttpCachePublic" json:"HttpCachePublic" flag:"http-cache-public"`
HttpCacheMaxAge int `yaml:"HttpCacheMaxAge" json:"HttpCacheMaxAge" flag:"http-cache-maxage"`
HttpVideoMaxAge int `yaml:"HttpVideoMaxAge" json:"HttpVideoMaxAge" flag:"http-video-maxage"`

View file

@ -233,6 +233,9 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"tls-key", c.TLSKey()},
{"http-mode", c.HttpMode()},
{"http-compression", c.HttpCompression()},
{"http-header-timeout", c.HttpHeaderTimeout().String()},
{"http-header-bytes", fmt.Sprintf("%d", c.HttpHeaderBytes())},
{"http-idle-timeout", c.HttpIdleTimeout().String()},
{"http-cache-public", fmt.Sprintf("%t", c.HttpCachePublic())},
{"http-cache-maxage", fmt.Sprintf("%d", c.HttpCacheMaxAge())},
{"http-video-maxage", fmt.Sprintf("%d", c.HttpVideoMaxAge())},

View file

@ -52,6 +52,37 @@ func TestConfig_ReportServicesCIDROrder(t *testing.T) {
assert.Less(t, servicesCIDR, disableTLS)
}
func TestConfig_ReportHttpHeaderSettingsOrder(t *testing.T) {
conf := NewConfig(CliTestContext())
rows, _ := conf.Report()
indexOf := func(name string) int {
for i := range rows {
if len(rows[i]) > 0 && rows[i][0] == name {
return i
}
}
return -1
}
httpCompression := indexOf("http-compression")
httpHeaderTimeout := indexOf("http-header-timeout")
httpHeaderBytes := indexOf("http-header-bytes")
httpIdleTimeout := indexOf("http-idle-timeout")
httpCachePublic := indexOf("http-cache-public")
assert.Greater(t, httpCompression, -1)
assert.Greater(t, httpHeaderTimeout, -1)
assert.Greater(t, httpHeaderBytes, -1)
assert.Greater(t, httpIdleTimeout, -1)
assert.Greater(t, httpCachePublic, -1)
assert.Greater(t, httpHeaderTimeout, httpCompression)
assert.Greater(t, httpHeaderBytes, httpHeaderTimeout)
assert.Greater(t, httpIdleTimeout, httpHeaderBytes)
assert.Less(t, httpIdleTimeout, httpCachePublic)
}
func TestConfig_ReportDatabaseSection(t *testing.T) {
collect := func(rows [][]string) map[string]string {
result := make(map[string]string, len(rows))

View file

@ -1,6 +1,6 @@
## PhotoPrism — HTTP Server
**Last Updated:** February 26, 2026
**Last Updated:** March 4, 2026
### Overview
@ -17,7 +17,7 @@
- Provide a single entrypoint (`Start`) that configures listeners, middleware, and routes consistently.
- Keep health/readiness endpoints lightweight and cache-safe.
- Ensure redirect and TLS listeners include sensible timeouts.
- Ensure redirect and TLS listeners include sensible header and idle limits.
#### Non-Goals
@ -46,7 +46,12 @@
- Compression: only gzip is enabled; brotli requests log a notice.
- Trusted proxies/platform headers are read from config; keep the list tight.
- If no trusted proxy ranges are configured (or the configured ranges are invalid), proxy trust is disabled and client IP resolution falls back to the TCP peer address.
- AutoTLS: uses `autocert` and spins up a redirect listener with explicit read/write timeouts; ensure ports 80/443 are reachable.
- HTTP hardening defaults:
- `ReadHeaderTimeout` is configured via `PHOTOPRISM_HTTP_HEADER_TIMEOUT` / `--http-header-timeout` (default `15s`).
- `MaxHeaderBytes` is configured via `PHOTOPRISM_HTTP_HEADER_BYTES` / `--http-header-bytes` (default `1 MiB`).
- `IdleTimeout` is configured via `PHOTOPRISM_HTTP_IDLE_TIMEOUT` / `--http-idle-timeout` (default `180s`).
- Global `ReadTimeout` / `WriteTimeout` remain disabled to avoid breaking large transfers.
- AutoTLS: uses `autocert` and spins up a redirect listener; ensure ports 80/443 are reachable.
- Unix sockets: optional `force` query removes stale sockets; permissions can be set via `mode` query.
- Health endpoints (`/livez`, `/health`, `/healthz`, `/readyz`) return `Cache-Control: no-store` and `Access-Control-Allow-Origin: *`.

View file

@ -117,17 +117,9 @@ func Start(ctx context.Context, conf *config.Config) {
}
router.Any(conf.BaseUri("/readyz"), isReady)
// Create a new HTTP server instance with no read or write timeout, except for reading the headers:
// https://pkg.go.dev/net/http#Server
server := &http.Server{
ReadHeaderTimeout: time.Minute,
ReadTimeout: -1,
WriteTimeout: -1,
Handler: router,
}
var tlsErr error
var tlsManager *autocert.Manager
var server *http.Server
// Listen on a Unix domain socket instead of a TCP port?
if unixSocket := conf.HttpSocket(); unixSocket != nil {
@ -168,6 +160,7 @@ func Start(ctx context.Context, conf *config.Config) {
// Listen on Unix socket, which should be automatically closed and removed after use:
// https://pkg.go.dev/net#UnixListener.SetUnlinkOnClose.
server = newHTTPServer(router, conf)
server.Addr = listener.Addr().String()
log.Infof("server: listening on %s [%s]", unixSocket.Path, time.Since(start))
@ -182,6 +175,8 @@ func Start(ctx context.Context, conf *config.Config) {
tlsConfig := tlsManager.TLSConfig()
tlsConfig.MinVersion = tls.VersionTLS12
server = newHTTPServer(router, conf)
// Listen on HTTPS socket.
server.Addr = tlsSocket
server.TLSConfig = tlsConfig
@ -198,6 +193,8 @@ func Start(ctx context.Context, conf *config.Config) {
MinVersion: tls.VersionTLS12,
}
server = newHTTPServer(router, conf)
// Listen on HTTPS socket.
server.Addr = tlsSocket
server.TLSConfig = tlsConfig
@ -216,6 +213,7 @@ func Start(ctx context.Context, conf *config.Config) {
return
} else {
// Listen on HTTP socket.
server = newHTTPServer(router, conf)
server.Addr = tcpSocket
log.Infof("server: listening on %s [%s]", server.Addr, time.Since(start))
@ -281,13 +279,8 @@ func StartAutoTLS(s *http.Server, m *autocert.Manager, conf *config.Config) {
var g errgroup.Group
g.Go(func() error {
redirectSrv := &http.Server{
Addr: fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpPort()),
Handler: m.HTTPHandler(http.HandlerFunc(redirect)),
ReadHeaderTimeout: time.Minute,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
redirectSrv := newHTTPServer(m.HTTPHandler(http.HandlerFunc(redirect)), conf)
redirectSrv.Addr = fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpPort())
return redirectSrv.ListenAndServe()
})
@ -310,3 +303,25 @@ func redirect(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, target, httpsRedirect)
}
// newHTTPServer creates an HTTP server with hardened header and idle settings.
func newHTTPServer(handler http.Handler, conf *config.Config) *http.Server {
headerTimeout := config.DefaultHttpHeaderTimeout
headerBytes := config.DefaultHttpHeaderBytes
idleTimeout := config.DefaultHttpIdleTimeout
if conf != nil {
headerTimeout = conf.HttpHeaderTimeout()
headerBytes = conf.HttpHeaderBytes()
idleTimeout = conf.HttpIdleTimeout()
}
return &http.Server{
ReadHeaderTimeout: headerTimeout,
ReadTimeout: 0,
WriteTimeout: 0,
IdleTimeout: idleTimeout,
MaxHeaderBytes: headerBytes,
Handler: handler,
}
}

View file

@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@ -77,3 +78,29 @@ func TestConfigureTrustedProxySettings(t *testing.T) {
assert.Equal(t, "198.51.100.11", ip)
})
}
func TestNewHTTPServer(t *testing.T) {
t.Run("UsesConfiguredValues", func(t *testing.T) {
conf := config.NewConfig(config.CliTestContext())
conf.Options().HttpHeaderTimeout = 15 * time.Second
conf.Options().HttpHeaderBytes = 2048
conf.Options().HttpIdleTimeout = 2 * time.Minute
server := newHTTPServer(http.NewServeMux(), conf)
assert.Equal(t, 15*time.Second, server.ReadHeaderTimeout)
assert.Equal(t, 0*time.Second, server.ReadTimeout)
assert.Equal(t, 0*time.Second, server.WriteTimeout)
assert.Equal(t, 2*time.Minute, server.IdleTimeout)
assert.Equal(t, 2048, server.MaxHeaderBytes)
})
t.Run("UsesDefaultsWhenConfigIsNil", func(t *testing.T) {
server := newHTTPServer(http.NewServeMux(), nil)
assert.Equal(t, config.DefaultHttpHeaderTimeout, server.ReadHeaderTimeout)
assert.Equal(t, 0*time.Second, server.ReadTimeout)
assert.Equal(t, 0*time.Second, server.WriteTimeout)
assert.Equal(t, config.DefaultHttpIdleTimeout, server.IdleTimeout)
assert.Equal(t, config.DefaultHttpHeaderBytes, server.MaxHeaderBytes)
})
}