-
Notifications
You must be signed in to change notification settings - Fork 14
feat(updater): support custom HTTP headers via updater.conf #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ _testmain.go | |
|
|
||
| *.exe | ||
| *.test | ||
| coverage.out | ||
|
|
||
| # Direnv stuff | ||
| .envrc | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| // SILVER - Service Wrapper | ||
| // Auto Updater | ||
| // | ||
| // Copyright (c) 2026 PaperCut Software http://www.papercut.com/ | ||
| // Use of this source code is governed by an MIT or GPL Version 2 license. | ||
| // See the project's LICENSE file for more information. | ||
| // | ||
|
|
||
| package update | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "net/textproto" | ||
| "os" | ||
| "regexp" | ||
| "sort" | ||
| "strings" | ||
| ) | ||
|
|
||
| const ( | ||
| headersFileName = "updater.conf" | ||
| maxHeadersFileBytes = 64 * 1024 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assume these limits are intended to restrict the header size. If such a restriction is to be implemented, I think it should either limit only the data size of the JSON file, or, instead of the file size limitation, calculate the number of headers, the size of keys and values when decoding the JSON. If restrictions are applied to both, there is a possibility that a definition cannot be established because it fails to meet one of the restrictions. Wouldn't that cause confusion for users defining custom headers?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Want me to drop the per-value/per-count limits and keep only the file-size limit?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Envoy proxy does something similar.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the new configuration file is used for other purposes as well, it may be difficult in the future to manage the size of custom headers using only the configuration file size. If flexibility is required, calculating the custom header size with low-level parsing of JSON data will be required. |
||
| maxHeaderValueBytes = 4096 | ||
| maxCustomHeaders = 20 | ||
| ) | ||
|
|
||
| // systemHeaderKeys are headers describing this binary or host itself. | ||
| // A custom header sharing one of these names is dropped. | ||
| // This provides defense in depth alongside the load-order guarantee in Check(). | ||
| // | ||
| // Legacy profile identity and channel headers are deliberately omitted. | ||
| // The new updater.conf is allowed to override updater-profile.conf on collision. | ||
| // | ||
| // Host, Content-Length, Transfer-Encoding, and Connection are also denied. | ||
| // Go's net/http manages these itself. It ignores whatever is set on | ||
| // req.Header for them. Allowing them through would silently do nothing. | ||
| // That's a confusing trap for an operator configuring updater.conf. | ||
| var systemHeaderKeys = map[string]struct{}{ | ||
| textproto.CanonicalMIMEHeaderKey(headerUserAgentKey): {}, | ||
| textproto.CanonicalMIMEHeaderKey(headerProfileTimezoneKey): {}, | ||
| textproto.CanonicalMIMEHeaderKey(headerOSTypeKey): {}, | ||
| textproto.CanonicalMIMEHeaderKey(headerOSVersionKey): {}, | ||
| textproto.CanonicalMIMEHeaderKey(headerOSArchKey): {}, | ||
| textproto.CanonicalMIMEHeaderKey(headerBinaryArchKey): {}, | ||
| textproto.CanonicalMIMEHeaderKey("Host"): {}, | ||
| textproto.CanonicalMIMEHeaderKey("Content-Length"): {}, | ||
| textproto.CanonicalMIMEHeaderKey("Transfer-Encoding"): {}, | ||
| textproto.CanonicalMIMEHeaderKey("Connection"): {}, | ||
| } | ||
|
|
||
| func isSystemHeader(canonical string) bool { | ||
| _, ok := systemHeaderKeys[canonical] | ||
| return ok | ||
| } | ||
|
|
||
| type headersFile struct { | ||
| Headers map[string]string `json:"Headers"` | ||
| } | ||
|
|
||
| // AddCustomHeaders attaches operator-defined headers from updater.conf | ||
| // to req. It fails open on any error. | ||
| // | ||
| // Callers must invoke AddCustomHeaders after legacy updater-profile.conf | ||
| // headers are set. Call it before User-Agent and OS headers are set. | ||
| func AddCustomHeaders(req *http.Request) { | ||
| headers, err := loadCustomHeaders() | ||
| if err != nil { | ||
| if errors.Is(err, os.ErrNotExist) { | ||
| fmt.Printf("%s not found. Proceeding without custom headers.\n", headersFileName) | ||
| } else { | ||
| fmt.Printf("Couldn't load custom headers: %v.\n", err) | ||
| } | ||
| return | ||
| } | ||
| for key, value := range headers { | ||
| req.Header.Set(key, value) | ||
| } | ||
| } | ||
|
|
||
| func loadCustomHeaders() (map[string]string, error) { | ||
| fn, err := getHeadersFileName() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return loadCustomHeadersFromFile(fn) | ||
| } | ||
|
|
||
| func loadCustomHeadersFromFile(fn string) (map[string]string, error) { | ||
| f, err := os.Open(fn) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| defer f.Close() | ||
|
|
||
| // Read one byte past the limit. An oversized file becomes an error. | ||
| // It is not silently truncated. | ||
| data, err := io.ReadAll(io.LimitReader(f, maxHeadersFileBytes+1)) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if len(data) > maxHeadersFileBytes { | ||
| return nil, fmt.Errorf("%s exceeds the %d byte limit", headersFileName, maxHeadersFileBytes) | ||
| } | ||
|
|
||
| var conf headersFile | ||
| if err := json.Unmarshal(data, &conf); err != nil { | ||
| return nil, fmt.Errorf("invalid JSON in %s: %w", headersFileName, err) | ||
| } | ||
|
|
||
| return validateHeaders(conf.Headers), nil | ||
| } | ||
|
|
||
| func validateHeaders(raw map[string]string) map[string]string { | ||
| // Sort keys first. Map iteration order is random. | ||
| // Without sorting, which headers get dropped past maxCustomHeaders | ||
| // would differ from run to run. | ||
| keys := make([]string, 0, len(raw)) | ||
| for key := range raw { | ||
| keys = append(keys, key) | ||
| } | ||
| sort.Strings(keys) | ||
|
|
||
| // "X-Foo" and "x-foo" canonicalize to the same header. | ||
| // Keep only the first one seen. This keeps the winner deterministic. | ||
| // Otherwise random map iteration order would decide it later. | ||
| seenCanonical := make(map[string]bool, len(raw)) | ||
|
|
||
| valid := make(map[string]string, len(raw)) | ||
| for i, key := range keys { | ||
| if len(valid) >= maxCustomHeaders { | ||
| fmt.Printf("%s defines more than %d valid headers. Ignoring the remaining %d.\n", | ||
| headersFileName, maxCustomHeaders, len(keys)-i) | ||
| break | ||
| } | ||
|
|
||
| value := raw[key] | ||
| key = strings.TrimSpace(key) | ||
| value = strings.TrimSpace(value) | ||
| canonical := textproto.CanonicalMIMEHeaderKey(key) | ||
|
|
||
| switch { | ||
| case !isValidHeaderFieldName(key): | ||
| fmt.Printf("Ignoring custom header %q. Invalid header field name.\n", key) | ||
| case len(value) > maxHeaderValueBytes: | ||
| fmt.Printf("Ignoring custom header %q. Value exceeds %d bytes.\n", key, maxHeaderValueBytes) | ||
| case !isValidHeaderFieldValue(value): | ||
| fmt.Printf("Ignoring custom header %q. Invalid header field value.\n", key) | ||
| case isSystemHeader(canonical): | ||
| fmt.Printf("Ignoring custom header %q. Reserved for internal use.\n", key) | ||
| case seenCanonical[canonical]: | ||
| fmt.Printf("Ignoring custom header %q. Duplicate of an already-configured header.\n", key) | ||
| default: | ||
| valid[key] = value | ||
| seenCanonical[canonical] = true | ||
| } | ||
| } | ||
| return valid | ||
| } | ||
|
|
||
| func getHeadersFileName() (string, error) { | ||
| return fileNextToExecutable(headersFileName) | ||
| } | ||
|
|
||
| // reHeaderFieldName matches a non-empty RFC 9110 token. | ||
| // That's the valid form for an HTTP field-name. | ||
| // See https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.2 | ||
| var reHeaderFieldName = regexp.MustCompile("^[\\w!#$%&'*+\\-.^`|~]+$") | ||
|
|
||
| func isValidHeaderFieldName(s string) bool { | ||
| return reHeaderFieldName.MatchString(s) | ||
| } | ||
|
|
||
| // isValidHeaderFieldValue reports whether s is a valid RFC 9110 field-value. | ||
| // Valid means visible ASCII or obs-text bytes, plus internal tabs. | ||
| // Control characters are rejected, notably CR/LF. | ||
| // Those could otherwise inject extra header lines. | ||
| func isValidHeaderFieldValue(s string) bool { | ||
| for i := 0; i < len(s); i++ { | ||
| b := s[i] | ||
| if b == '\t' { | ||
| continue | ||
| } | ||
| if b < 0x20 || b == 0x7f { | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be better to split into an internal pacakge, such as
update/internal/customheaer.