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 (
99 "github.com/zerolabsco/devianter"
1010)
1111
12// API serves the JSON endpoints under /api, backed by the request its main
13// field points at.
1214type API struct {
1315 main *skunkyart
1416}
@@ -18,6 +20,7 @@ type info struct {
1820 Settings settingsParams `json:"settings"`
1921}
2022
23// Info responds with this instance's version and its proxy/NSFW settings.
2124func (a API) Info() {
2225 json, err := json.Marshal(info{
2326 Version: a.main.Version,
@@ -27,9 +30,10 @@ func (a API) Info() {
2730 },
2831 })
2932 try(err)
30 a.main.Writer.Write(json)
33 _, _ = a.main.Writer.Write(json)
3134}
3235
36// Error responds with a JSON error body and the given HTTP status.
3337func (a API) Error(description string, status int) {
3438 a.main.Writer.WriteHeader(status)
3539 var response strings.Builder
@@ -40,33 +44,38 @@ func (a API) Error(description string, status int) {
4044}
4145
4246func (a API) sendMedia(d *devianter.Deviation) {
43 mediaUrl, name := devianter.UrlFromMedia(d.Media)
47 mediaURL, name := devianter.UrlFromMedia(d.Media)
4448 a.main.SetFilename(name)
45 if len(mediaUrl) != 0 {
49 if len(mediaURL) != 0 {
4650 return
4751 }
4852
4953 if CFG.Proxy {
50 mediaUrl = mediaUrl[21:]
51 dot := strings.Index(mediaUrl, ".")
54 mediaURL = mediaURL[21:]
55 dot := strings.Index(mediaURL, ".")
5256 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:])
5458 } else {
55 a.main.Writer.Header().Add("Location", mediaUrl)
59 a.main.Writer.Header().Add("Location", mediaURL)
5660 a.main.Writer.WriteHeader(302)
5761 }
5862}
5963
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.
6168func (a API) Random() {
6269 // Bounded retries: the loop used to be unbounded, and the DeviantArt-error
6370 // path never incremented attempt, so a single request could spin forever
6471 // hammering the API (and get this instance's egress IP banned).
6572 const maxAttempts = 3
6673
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 {
6877 // 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
7079 try(err)
7180 if daErr.RAW != nil {
7281 continue
@@ -77,7 +86,7 @@ func (a API) Random() {
7786 continue
7887 }
7988
80 deviation := &s.Results[rand.Intn(len(s.Results))]
89 deviation := &s.Results[rand.Intn(len(s.Results))] //nolint:gosec // G404: see above
8190 if deviation.NSFW && !CFG.Nsfw {
8291 continue
8392 }
app/cache.go +27 −14
@@ -1,8 +1,9 @@
1// TODO: implement JSON caching and clean up the code
21package app
32
3// TODO: implement JSON caching and clean up the code.
4
45import (
5 "crypto/sha1"
6 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive
67 "encoding/hex"
78 "io"
89 "os"
@@ -20,6 +21,9 @@ type file struct {
2021var tempFS = make(map[[20]byte]*file)
2122var mx = &sync.RWMutex{}
2223
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.
2327func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
2428 var url strings.Builder
2529 url.WriteString("https://images-wixmp-")
@@ -35,20 +39,24 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
3539
3640 switch {
3741 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
3943 filePath := CFG.Cache.Path + "/" + hex.EncodeToString(fileName[:])
4044
4145 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
4349 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") {
4552 response = dwnld.Body
46 try(os.WriteFile(filePath, response, 0700))
53 try(os.WriteFile(filePath, response, 0600))
4754 } else {
4855 s.ReturnHTTPError(dwnld.Status)
4956 return
5057 }
5158 } else {
59 defer func() { try(file.Close()) }()
5260 file, e := io.ReadAll(file)
5361 try(e)
5462 response = file
@@ -104,16 +112,19 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
104112 response = []byte("Sorry, butt proxy on this instance are disabled.")
105113 }
106114
107 s.Writer.Write(response)
115 _, _ = s.Writer.Write(response)
108116}
109117
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.
110121func InitCacheSystem() {
111122 c := &CFG.Cache
112123 for {
113124 dir, err := os.ReadDir(c.Path)
114125 if err != nil {
115126 if os.IsNotExist(err) {
116 os.Mkdir(c.Path, 0700)
127 try(os.Mkdir(c.Path, 0700))
117128 continue
118129 }
119130 println(err.Error())
@@ -128,11 +139,13 @@ func InitCacheSystem() {
128139 if c.Lifetime != "" {
129140 now := time.Now().UnixMilli()
130141
131 stat := fileInfo.Sys().(*syscall.Stat_t)
132 time := statTime(stat)
133
134 if time+lifetimeParsed <= now {
135 try(os.RemoveAll(fileName))
142 // Sys() is platform-specific and only documented to be a
143 // *syscall.Stat_t on unix; skip rotation rather than panic
144 // if the filesystem reports something else.
145 if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
146 if statTime(stat)+lifetimeParsed <= now {
147 try(os.RemoveAll(fileName))
148 }
136149 }
137150 }
138151
@@ -144,7 +157,7 @@ func InitCacheSystem() {
144157
145158 if c.MaxSize != 0 && total > c.MaxSize {
146159 try(os.RemoveAll(c.Path))
147 os.Mkdir(c.Path, 0700)
160 try(os.Mkdir(c.Path, 0700))
148161 }
149162
150163 time.Sleep(time.Second * time.Duration(c.UpdateInterval))
app/cli.go +23 −10
@@ -9,6 +9,9 @@ import (
99 "time"
1010)
1111
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.
1215func ExecuteCommandLineArguments() {
1316 var helpmsg = `SkunkyArt v{{.Version}} [{{.Description}}]
1417Usage:
@@ -31,8 +34,12 @@ Copyright lost+skunk, X11. https://github.com/zerolabsco/skunky-art/releases/tag
3134 case "-h", "--help":
3235 var buf bytes.Buffer
3336 t := template.New("help")
34 t.Parse(helpmsg)
35 t.Execute(&buf, &Release)
37 tryWithExitStatus(func() error {
38 if _, err := t.Parse(helpmsg); err != nil {
39 return err
40 }
41 return t.Execute(&buf, &Release)
42 }(), 1)
3643 exit(buf.String(), 0)
3744 case "-a", "--add-instance":
3845 addInstance()
@@ -79,13 +86,19 @@ func addInstance() {
7986 var settingsVar struct {
8087 Instances []settings `json:"instances"`
8188 }
82 instancesJson, err := os.OpenFile("instances.json", os.O_CREATE|os.O_WRONLY, 0644)
83 try(err)
84 defer instancesJson.Close()
89 // 0644: both files are committed to the repository and are meant to be
90 // world-readable, so gosec's 0600 default does not apply.
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()) }()
8596
86 instancesFile, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
87 try(err)
88 defer instancesFile.Close()
97 instancesFile, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec // G302
98 if err != nil {
99 exit(err.Error(), 1)
100 }
101 defer func() { try(instancesFile.Close()) }()
89102
90103 for {
91104 if string(instances) == "" {
@@ -113,7 +126,7 @@ func addInstance() {
113126 j, err := json.MarshalIndent(&settingsVar, "", " ")
114127 try(err)
115128
116 instancesJson.Write(j)
129 try(func() error { _, err := instancesJSON.Write(j); return err }())
117130
118131 settingsVar := &settingsVar.Instances[len(settingsVar.Instances)-1]
119132 var mdstr bytes.Buffer
@@ -157,7 +170,7 @@ func addInstance() {
157170 mdstr.WriteString(settingsVar.Country)
158171 mdstr.WriteString("|")
159172
160 instancesFile.Write(mdstr.Bytes())
173 try(func() error { _, err := instancesFile.Write(mdstr.Bytes()); return err }())
161174 break
162175 }
163176 time.Sleep(500 * time.Millisecond)
app/httpclient.go +49 −9
@@ -2,6 +2,7 @@ package app
22
33import (
44 "net/http"
5 "net/url"
56 "strings"
67 "sync"
78 "time"
@@ -23,6 +24,10 @@ var (
2324 daMaxConcurrent = 2 // max simultaneous in-flight DA requests
2425)
2526
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
2631type daThrottle struct {
2732 base http.RoundTripper
2833 sem chan struct{}
@@ -30,6 +35,8 @@ type daThrottle struct {
3035 last time.Time
3136}
3237
38// RoundTrip applies the rate and concurrency limits to DeviantArt requests and
39// passes everything else straight through to the base transport.
3340func (t *daThrottle) RoundTrip(req *http.Request) (*http.Response, error) {
3441 // Only throttle DeviantArt's WAF-protected API host; let everything else fly.
3542 if !strings.Contains(req.URL.Hostname(), "deviantart.com") {
@@ -51,18 +58,51 @@ func (t *daThrottle) RoundTrip(req *http.Request) (*http.Response, error) {
5158 return t.base.RoundTrip(req)
5259}
5360
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
5484// InstallDAThrottle wraps http.DefaultTransport with the rate/concurrency limits and
5585// timeouts above. Call once at startup, before any DeviantArt request is made.
5686func InstallDAThrottle() {
57 // Clone the default transport so we keep its Proxy (ProxyFromEnvironment) and
58 // connection-pool defaults, then tighten timeouts to bound hung connections.
59 base := http.DefaultTransport.(*http.Transport).Clone()
60 base.TLSHandshakeTimeout = 10 * time.Second
61 base.ResponseHeaderTimeout = 20 * time.Second
62 base.ExpectContinueTimeout = 2 * time.Second
87 baseTransport = tunedTransport()
88 http.DefaultTransport = throttled(baseTransport)
89}
90
91// throttled wraps base with the DeviantArt rate and concurrency limits.
92func throttled(base http.RoundTripper) http.RoundTripper {
93 return &daThrottle{base: base, sem: make(chan struct{}, daMaxConcurrent)}
94}
6395
64 http.DefaultTransport = &daThrottle{
65 base: base,
66 sem: make(chan struct{}, daMaxConcurrent),
96// ProxiedTransport returns a throttled transport routing through proxy. Downloads
97// configured with download-proxy go through here so they keep the timeouts and
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()
67105 }
106 base.Proxy = http.ProxyURL(proxy)
107 return throttled(base)
68108}
app/router.go +34 −11
@@ -7,10 +7,15 @@ import (
77 "skunkyart/static"
88 "strconv"
99 "strings"
10 "time"
1011)
1112
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.
1215var Host string
1316
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.
1419func Router() {
1520 parsepath := func(path string) map[int]string {
1621 if l := len(CFG.URI); len(path) > l {
@@ -33,22 +38,30 @@ func Router() {
3338 return parsedpath
3439 }
3540
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
3743 for x, l := from, len(path)-1; x <= l; x++ {
38 out += path[x]
44 out.WriteString(path[x])
3945 if x != l {
40 out += "/"
46 out.WriteString("/")
4147 }
4248 }
43 return
49 return out.String()
4450 }
4551
4652 open := func(name string) []byte {
4753 file, err := static.Templates.Open(name)
48 try(err)
49 fileReaded, err := io.ReadAll(file)
50 try(err)
54 if err != nil {
55 try(err)
56 return nil
57 }
58 defer func() { try(file.Close()) }()
5159
60 fileReaded, err := io.ReadAll(file)
61 if err != nil {
62 try(err)
63 return nil
64 }
5265 return fileReaded
5366 }
5467
@@ -119,10 +132,10 @@ func Router() {
119132 skunky.Emojitar(path[3])
120133 }
121134 case "stylesheet":
122 w.Header().Add("content-type", "text/css")
123 w.Write(open("css/skunky.css"))
135 w.Header().Add("Content-Type", "text/css")
136 _, _ = w.Write(open("css/skunky.css"))
124137 case "favicon.ico":
125 w.Write(open("images/logo.png"))
138 _, _ = w.Write(open("images/logo.png"))
126139
127140 // API
128141 case "api":
@@ -145,5 +158,15 @@ func Router() {
145158 http.HandleFunc("/", handle)
146159 println("SkunkyArt is listening on", CFG.Listen)
147160
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)
149172}