Skip to content

Commit 8b03695

Browse files
committed
fix(uiasset): bound release download to 64 MiB; log cache metadata write failures
Review follow-ups (kilo, #766): io.ReadAll on the asset body was unbounded, so a hostile or misconfigured release could exhaust memory — the download now reads at most maxAssetBytes+1 and refuses oversized assets explicitly (a bare LimitReader would silently truncate and, absent a release digest, serve a truncated file). meta.json write failures are now logged instead of dropped.
1 parent ab4ba88 commit 8b03695

3 files changed

Lines changed: 34 additions & 2 deletions

File tree

src/infrastructure/uiasset/cache.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"path/filepath"
88
"strings"
99
"time"
10+
11+
"github.com/sirupsen/logrus"
1012
)
1113

1214
const (
@@ -69,7 +71,9 @@ func (m *Manager) persistMeta(tag, sha, etag string) {
6971
DownloadedAt: time.Now().UTC(),
7072
}
7173
if raw, err := json.MarshalIndent(meta, "", " "); err == nil {
72-
_ = atomicWrite(filepath.Join(m.cfg.CacheDir, metaFileName), raw)
74+
if writeErr := atomicWrite(filepath.Join(m.cfg.CacheDir, metaFileName), raw); writeErr != nil {
75+
logrus.Warnf("[UI_ASSET] cache metadata write failed (ETag/tag lost on restart): %v", writeErr)
76+
}
7377
}
7478
}
7579

src/infrastructure/uiasset/github.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import (
1616
// githubAPIBase is a variable so tests can point it at a local server.
1717
var githubAPIBase = "https://api.github.com"
1818

19+
// maxAssetBytes bounds the release download so a hostile or misconfigured
20+
// release cannot exhaust memory: the dashboard is a single HTML file (~1 MB),
21+
// so 64 MiB is generous headroom. A variable so tests can lower it.
22+
var maxAssetBytes int64 = 64 << 20
23+
1924
type releaseAsset struct {
2025
Name string `json:"name"`
2126
Digest string `json:"digest"` // "sha256:<hex>", may be empty
@@ -131,9 +136,14 @@ func (m *Manager) download(ctx context.Context, url string) ([]byte, string, err
131136
}
132137

133138
hasher := sha256.New()
134-
body, err := io.ReadAll(io.TeeReader(resp.Body, hasher))
139+
// Read one byte past the limit so truncation is detected as an explicit
140+
// error instead of a silently short (and wrongly hashed) asset.
141+
body, err := io.ReadAll(io.LimitReader(io.TeeReader(resp.Body, hasher), maxAssetBytes+1))
135142
if err != nil {
136143
return nil, "", err
137144
}
145+
if int64(len(body)) > maxAssetBytes {
146+
return nil, "", fmt.Errorf("asset exceeds the %d MiB download limit; refusing", maxAssetBytes>>20)
147+
}
138148
return body, hex.EncodeToString(hasher.Sum(nil)), nil
139149
}

src/infrastructure/uiasset/manager_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,24 @@ func TestPinnedSHARejectsTamperedCache(t *testing.T) {
226226
assert.False(t, ok)
227227
}
228228

229+
func TestEnsureLatestRejectsOversizedAsset(t *testing.T) {
230+
previous := maxAssetBytes
231+
maxAssetBytes = 16
232+
t.Cleanup(func() { maxAssetBytes = previous })
233+
234+
fake := newFakeGithub(t, "v1.0.0", []byte("<html>this asset is larger than the limit</html>"))
235+
manager := newTestManager(t, fake)
236+
237+
err := manager.EnsureLatest(context.Background())
238+
require.Error(t, err)
239+
assert.Contains(t, err.Error(), "download limit")
240+
241+
_, _, ok := manager.Content()
242+
assert.False(t, ok, "oversized asset must not be served")
243+
_, statErr := os.Stat(filepath.Join(manager.cfg.CacheDir, "index.html"))
244+
assert.True(t, os.IsNotExist(statErr), "oversized asset must not be cached")
245+
}
246+
229247
func TestFallbackHTMLMentionsRepoAndVersion(t *testing.T) {
230248
page := string(FallbackHTML("v9.0.0", "aldinokemal/gowa-ui"))
231249
assert.Contains(t, page, "v9.0.0")

0 commit comments

Comments
 (0)