VYPR
Moderate severityNVD Advisory· Published Feb 24, 2026· Updated Feb 27, 2026

Caddy vulnerable to cross-origin config application via local admin API /load (caddy)

CVE-2026-27589

Description

Caddy is an extensible server platform that uses TLS by default. Prior to version 2.11.1, the local caddy admin API (default listen 127.0.0.1:2019) exposes a state-changing POST /load endpoint that replaces the entire running configuration. When origin enforcement is not enabled (enforce_origin not configured), the admin endpoint accepts cross-origin requests (e.g., from attacker-controlled web content in a victim browser) and applies an attacker-supplied JSON config. This can change the admin listener settings and alter HTTP server behavior without user intent. Version 2.11.1 contains a fix for the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/caddyserver/caddy/v2Go
< 2.11.12.11.1

Affected products

1

Patches

1
65e0ddc22137

core: Reloading with `SIGUSR1` if config never changed via admin (#7258)

https://github.com/caddyserver/caddyFrancis LavoieSep 26, 2025via ghsa
7 files changed · +167 20
  • admin.go+7 0 modified
    @@ -1029,6 +1029,13 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {
     			return err
     		}
     
    +		// If this request changed the config, clear the last
    +		// config info we have stored, if it is different from
    +		// the original source.
    +		ClearLastConfigIfDifferent(
    +			r.Header.Get("Caddy-Config-Source-File"),
    +			r.Header.Get("Caddy-Config-Source-Adapter"))
    +
     	default:
     		return APIError{
     			HTTPStatus: http.StatusMethodNotAllowed,
    
  • caddyconfig/load.go+7 0 modified
    @@ -121,6 +121,13 @@ func (adminLoad) handleLoad(w http.ResponseWriter, r *http.Request) error {
     		}
     	}
     
    +	// If this request changed the config, clear the last
    +	// config info we have stored, if it is different from
    +	// the original source.
    +	caddy.ClearLastConfigIfDifferent(
    +		r.Header.Get("Caddy-Config-Source-File"),
    +		r.Header.Get("Caddy-Config-Source-Adapter"))
    +
     	caddy.Log().Named("admin.api").Info("load complete")
     
     	return nil
    
  • caddy.go+85 0 modified
    @@ -1197,6 +1197,91 @@ var (
     	rawCfgMu sync.RWMutex
     )
     
    +// lastConfigFile and lastConfigAdapter remember the source config
    +// file and adapter used when Caddy was started via the CLI "run" command.
    +// These are consulted by the SIGUSR1 handler to attempt reloading from
    +// the same source. They are intentionally not set for other entrypoints
    +// such as "caddy start" or subcommands like file-server.
    +var (
    +	lastConfigMu      sync.RWMutex
    +	lastConfigFile    string
    +	lastConfigAdapter string
    +)
    +
    +// reloadFromSourceFunc is the type of stored callback
    +// which is called when we receive a SIGUSR1 signal.
    +type reloadFromSourceFunc func(file, adapter string) error
    +
    +// reloadFromSourceCallback is the stored callback
    +// which is called when we receive a SIGUSR1 signal.
    +var reloadFromSourceCallback reloadFromSourceFunc
    +
    +// errReloadFromSourceUnavailable is returned when no reload-from-source callback is set.
    +var errReloadFromSourceUnavailable = errors.New("reload from source unavailable in this process") //nolint:unused
    +
    +// SetLastConfig records the given source file and adapter as the
    +// last-known external configuration source. Intended to be called
    +// only when starting via "caddy run --config <file> --adapter <adapter>".
    +func SetLastConfig(file, adapter string, fn reloadFromSourceFunc) {
    +	lastConfigMu.Lock()
    +	lastConfigFile = file
    +	lastConfigAdapter = adapter
    +	reloadFromSourceCallback = fn
    +	lastConfigMu.Unlock()
    +}
    +
    +// ClearLastConfigIfDifferent clears the recorded last-config if the provided
    +// source file/adapter do not match the recorded last-config. If both srcFile
    +// and srcAdapter are empty, the last-config is cleared.
    +func ClearLastConfigIfDifferent(srcFile, srcAdapter string) {
    +	if (srcFile != "" || srcAdapter != "") && lastConfigMatches(srcFile, srcAdapter) {
    +		return
    +	}
    +	SetLastConfig("", "", nil)
    +}
    +
    +// getLastConfig returns the last-known config file and adapter.
    +func getLastConfig() (file, adapter string, fn reloadFromSourceFunc) {
    +	lastConfigMu.RLock()
    +	f, a, cb := lastConfigFile, lastConfigAdapter, reloadFromSourceCallback
    +	lastConfigMu.RUnlock()
    +	return f, a, cb
    +}
    +
    +// lastConfigMatches returns true if the provided source file and/or adapter
    +// matches the recorded last-config. Matching rules (in priority order):
    +//  1. If srcAdapter is provided and differs from the recorded adapter, no match.
    +//  2. If srcFile exactly equals the recorded file, match.
    +//  3. If both sides can be made absolute and equal, match.
    +//  4. If basenames are equal, match.
    +func lastConfigMatches(srcFile, srcAdapter string) bool {
    +	lf, la, _ := getLastConfig()
    +
    +	// If adapter is provided, it must match.
    +	if srcAdapter != "" && srcAdapter != la {
    +		return false
    +	}
    +
    +	// Quick equality check.
    +	if srcFile == lf {
    +		return true
    +	}
    +
    +	// Try absolute path comparison.
    +	sAbs, sErr := filepath.Abs(srcFile)
    +	lAbs, lErr := filepath.Abs(lf)
    +	if sErr == nil && lErr == nil && sAbs == lAbs {
    +		return true
    +	}
    +
    +	// Final fallback: basename equality.
    +	if filepath.Base(srcFile) == filepath.Base(lf) {
    +		return true
    +	}
    +
    +	return false
    +}
    +
     // errSameConfig is returned if the new config is the same
     // as the old one. This isn't usually an actual, actionable
     // error; it's mostly a sentinel value.
    
  • cmd/commandfuncs.go+23 5 modified
    @@ -231,8 +231,9 @@ func cmdRun(fl Flags) (int, error) {
     	}
     	// we don't use 'else' here since this value might have been changed in 'if' block; i.e. not mutually exclusive
     	var configFile string
    +	var adapterUsed string
     	if !resumeFlag {
    -		config, configFile, err = LoadConfig(configFlag, configAdapterFlag)
    +		config, configFile, adapterUsed, err = LoadConfig(configFlag, configAdapterFlag)
     		if err != nil {
     			logBuffer.FlushTo(defaultLogger)
     			return caddy.ExitCodeFailedStartup, err
    @@ -249,6 +250,19 @@ func cmdRun(fl Flags) (int, error) {
     		}
     	}
     
    +	// If we have a source config file (we're running via 'caddy run --config ...'),
    +	// record it so SIGUSR1 can reload from the same file. Also provide a callback
    +	// that knows how to load/adapt that source when requested by the main process.
    +	if configFile != "" {
    +		caddy.SetLastConfig(configFile, adapterUsed, func(file, adapter string) error {
    +			cfg, _, _, err := LoadConfig(file, adapter)
    +			if err != nil {
    +				return err
    +			}
    +			return caddy.Load(cfg, true)
    +		})
    +	}
    +
     	// run the initial config
     	err = caddy.Load(config, true)
     	if err != nil {
    @@ -295,7 +309,7 @@ func cmdRun(fl Flags) (int, error) {
     	// if enabled, reload config file automatically on changes
     	// (this better only be used in dev!)
     	if watchFlag {
    -		go watchConfigFile(configFile, configAdapterFlag)
    +		go watchConfigFile(configFile, adapterUsed)
     	}
     
     	// warn if the environment does not provide enough information about the disk
    @@ -350,7 +364,7 @@ func cmdReload(fl Flags) (int, error) {
     	forceFlag := fl.Bool("force")
     
     	// get the config in caddy's native format
    -	config, configFile, err := LoadConfig(configFlag, configAdapterFlag)
    +	config, configFile, adapterUsed, err := LoadConfig(configFlag, configAdapterFlag)
     	if err != nil {
     		return caddy.ExitCodeFailedStartup, err
     	}
    @@ -368,6 +382,10 @@ func cmdReload(fl Flags) (int, error) {
     	if forceFlag {
     		headers.Set("Cache-Control", "must-revalidate")
     	}
    +	// Provide the source file/adapter to the running process so it can
    +	// preserve its last-config knowledge if this reload came from the same source.
    +	headers.Set("Caddy-Config-Source-File", configFile)
    +	headers.Set("Caddy-Config-Source-Adapter", adapterUsed)
     
     	resp, err := AdminAPIRequest(adminAddr, http.MethodPost, "/load", headers, bytes.NewReader(config))
     	if err != nil {
    @@ -582,7 +600,7 @@ func cmdValidateConfig(fl Flags) (int, error) {
     			fmt.Errorf("input file required when there is no Caddyfile in current directory (use --config flag)")
     	}
     
    -	input, _, err := LoadConfig(configFlag, adapterFlag)
    +	input, _, _, err := LoadConfig(configFlag, adapterFlag)
     	if err != nil {
     		return caddy.ExitCodeFailedStartup, err
     	}
    @@ -797,7 +815,7 @@ func DetermineAdminAPIAddress(address string, config []byte, configFile, configA
     		loadedConfig := config
     		if len(loadedConfig) == 0 {
     			// get the config in caddy's native format
    -			loadedConfig, loadedConfigFile, err = LoadConfig(configFile, configAdapter)
    +			loadedConfig, loadedConfigFile, _, err = LoadConfig(configFile, configAdapter)
     			if err != nil {
     				return "", err
     			}
    
  • cmd/main.go+18 13 modified
    @@ -100,7 +100,12 @@ func handlePingbackConn(conn net.Conn, expect []byte) error {
     // there is no config available. It prints any warnings to stderr,
     // and returns the resulting JSON config bytes along with
     // the name of the loaded config file (if any).
    -func LoadConfig(configFile, adapterName string) ([]byte, string, error) {
    +// The return values are:
    +//   - config bytes (nil if no config)
    +//   - config file used ("" if none)
    +//   - adapter used ("" if none)
    +//   - error, if any
    +func LoadConfig(configFile, adapterName string) ([]byte, string, string, error) {
     	return loadConfigWithLogger(caddy.Log(), configFile, adapterName)
     }
     
    @@ -138,7 +143,7 @@ func isCaddyfile(configFile, adapterName string) (bool, error) {
     	return false, nil
     }
     
    -func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([]byte, string, error) {
    +func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([]byte, string, string, error) {
     	// if no logger is provided, use a nop logger
     	// just so we don't have to check for nil
     	if logger == nil {
    @@ -147,7 +152,7 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
     
     	// specifying an adapter without a config file is ambiguous
     	if adapterName != "" && configFile == "" {
    -		return nil, "", fmt.Errorf("cannot adapt config without config file (use --config)")
    +		return nil, "", "", fmt.Errorf("cannot adapt config without config file (use --config)")
     	}
     
     	// load initial config and adapter
    @@ -158,13 +163,13 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
     		if configFile == "-" {
     			config, err = io.ReadAll(os.Stdin)
     			if err != nil {
    -				return nil, "", fmt.Errorf("reading config from stdin: %v", err)
    +				return nil, "", "", fmt.Errorf("reading config from stdin: %v", err)
     			}
     			logger.Info("using config from stdin")
     		} else {
     			config, err = os.ReadFile(configFile)
     			if err != nil {
    -				return nil, "", fmt.Errorf("reading config from file: %v", err)
    +				return nil, "", "", fmt.Errorf("reading config from file: %v", err)
     			}
     			logger.Info("using config from file", zap.String("file", configFile))
     		}
    @@ -179,7 +184,7 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
     				cfgAdapter = nil
     			} else if err != nil {
     				// default Caddyfile exists, but error reading it
    -				return nil, "", fmt.Errorf("reading default Caddyfile: %v", err)
    +				return nil, "", "", fmt.Errorf("reading default Caddyfile: %v", err)
     			} else {
     				// success reading default Caddyfile
     				configFile = "Caddyfile"
    @@ -191,14 +196,14 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
     	if yes, err := isCaddyfile(configFile, adapterName); yes {
     		adapterName = "caddyfile"
     	} else if err != nil {
    -		return nil, "", err
    +		return nil, "", "", err
     	}
     
     	// load config adapter
     	if adapterName != "" {
     		cfgAdapter = caddyconfig.GetAdapter(adapterName)
     		if cfgAdapter == nil {
    -			return nil, "", fmt.Errorf("unrecognized config adapter: %s", adapterName)
    +			return nil, "", "", fmt.Errorf("unrecognized config adapter: %s", adapterName)
     		}
     	}
     
    @@ -208,7 +213,7 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
     			"filename": configFile,
     		})
     		if err != nil {
    -			return nil, "", fmt.Errorf("adapting config using %s: %v", adapterName, err)
    +			return nil, "", "", fmt.Errorf("adapting config using %s: %v", adapterName, err)
     		}
     		logger.Info("adapted config to JSON", zap.String("adapter", adapterName))
     		for _, warn := range warnings {
    @@ -226,11 +231,11 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([
     		// validate that the config is at least valid JSON
     		err = json.Unmarshal(config, new(any))
     		if err != nil {
    -			return nil, "", fmt.Errorf("config is not valid JSON: %v; did you mean to use a config adapter (the --adapter flag)?", err)
    +			return nil, "", "", fmt.Errorf("config is not valid JSON: %v; did you mean to use a config adapter (the --adapter flag)?", err)
     		}
     	}
     
    -	return config, configFile, nil
    +	return config, configFile, adapterName, nil
     }
     
     // watchConfigFile watches the config file at filename for changes
    @@ -256,7 +261,7 @@ func watchConfigFile(filename, adapterName string) {
     	}
     
     	// get current config
    -	lastCfg, _, err := loadConfigWithLogger(nil, filename, adapterName)
    +	lastCfg, _, _, err := loadConfigWithLogger(nil, filename, adapterName)
     	if err != nil {
     		logger().Error("unable to load latest config", zap.Error(err))
     		return
    @@ -268,7 +273,7 @@ func watchConfigFile(filename, adapterName string) {
     	//nolint:staticcheck
     	for range time.Tick(1 * time.Second) {
     		// get current config
    -		newCfg, _, err := loadConfigWithLogger(nil, filename, adapterName)
    +		newCfg, _, _, err := loadConfigWithLogger(nil, filename, adapterName)
     		if err != nil {
     			logger().Error("unable to load latest config", zap.Error(err))
     			return
    
  • cmd/storagefuncs.go+1 1 modified
    @@ -36,7 +36,7 @@ type storVal struct {
     // determineStorage returns the top-level storage module from the given config.
     // It may return nil even if no error.
     func determineStorage(configFile string, configAdapter string) (*storVal, error) {
    -	cfg, _, err := LoadConfig(configFile, configAdapter)
    +	cfg, _, _, err := LoadConfig(configFile, configAdapter)
     	if err != nil {
     		return nil, err
     	}
    
  • sigtrap_posix.go+26 1 modified
    @@ -18,6 +18,7 @@ package caddy
     
     import (
     	"context"
    +	"errors"
     	"os"
     	"os/signal"
     	"syscall"
    @@ -48,7 +49,31 @@ func trapSignalsPosix() {
     				exitProcessFromSignal("SIGTERM")
     
     			case syscall.SIGUSR1:
    -				Log().Info("not implemented", zap.String("signal", "SIGUSR1"))
    +				logger := Log().With(zap.String("signal", "SIGUSR1"))
    +				// If we know the last source config file/adapter (set when starting
    +				// via `caddy run --config <file> --adapter <adapter>`), attempt
    +				// to reload from that source. Otherwise, ignore the signal.
    +				file, adapter, reloadCallback := getLastConfig()
    +				if file == "" {
    +					logger.Info("last config unknown, ignored SIGUSR1")
    +					break
    +				}
    +				logger = logger.With(
    +					zap.String("file", file),
    +					zap.String("adapter", adapter))
    +				if reloadCallback == nil {
    +					logger.Warn("no reload helper available, ignored SIGUSR1")
    +					break
    +				}
    +				logger.Info("reloading config from last-known source")
    +				if err := reloadCallback(file, adapter); errors.Is(err, errReloadFromSourceUnavailable) {
    +					// No reload helper available (likely not started via caddy run).
    +					logger.Warn("reload from source unavailable in this process; ignored SIGUSR1")
    +				} else if err != nil {
    +					logger.Error("failed to reload config from file", zap.Error(err))
    +				} else {
    +					logger.Info("successfully reloaded config from file")
    +				}
     
     			case syscall.SIGUSR2:
     				Log().Info("not implemented", zap.String("signal", "SIGUSR2"))
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.