Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ _testmain.go

*.exe
*.test
coverage.out

# Direnv stuff
.envrc
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,27 @@ You can define a series of operations to run after the update is extracted:
* `remove` / `rm` / `del`: Delete a file or directory.
* `batchrename`: Recursively find and rename files in a directory.

### **Custom HTTP Headers**

The `updater` can attach operator-defined HTTP headers to manifest check and package download requests. This allows Silver to work behind infrastructure that expects custom headers. Examples: routing tags for a multi-tenant CDN, or an enterprise API gateway.

Headers are read from a JSON file named `updater.conf` located alongside the `updater` binary:

```json
{
"Headers": {
"X-Custom-Tenant": "tenant-42",
"X-Custom-Routing": "us-east-edge"
}
}
```

* If `updater.conf` is missing or invalid, the updater logs a warning and fails open. It proceeds without custom headers and never blocks an update check.
* Header names and values are validated against [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#fields). Individual values are capped at 4 KB. Headers are capped at 20 per file. The file size is capped at 64 KB.
* Custom headers can override legacy `X-profile-identity` and `X-profile-channel` headers from `updater-profile.conf`. They cannot override host headers (`User-Agent`, `X-OS-*`, `X-Binary-Arch`, `X-profile-timezone`).

**Note**: Avoid placing credentials (such as an `Authorization` token) in `updater.conf`. The file is stored in plaintext on disk. Custom headers are also forwarded on HTTP redirects. That includes cross-domain redirects.

## **A Robust Upgrade Strategy**

Overwriting files in-place during an upgrade is risky. A partial update caused by a full disk, an inconveniently timed system reboot, or a permissions issue can leave your application in an unrecoverable state.
Expand Down Expand Up @@ -405,6 +426,8 @@ While delivering manifests over a secure HTTPS connection is a fundamental first
* `updater.exe profile-set-random-id`: Sets a unique random ID for this installation, sent to the update server.
* `updater.exe profile-set-channel <channel-name>`: Sets the update channel (e.g., `beta`, `stable`), also sent to the update server for targeted rollouts.

These commands write to `updater-profile.conf`. That file is deprecated in favour of `updater.conf` (see *Custom HTTP Headers* above). It is still supported for backward compatibility.

---

## **Building from Source**
Expand Down
16 changes: 13 additions & 3 deletions updater/update/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,17 @@ package update

// Test-only exports for black-box tests in package update_test.
var (
SetOSHeaders = setOSHeaders
TrimToNumericVersion = trimToNumericVersion
UserAgent = userAgent
SetOSHeaders = setOSHeaders
TrimToNumericVersion = trimToNumericVersion
UserAgent = userAgent
LoadCustomHeadersFromFile = loadCustomHeadersFromFile
ValidateHeaders = validateHeaders
IsValidHeaderFieldName = isValidHeaderFieldName
IsValidHeaderFieldValue = isValidHeaderFieldValue
)

const (
MaxHeadersFileBytes = maxHeadersFileBytes
MaxHeaderValueBytes = maxHeaderValueBytes
MaxCustomHeaders = maxCustomHeaders
)
186 changes: 186 additions & 0 deletions updater/update/headers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// 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"
"fmt"
"io"
"net/http"
"net/textproto"
"os"
"regexp"
"sort"
"strings"
)

const (
headersFileName = "updater.conf"
maxHeadersFileBytes = 64 * 1024
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]bool{
textproto.CanonicalMIMEHeaderKey(headerUserAgentKey): true,
textproto.CanonicalMIMEHeaderKey(headerProfileTimezoneKey): true,
textproto.CanonicalMIMEHeaderKey(headerOSTypeKey): true,
textproto.CanonicalMIMEHeaderKey(headerOSVersionKey): true,
textproto.CanonicalMIMEHeaderKey(headerOSArchKey): true,
textproto.CanonicalMIMEHeaderKey(headerBinaryArchKey): true,
textproto.CanonicalMIMEHeaderKey("Host"): true,
textproto.CanonicalMIMEHeaderKey("Content-Length"): true,
textproto.CanonicalMIMEHeaderKey("Transfer-Encoding"): true,
textproto.CanonicalMIMEHeaderKey("Connection"): true,
}

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 os.IsNotExist(err) {
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 systemHeaderKeys[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)
}

// headerFieldNameRE 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 headerFieldNameRE = regexp.MustCompile(`^[A-Za-z0-9!#$%&'*+\-.^_` + "`" + `|~]+$`)

func isValidHeaderFieldName(s string) bool {
return headerFieldNameRE.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
}
Loading