summaryrefslogtreecommitdiff
path: root/app/cache.go
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-15 02:24:58 -0500
committerChristian Cleberg <[email protected]>2026-07-15 02:24:58 -0500
commit7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b (patch)
tree85e30ad9bf89809e3f2179123239d52bda81f63c /app/cache.go
parent40d405f318a109aad5e7de7ad71fedcc04bb0e86 (diff)
downloadskunky-art-7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b.tar.gz
skunky-art-7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b.tar.bz2
skunky-art-7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b.zip
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.
Diffstat (limited to 'app/cache.go')
-rwxr-xr-xapp/cache.go41
1 files changed, 27 insertions, 14 deletions
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))