feat(parser): bound single-file gzip by ratio guard instead of fixed byte cap
Plain gzipped logs (nvidia-bug-report-*.log.gz) routinely exceed the old 50MB decompression cap, which silently dropped the tail. Replace it with a decompression-ratio bomb guard plus a 1GB absolute memory ceiling. See ADL-054. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
632067fef5
commit
9d701885da
+65
-15
@@ -18,7 +18,15 @@ import (
|
||||
const maxSingleFileSize = 10 * 1024 * 1024
|
||||
const maxSingleFileSizeLarge = 1024 * 1024 * 1024
|
||||
const maxZipArchiveSize = 50 * 1024 * 1024
|
||||
const maxGzipDecompressedSize = 50 * 1024 * 1024
|
||||
|
||||
// A plain gzipped log file (e.g. nvidia-bug-report-*.log.gz) is decompressed
|
||||
// fully into memory, but without a fixed byte cap — real bug-report dumps
|
||||
// routinely exceed 50MB and a hard cap silently drops the tail. Instead it's
|
||||
// bounded by a decompression-ratio guard (catches gzip bombs, which achieve
|
||||
// enormous ratios) plus a generous absolute ceiling as a memory safety net.
|
||||
const gzipBombRatio = 300 // decompressed:compressed ratio that trips the bomb guard
|
||||
const gzipBombMinCompressed = 4096 // don't evaluate the ratio until this many compressed bytes have actually been consumed, to avoid false positives from gzip header/buffering overhead on tiny inputs
|
||||
const gzipAbsoluteCeiling = 1024 * 1024 * 1024 // 1GB hard safety net regardless of ratio, matches maxSingleFileSizeLarge
|
||||
|
||||
|
||||
var supportedArchiveExt = map[string]struct{}{
|
||||
@@ -167,7 +175,8 @@ func extractTarFromReader(r io.Reader) ([]ExtractedFile, error) {
|
||||
}
|
||||
|
||||
func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
|
||||
gzr, err := gzip.NewReader(r)
|
||||
compressed := &countingReader{r: r}
|
||||
gzr, err := gzip.NewReader(compressed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip reader: %w", err)
|
||||
}
|
||||
@@ -192,15 +201,12 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
|
||||
return extractTarEntriesWithLimit(buf, maxSingleFileSizeLarge)
|
||||
}
|
||||
|
||||
// Not a tar archive - treat as a single gzipped file. A hard byte cap
|
||||
// is safe here since there's no container structure to corrupt.
|
||||
decompressed, err := io.ReadAll(io.LimitReader(buf, maxGzipDecompressedSize+1))
|
||||
// Not a tar archive - treat as a single gzipped file. No fixed byte cap:
|
||||
// stream the decompression and only stop early on a runaway compression
|
||||
// ratio (gzip bomb) or the absolute memory safety net.
|
||||
decompressed, truncMsg, err := readGzipWithBombGuard(buf, compressed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read gzip content: %w", err)
|
||||
}
|
||||
gzipTruncated := len(decompressed) > maxGzipDecompressedSize
|
||||
if gzipTruncated {
|
||||
decompressed = decompressed[:maxGzipDecompressedSize]
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseName := strings.TrimSuffix(filename, ".gz")
|
||||
@@ -213,17 +219,61 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
|
||||
Content: decompressed,
|
||||
ModTime: gzr.ModTime,
|
||||
}
|
||||
if gzipTruncated {
|
||||
if truncMsg != "" {
|
||||
file.Truncated = true
|
||||
file.TruncatedMessage = fmt.Sprintf(
|
||||
"decompressed gzip content exceeded %d bytes and was truncated",
|
||||
maxGzipDecompressedSize,
|
||||
)
|
||||
file.TruncatedMessage = truncMsg
|
||||
}
|
||||
|
||||
return []ExtractedFile{file}, nil
|
||||
}
|
||||
|
||||
// countingReader tracks the number of bytes read from the underlying reader.
|
||||
type countingReader struct {
|
||||
r io.Reader
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
c.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// readGzipWithBombGuard streams dec (a gzip decompression stream) to
|
||||
// completion, tracking bytes consumed from the compressed source via
|
||||
// compressed. It aborts early — returning what was decompressed so far plus
|
||||
// a non-empty reason — if the decompression ratio suggests a gzip bomb, or
|
||||
// if the absolute ceiling is hit regardless of ratio.
|
||||
func readGzipWithBombGuard(dec io.Reader, compressed *countingReader) ([]byte, string, error) {
|
||||
var out bytes.Buffer
|
||||
chunk := make([]byte, 256*1024)
|
||||
for {
|
||||
n, readErr := dec.Read(chunk)
|
||||
if n > 0 {
|
||||
out.Write(chunk[:n])
|
||||
|
||||
if int64(out.Len()) > gzipAbsoluteCeiling {
|
||||
return out.Bytes(), fmt.Sprintf(
|
||||
"decompressed gzip content exceeded the %d byte safety ceiling and was truncated",
|
||||
gzipAbsoluteCeiling,
|
||||
), nil
|
||||
}
|
||||
if compressed.n >= gzipBombMinCompressed && int64(out.Len()) > compressed.n*gzipBombRatio {
|
||||
return out.Bytes(), fmt.Sprintf(
|
||||
"decompression ratio exceeded %dx (likely a gzip bomb) and was truncated after %d bytes",
|
||||
gzipBombRatio, out.Len(),
|
||||
), nil
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
return out.Bytes(), "", nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, "", fmt.Errorf("read gzip content: %w", readErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractTarEntriesWithLimit streams tar entries from r, skipping individual
|
||||
// files over 10MB, and stops once the cumulative extracted content reaches
|
||||
// totalLimit — always at an entry boundary, never mid-file.
|
||||
|
||||
Reference in New Issue
Block a user