{"record":{"id":"99b59f56f8f6555f","repo":"caddyserver/caddy","slug":"malformed-if-match-header-expect-quoted-string","errorCode":null,"errorMessage":"malformed If-Match header; expect quoted string","messagePattern":"malformed If-Match header; expect quoted string","errorType":"http","errorClass":"APIError","httpStatus":400,"severity":"error","filePath":"caddy.go","lineNumber":176,"sourceCode":"func changeConfig(method, path string, input []byte, ifMatchHeader string, forceReload bool) error {\n\tswitch method {\n\tcase http.MethodGet,\n\t\thttp.MethodHead,\n\t\thttp.MethodOptions,\n\t\thttp.MethodConnect,\n\t\thttp.MethodTrace:\n\t\treturn fmt.Errorf(\"method not allowed\")\n\t}\n\n\trawCfgMu.Lock()\n\tdefer rawCfgMu.Unlock()\n\n\tif ifMatchHeader != \"\" {\n\t\t// expect the first and last character to be quotes\n\t\tif len(ifMatchHeader) < 2 || ifMatchHeader[0] != '\"' || ifMatchHeader[len(ifMatchHeader)-1] != '\"' {\n\t\t\treturn APIError{\n\t\t\t\tHTTPStatus: http.StatusBadRequest,\n\t\t\t\tErr:        fmt.Errorf(\"malformed If-Match header; expect quoted string\"),\n\t\t\t}\n\t\t}\n\n\t\t// read out the parts\n\t\tparts := strings.Fields(ifMatchHeader[1 : len(ifMatchHeader)-1])\n\t\tif len(parts) != 2 {\n\t\t\treturn APIError{\n\t\t\t\tHTTPStatus: http.StatusBadRequest,\n\t\t\t\tErr:        fmt.Errorf(\"malformed If-Match header; expect format \\\"<path> <hash>\\\"\"),\n\t\t\t}\n\t\t}\n\n\t\t// get the current hash of the config\n\t\t// at the given path\n\t\thash := etagHasher()\n\t\terr := unsyncedConfigAccess(http.MethodGet, parts[0], nil, hash)\n\t\tif err != nil {\n\t\t\treturn err","sourceCodeStart":158,"sourceCodeEnd":194,"githubUrl":"https://github.com/caddyserver/caddy/blob/50e54ee279aa1e504fe218ca49ab6ae16c100410/caddy.go#L158-L194","documentation":"When a config-change request carries an If-Match header, changeConfig requires the header value to be a quoted string (per ETag conventions). Values shorter than two characters or lacking surrounding double quotes are rejected as an APIError with HTTP 400 before any comparison happens. This is a client-side formatting bug, not a config mismatch.","triggerScenarios":"Sending If-Match: abc123 (unquoted), If-Match: \" (single quote char), or an empty quoted string \"\" against POST/PUT/PATCH /load or /config/ endpoints; copying the raw hex hash from a GET response's ETag body instead of the quoted ETag header value.","commonSituations":"Automation scripts that build If-Match from the hash alone; clients that strip quotes when storing ETags; tutorials showing the unquoted hash; using W/ weak prefixes or * which this implementation does not accept.","solutions":["Wrap the ETag in double quotes: If-Match: \"<path> <hash>\" — copy the value verbatim from the ETag response header of a prior GET.","Ensure the quoted content contains exactly two fields: the config path and the hash (see the follow-up format error).","Drop the If-Match header entirely if optimistic concurrency is not needed."],"exampleFix":"# before\ncurl -X POST localhost:2019/load -H 'If-Match: 5f2c...a1' --data @cfg.json\n\n# after\ncurl -X POST localhost:2019/load -H 'If-Match: \"/ 5f2c...a1\"' --data @cfg.json","handlingStrategy":"validation","validationCode":"func validIfMatch(h string) bool {\n    return len(h) >= 2 && h[0] == '\"' && h[len(h)-1] == '\"'\n}\nif ifMatch != \"\" && !validIfMatch(ifMatch) {\n    return errors.New(\"If-Match must be a quoted string\")\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Always copy the ETag response header verbatim into If-Match.","Never hand-build the header from a bare hash.","Store ETags opaque — do not strip quotes when persisting them."],"tags":["caddy","admin-api","http","if-match","etag","validation"],"backgroundTag":null,"analyzedSha":"50e54ee279aa1e504fe218ca49ab6ae16c100410","analyzedAt":"2026-08-15T09:20:21.641Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}