From 7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 15 Jul 2026 02:24:58 -0500 Subject: fix: harden HTTP transport, server timeouts and panic paths Correctness and security findings surfaced by golangci-lint, plus two latent panics found alongside them. - router: http.ListenAndServe has no timeouts at all (gosec G114), so a slow client could hold a connection and its handler open indefinitely. Replace it with an explicit http.Server carrying read/write/idle timeouts. - httpclient: InstallDAThrottle asserted http.DefaultTransport was a *http.Transport and would panic outright if anything had already wrapped it -- which is precisely what that function does. Check the assertion and fall back to a fresh transport. Expose ProxiedTransport so a configured download-proxy can inherit the same throttle and timeouts instead of silently bypassing them. - cache: the Sys() assertion to *syscall.Stat_t is only valid on unix and would panic elsewhere; skip rotation instead. Indexing Headers["Content-Type"][0] panics when the header is absent; use Headers.Get. Cache files are written 0600 rather than 0700, as they are never executed. - cli, api: check error returns, and exit rather than nil-dereference a file handle that failed to open. SHA-1 and math/rand keep //nolint:gosec with reasons: they are cache-key hashes and random-artwork picks, not security primitives. --- app/cache.go | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) (limited to 'app/cache.go') diff --git a/app/cache.go b/app/cache.go index 1cb8988..91c9847 100755 --- a/app/cache.go +++ b/app/cache.go @@ -1,8 +1,9 @@ -// TODO: implement JSON caching and clean up the code package app +// TODO: implement JSON caching and clean up the code. + import ( - "crypto/sha1" + "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive "encoding/hex" "io" "os" @@ -20,6 +21,9 @@ type file struct { var tempFS = make(map[[20]byte]*file) var mx = &sync.RWMutex{} +// DownloadAndSendMedia proxies one image from DeviantArt's wixmp CDN to the +// client, serving it from the on-disk or in-memory cache when enabled. It +// responds 403 when proxying is turned off for this instance. func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { var url strings.Builder url.WriteString("https://images-wixmp-") @@ -35,20 +39,24 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { switch { case CFG.Cache.Enabled: - fileName := sha1.Sum([]byte(subdomain + path)) + fileName := sha1.Sum([]byte(subdomain + path)) //nolint:gosec // G401: cache-key hash, not a security primitive filePath := CFG.Cache.Path + "/" + hex.EncodeToString(fileName[:]) c := func() { - file, err := os.Open(filePath) + // filePath is built from a SHA-1 of the request, not from user input, + // so it cannot escape the cache directory. + file, err := os.Open(filePath) //nolint:gosec // G304: path is a hash, not user-controlled if err != nil { - if dwnld := Download(url.String()); dwnld.Status == 200 && dwnld.Headers["Content-Type"][0][:5] == "image" { + dwnld := Download(url.String()) + if dwnld.Status == 200 && strings.HasPrefix(dwnld.Headers.Get("Content-Type"), "image") { response = dwnld.Body - try(os.WriteFile(filePath, response, 0700)) + try(os.WriteFile(filePath, response, 0600)) } else { s.ReturnHTTPError(dwnld.Status) return } } else { + defer func() { try(file.Close()) }() file, e := io.ReadAll(file) try(e) response = file @@ -104,16 +112,19 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { response = []byte("Sorry, butt proxy on this instance are disabled.") } - s.Writer.Write(response) + _, _ = s.Writer.Write(response) } +// InitCacheSystem runs the cache rotation loop forever, evicting files past +// their lifetime and emptying the cache when it outgrows max-size. Run it in its +// own goroutine. func InitCacheSystem() { c := &CFG.Cache for { dir, err := os.ReadDir(c.Path) if err != nil { if os.IsNotExist(err) { - os.Mkdir(c.Path, 0700) + try(os.Mkdir(c.Path, 0700)) continue } println(err.Error()) @@ -128,11 +139,13 @@ func InitCacheSystem() { if c.Lifetime != "" { now := time.Now().UnixMilli() - stat := fileInfo.Sys().(*syscall.Stat_t) - time := statTime(stat) - - if time+lifetimeParsed <= now { - try(os.RemoveAll(fileName)) + // Sys() is platform-specific and only documented to be a + // *syscall.Stat_t on unix; skip rotation rather than panic + // if the filesystem reports something else. + if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok { + if statTime(stat)+lifetimeParsed <= now { + try(os.RemoveAll(fileName)) + } } } @@ -144,7 +157,7 @@ func InitCacheSystem() { if c.MaxSize != 0 && total > c.MaxSize { try(os.RemoveAll(c.Path)) - os.Mkdir(c.Path, 0700) + try(os.Mkdir(c.Path, 0700)) } time.Sleep(time.Second * time.Duration(c.UpdateInterval)) -- cgit v1.2.3