Commit 7eb5e5e223

7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b

parent: 40d405f318

Verified · cmc

cmc <hello@cleberg.net> · 2026-07-15 07:24 UTC

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.

Layout: unified · split

app/api.go +20 −11
@@ -9,6 +9,8 @@ import (
9 "github.com/zerolabsco/devianter" 9 "github.com/zerolabsco/devianter"
10) 10)
11 11
12// API serves the JSON endpoints under /api, backed by the request its main
13// field points at.
12type API struct { 14type API struct {
13 main *skunkyart 15 main *skunkyart
14} 16}
@@ -18,6 +20,7 @@ type info struct {
18 Settings settingsParams `json:"settings"` 20 Settings settingsParams `json:"settings"`
19} 21}
20 22
23// Info responds with this instance's version and its proxy/NSFW settings.
21func (a API) Info() { 24func (a API) Info() {
22 json, err := json.Marshal(info{ 25 json, err := json.Marshal(info{
23 Version: a.main.Version, 26 Version: a.main.Version,
@@ -27,9 +30,10 @@ func (a API) Info() {
27 }, 30 },
28 }) 31 })
29 try(err) 32 try(err)
30 a.main.Writer.Write(json) 33 _, _ = a.main.Writer.Write(json)
31} 34}
32 35
36// Error responds with a JSON error body and the given HTTP status.
33func (a API) Error(description string, status int) { 37func (a API) Error(description string, status int) {
34 a.main.Writer.WriteHeader(status) 38 a.main.Writer.WriteHeader(status)
35 var response strings.Builder 39 var response strings.Builder
@@ -40,33 +44,38 @@ func (a API) Error(description string, status int) {
40} 44}
41 45
42func (a API) sendMedia(d *devianter.Deviation) { 46func (a API) sendMedia(d *devianter.Deviation) {
43 mediaUrl, name := devianter.UrlFromMedia(d.Media) 47 mediaURL, name := devianter.UrlFromMedia(d.Media)
44 a.main.SetFilename(name) 48 a.main.SetFilename(name)
45 if len(mediaUrl) != 0 { 49 if len(mediaURL) != 0 {
46 return 50 return
47 } 51 }
48 52
49 if CFG.Proxy { 53 if CFG.Proxy {
50 mediaUrl = mediaUrl[21:] 54 mediaURL = mediaURL[21:]
51 dot := strings.Index(mediaUrl, ".") 55 dot := strings.Index(mediaURL, ".")
52 a.main.Writer.Header().Del("Content-Type") 56 a.main.Writer.Header().Del("Content-Type")
53 a.main.DownloadAndSendMedia(mediaUrl[:dot], mediaUrl[dot+11:]) 57 a.main.DownloadAndSendMedia(mediaURL[:dot], mediaURL[dot+11:])
54 } else { 58 } else {
55 a.main.Writer.Header().Add("Location", mediaUrl) 59 a.main.Writer.Header().Add("Location", mediaURL)
56 a.main.Writer.WriteHeader(302) 60 a.main.Writer.WriteHeader(302)
57 } 61 }
58} 62}
59 63
60// TODO: add filters 64// Random responds with a random artwork's media, retrying a bounded number of
65// times when a search comes back empty or NSFW-filtered.
66//
67// TODO: add filters.
61func (a API) Random() { 68func (a API) Random() {
62 // Bounded retries: the loop used to be unbounded, and the DeviantArt-error 69 // Bounded retries: the loop used to be unbounded, and the DeviantArt-error
63 // path never incremented attempt, so a single request could spin forever 70 // path never incremented attempt, so a single request could spin forever
64 // hammering the API (and get this instance's egress IP banned). 71 // hammering the API (and get this instance's egress IP banned).
65 const maxAttempts = 3 72 const maxAttempts = 3
66 73
67 for attempt := 0; attempt < maxAttempts; attempt++ { 74 // math/rand is deliberate: this picks a random artwork to show, which is not
75 // a security decision and does not need a cryptographic source.
76 for range maxAttempts {
68 // strconv.Itoa, not string(): string(65) is "A", not "65". 77 // strconv.Itoa, not string(): string(65) is "A", not "65".
69 s, daErr, err := devianter.PerformSearch(strconv.Itoa(rand.Intn(999)), rand.Intn(30), 'a') 78 s, daErr, err := devianter.PerformSearch(strconv.Itoa(rand.Intn(999)), rand.Intn(30), 'a') //nolint:gosec // G404
70 try(err) 79 try(err)
71 if daErr.RAW != nil { 80 if daErr.RAW != nil {
72 continue 81 continue
@@ -77,7 +86,7 @@ func (a API) Random() {
77 continue 86 continue
78 } 87 }
79 88
80 deviation := &s.Results[rand.Intn(len(s.Results))] 89 deviation := &s.Results[rand.Intn(len(s.Results))] //nolint:gosec // G404: see above
81 if deviation.NSFW && !CFG.Nsfw { 90 if deviation.NSFW && !CFG.Nsfw {
82 continue 91 continue
83 } 92 }
app/cache.go +27 −14
@@ -1,8 +1,9 @@
1// TODO: implement JSON caching and clean up the code
2package app 1package app
3 2
3// TODO: implement JSON caching and clean up the code.
4
4import ( 5import (
5 "crypto/sha1" 6 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive
6 "encoding/hex" 7 "encoding/hex"
7 "io" 8 "io"
8 "os" 9 "os"
@@ -20,6 +21,9 @@ type file struct {
20var tempFS = make(map[[20]byte]*file) 21var tempFS = make(map[[20]byte]*file)
21var mx = &sync.RWMutex{} 22var mx = &sync.RWMutex{}
22 23
24// DownloadAndSendMedia proxies one image from DeviantArt's wixmp CDN to the
25// client, serving it from the on-disk or in-memory cache when enabled. It
26// responds 403 when proxying is turned off for this instance.
23func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { 27func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
24 var url strings.Builder 28 var url strings.Builder
25 url.WriteString("https://images-wixmp-") 29 url.WriteString("https://images-wixmp-")
@@ -35,20 +39,24 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
35 39
36 switch { 40 switch {
37 case CFG.Cache.Enabled: 41 case CFG.Cache.Enabled:
38 fileName := sha1.Sum([]byte(subdomain + path)) 42 fileName := sha1.Sum([]byte(subdomain + path)) //nolint:gosec // G401: cache-key hash, not a security primitive
39 filePath := CFG.Cache.Path + "/" + hex.EncodeToString(fileName[:]) 43 filePath := CFG.Cache.Path + "/" + hex.EncodeToString(fileName[:])
40 44
41 c := func() { 45 c := func() {
42 file, err := os.Open(filePath) 46 // filePath is built from a SHA-1 of the request, not from user input,
47 // so it cannot escape the cache directory.
48 file, err := os.Open(filePath) //nolint:gosec // G304: path is a hash, not user-controlled
43 if err != nil { 49 if err != nil {
44 if dwnld := Download(url.String()); dwnld.Status == 200 && dwnld.Headers["Content-Type"][0][:5] == "image" { 50 dwnld := Download(url.String())
51 if dwnld.Status == 200 && strings.HasPrefix(dwnld.Headers.Get("Content-Type"), "image") {
45 response = dwnld.Body 52 response = dwnld.Body
46 try(os.WriteFile(filePath, response, 0700)) 53 try(os.WriteFile(filePath, response, 0600))
47 } else { 54 } else {
48 s.ReturnHTTPError(dwnld.Status) 55 s.ReturnHTTPError(dwnld.Status)
49 return 56 return
50 } 57 }
51 } else { 58 } else {
59 defer func() { try(file.Close()) }()
52 file, e := io.ReadAll(file) 60 file, e := io.ReadAll(file)
53 try(e) 61 try(e)
54 response = file 62 response = file
@@ -104,16 +112,19 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
104 response = []byte("Sorry, butt proxy on this instance are disabled.") 112 response = []byte("Sorry, butt proxy on this instance are disabled.")
105 } 113 }
106 114
107 s.Writer.Write(response) 115 _, _ = s.Writer.Write(response)
108} 116}
109 117
118// InitCacheSystem runs the cache rotation loop forever, evicting files past
119// their lifetime and emptying the cache when it outgrows max-size. Run it in its
120// own goroutine.
110func InitCacheSystem() { 121func InitCacheSystem() {
111 c := &CFG.Cache 122 c := &CFG.Cache
112 for { 123 for {
113 dir, err := os.ReadDir(c.Path) 124 dir, err := os.ReadDir(c.Path)
114 if err != nil { 125 if err != nil {
115 if os.IsNotExist(err) { 126 if os.IsNotExist(err) {
116 os.Mkdir(c.Path, 0700) 127 try(os.Mkdir(c.Path, 0700))
117 continue 128 continue
118 } 129 }
119 println(err.Error()) 130 println(err.Error())
@@ -128,11 +139,13 @@ func InitCacheSystem() {
128 if c.Lifetime != "" { 139 if c.Lifetime != "" {
129 now := time.Now().UnixMilli() 140 now := time.Now().UnixMilli()
130 141
131 stat := fileInfo.Sys().(*syscall.Stat_t) 142 // Sys() is platform-specific and only documented to be a
132 time := statTime(stat) 143 // *syscall.Stat_t on unix; skip rotation rather than panic
133 144 // if the filesystem reports something else.
134 if time+lifetimeParsed <= now { 145 if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
135 try(os.RemoveAll(fileName)) 146 if statTime(stat)+lifetimeParsed <= now {
147 try(os.RemoveAll(fileName))
148 }
136 } 149 }
137 } 150 }
138 151
@@ -144,7 +157,7 @@ func InitCacheSystem() {
144 157
145 if c.MaxSize != 0 && total > c.MaxSize { 158 if c.MaxSize != 0 && total > c.MaxSize {
146 try(os.RemoveAll(c.Path)) 159 try(os.RemoveAll(c.Path))
147 os.Mkdir(c.Path, 0700) 160 try(os.Mkdir(c.Path, 0700))
148 } 161 }
149 162
150 time.Sleep(time.Second * time.Duration(c.UpdateInterval)) 163 time.Sleep(time.Second * time.Duration(c.UpdateInterval))
app/cli.go +23 −10
@@ -9,6 +9,9 @@ import (
9 "time" 9 "time"
10) 10)
11 11
12// ExecuteCommandLineArguments parses argv, applying the flags that override
13// config and running one-shot commands such as --help and --add-instance. Some
14// of those commands exit the process rather than return.
12func ExecuteCommandLineArguments() { 15func ExecuteCommandLineArguments() {
13 var helpmsg = `SkunkyArt v{{.Version}} [{{.Description}}] 16 var helpmsg = `SkunkyArt v{{.Version}} [{{.Description}}]
14Usage: 17Usage:
@@ -31,8 +34,12 @@ Copyright lost+skunk, X11. https://github.com/zerolabsco/skunky-art/releases/tag
31 case "-h", "--help": 34 case "-h", "--help":
32 var buf bytes.Buffer 35 var buf bytes.Buffer
33 t := template.New("help") 36 t := template.New("help")
34 t.Parse(helpmsg) 37 tryWithExitStatus(func() error {
35 t.Execute(&buf, &Release) 38 if _, err := t.Parse(helpmsg); err != nil {
39 return err
40 }
41 return t.Execute(&buf, &Release)
42 }(), 1)
36 exit(buf.String(), 0) 43 exit(buf.String(), 0)
37 case "-a", "--add-instance": 44 case "-a", "--add-instance":
38 addInstance() 45 addInstance()
@@ -79,13 +86,19 @@ func addInstance() {
79 var settingsVar struct { 86 var settingsVar struct {
80 Instances []settings `json:"instances"` 87 Instances []settings `json:"instances"`
81 } 88 }
82 instancesJson, err := os.OpenFile("instances.json", os.O_CREATE|os.O_WRONLY, 0644) 89 // 0644: both files are committed to the repository and are meant to be
83 try(err) 90 // world-readable, so gosec's 0600 default does not apply.
84 defer instancesJson.Close() 91 instancesJSON, err := os.OpenFile("instances.json", os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec // G302
92 if err != nil {
93 exit(err.Error(), 1)
94 }
95 defer func() { try(instancesJSON.Close()) }()
85 96
86 instancesFile, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 97 instancesFile, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec // G302
87 try(err) 98 if err != nil {
88 defer instancesFile.Close() 99 exit(err.Error(), 1)
100 }
101 defer func() { try(instancesFile.Close()) }()
89 102
90 for { 103 for {
91 if string(instances) == "" { 104 if string(instances) == "" {
@@ -113,7 +126,7 @@ func addInstance() {
113 j, err := json.MarshalIndent(&settingsVar, "", " ") 126 j, err := json.MarshalIndent(&settingsVar, "", " ")
114 try(err) 127 try(err)
115 128
116 instancesJson.Write(j) 129 try(func() error { _, err := instancesJSON.Write(j); return err }())
117 130
118 settingsVar := &settingsVar.Instances[len(settingsVar.Instances)-1] 131 settingsVar := &settingsVar.Instances[len(settingsVar.Instances)-1]
119 var mdstr bytes.Buffer 132 var mdstr bytes.Buffer
@@ -157,7 +170,7 @@ func addInstance() {
157 mdstr.WriteString(settingsVar.Country) 170 mdstr.WriteString(settingsVar.Country)
158 mdstr.WriteString("|") 171 mdstr.WriteString("|")
159 172
160 instancesFile.Write(mdstr.Bytes()) 173 try(func() error { _, err := instancesFile.Write(mdstr.Bytes()); return err }())
161 break 174 break
162 } 175 }
163 time.Sleep(500 * time.Millisecond) 176 time.Sleep(500 * time.Millisecond)
app/httpclient.go +49 −9
@@ -2,6 +2,7 @@ package app
2 2
3import ( 3import (
4 "net/http" 4 "net/http"
5 "net/url"
5 "strings" 6 "strings"
6 "sync" 7 "sync"
7 "time" 8 "time"
@@ -23,6 +24,10 @@ var (
23 daMaxConcurrent = 2 // max simultaneous in-flight DA requests 24 daMaxConcurrent = 2 // max simultaneous in-flight DA requests
24) 25)
25 26
27// downloadTimeout bounds a single outbound fetch end to end, so that a stalled
28// CDN connection cannot pin a request handler open indefinitely.
29const downloadTimeout = 60 * time.Second
30
26type daThrottle struct { 31type daThrottle struct {
27 base http.RoundTripper 32 base http.RoundTripper
28 sem chan struct{} 33 sem chan struct{}
@@ -30,6 +35,8 @@ type daThrottle struct {
30 last time.Time 35 last time.Time
31} 36}
32 37
38// RoundTrip applies the rate and concurrency limits to DeviantArt requests and
39// passes everything else straight through to the base transport.
33func (t *daThrottle) RoundTrip(req *http.Request) (*http.Response, error) { 40func (t *daThrottle) RoundTrip(req *http.Request) (*http.Response, error) {
34 // Only throttle DeviantArt's WAF-protected API host; let everything else fly. 41 // Only throttle DeviantArt's WAF-protected API host; let everything else fly.
35 if !strings.Contains(req.URL.Hostname(), "deviantart.com") { 42 if !strings.Contains(req.URL.Hostname(), "deviantart.com") {
@@ -51,18 +58,51 @@ func (t *daThrottle) RoundTrip(req *http.Request) (*http.Response, error) {
51 return t.base.RoundTrip(req) 58 return t.base.RoundTrip(req)
52} 59}
53 60
61// baseTransport is the tuned transport installed by InstallDAThrottle, kept so
62// that per-client transports (see ProxiedTransport) inherit the same timeouts
63// instead of silently bypassing them.
64var baseTransport *http.Transport
65
66// tunedTransport clones the current default transport, preserving its Proxy
67// (ProxyFromEnvironment) and connection-pool defaults, and tightens timeouts to
68// bound hung connections.
69func tunedTransport() *http.Transport {
70 base, ok := http.DefaultTransport.(*http.Transport)
71 if !ok {
72 // Already wrapped, or a non-standard transport is installed. Start from a
73 // fresh one rather than panicking on a type assertion.
74 base = &http.Transport{Proxy: http.ProxyFromEnvironment}
75 }
76
77 t := base.Clone()
78 t.TLSHandshakeTimeout = 10 * time.Second
79 t.ResponseHeaderTimeout = 20 * time.Second
80 t.ExpectContinueTimeout = 2 * time.Second
81 return t
82}
83
54// InstallDAThrottle wraps http.DefaultTransport with the rate/concurrency limits and 84// InstallDAThrottle wraps http.DefaultTransport with the rate/concurrency limits and
55// timeouts above. Call once at startup, before any DeviantArt request is made. 85// timeouts above. Call once at startup, before any DeviantArt request is made.
56func InstallDAThrottle() { 86func InstallDAThrottle() {
57 // Clone the default transport so we keep its Proxy (ProxyFromEnvironment) and 87 baseTransport = tunedTransport()
58 // connection-pool defaults, then tighten timeouts to bound hung connections. 88 http.DefaultTransport = throttled(baseTransport)
59 base := http.DefaultTransport.(*http.Transport).Clone() 89}
60 base.TLSHandshakeTimeout = 10 * time.Second 90
61 base.ResponseHeaderTimeout = 20 * time.Second 91// throttled wraps base with the DeviantArt rate and concurrency limits.
62 base.ExpectContinueTimeout = 2 * time.Second 92func throttled(base http.RoundTripper) http.RoundTripper {
93 return &daThrottle{base: base, sem: make(chan struct{}, daMaxConcurrent)}
94}
63 95
64 http.DefaultTransport = &daThrottle{ 96// ProxiedTransport returns a throttled transport routing through proxy. Downloads
65 base: base, 97// configured with download-proxy go through here so they keep the timeouts and
66 sem: make(chan struct{}, daMaxConcurrent), 98// limits that InstallDAThrottle installs on the default transport.
99func ProxiedTransport(proxy *url.URL) http.RoundTripper {
100 var base *http.Transport
101 if baseTransport != nil {
102 base = baseTransport.Clone()
103 } else {
104 base = tunedTransport()
67 } 105 }
106 base.Proxy = http.ProxyURL(proxy)
107 return throttled(base)
68} 108}
app/router.go +34 −11
@@ -7,10 +7,15 @@ import (
7 "skunkyart/static" 7 "skunkyart/static"
8 "strconv" 8 "strconv"
9 "strings" 9 "strings"
10 "time"
10) 11)
11 12
13// Host is the scheme and host that generated links are built from. It is set per
14// request from the Host header and X-Forwarded-Proto.
12var Host string 15var Host string
13 16
17// Router registers the single catch-all handler that dispatches every path, then
18// serves until the process exits. It does not return on success.
14func Router() { 19func Router() {
15 parsepath := func(path string) map[int]string { 20 parsepath := func(path string) map[int]string {
16 if l := len(CFG.URI); len(path) > l { 21 if l := len(CFG.URI); len(path) > l {
@@ -33,22 +38,30 @@ func Router() {
33 return parsedpath 38 return parsedpath
34 } 39 }
35 40
36 next := func(path map[int]string, from int) (out string) { 41 next := func(path map[int]string, from int) string {
42 var out strings.Builder
37 for x, l := from, len(path)-1; x <= l; x++ { 43 for x, l := from, len(path)-1; x <= l; x++ {
38 out += path[x] 44 out.WriteString(path[x])
39 if x != l { 45 if x != l {
40 out += "/" 46 out.WriteString("/")
41 } 47 }
42 } 48 }
43 return 49 return out.String()
44 } 50 }
45 51
46 open := func(name string) []byte { 52 open := func(name string) []byte {
47 file, err := static.Templates.Open(name) 53 file, err := static.Templates.Open(name)
48 try(err) 54 if err != nil {
49 fileReaded, err := io.ReadAll(file) 55 try(err)
50 try(err) 56 return nil
57 }
58 defer func() { try(file.Close()) }()
51 59
60 fileReaded, err := io.ReadAll(file)
61 if err != nil {
62 try(err)
63 return nil
64 }
52 return fileReaded 65 return fileReaded
53 } 66 }
54 67
@@ -119,10 +132,10 @@ func Router() {
119 skunky.Emojitar(path[3]) 132 skunky.Emojitar(path[3])
120 } 133 }
121 case "stylesheet": 134 case "stylesheet":
122 w.Header().Add("content-type", "text/css") 135 w.Header().Add("Content-Type", "text/css")
123 w.Write(open("css/skunky.css")) 136 _, _ = w.Write(open("css/skunky.css"))
124 case "favicon.ico": 137 case "favicon.ico":
125 w.Write(open("images/logo.png")) 138 _, _ = w.Write(open("images/logo.png"))
126 139
127 // API 140 // API
128 case "api": 141 case "api":
@@ -145,5 +158,15 @@ func Router() {
145 http.HandleFunc("/", handle) 158 http.HandleFunc("/", handle)
146 println("SkunkyArt is listening on", CFG.Listen) 159 println("SkunkyArt is listening on", CFG.Listen)
147 160
148 tryWithExitStatus(http.ListenAndServe(CFG.Listen, nil), 1) 161 // Explicit timeouts: the bare http.ListenAndServe has none, so a slow client
162 // can hold a connection (and its handler) open indefinitely. WriteTimeout is
163 // generous because media proxying streams large files through a handler.
164 srv := &http.Server{
165 Addr: CFG.Listen,
166 ReadHeaderTimeout: 10 * time.Second,
167 ReadTimeout: 30 * time.Second,
168 WriteTimeout: 120 * time.Second,
169 IdleTimeout: 120 * time.Second,
170 }
171 tryWithExitStatus(srv.ListenAndServe(), 1)
149} 172}