check / check (pull_request) Successful in 1m22s
Objects fetched from the store are untrusted; several decode paths let one expand or print without limit. - blobgen.LimitReader errors past a byte cap (not io.LimitReader's silent EOF). DecodeManifest reads through caps on both compressed input and decompressed output, far above any real manifest, so json.Decode cannot buffer a compressible bomb. FetchAndDecryptBlob bounds decompression to the blob's recorded uncompressed_size (not the restoring host's blob_size_limit). - downloadSnapshotDB streams straight from storage to its temp file with io.Copy, replacing two ReadAll calls that held the whole database twice. - FetchBlob drops the per-blob Stat round-trip, its expectedSize parameter and returned size, all of which only fed a debug log. - TTYHandler and ui.Writer escape control characters in messages, attribute keys/values, and rendered identifiers/paths before colour codes are applied, so a crafted value cannot drive the terminal. Model: opus-4-8
107 lines
3.4 KiB
Go
107 lines
3.4 KiB
Go
package snapshot
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
|
|
"github.com/klauspost/compress/zstd"
|
|
"sneak.berlin/go/vaultik/internal/blobgen"
|
|
)
|
|
|
|
// Manifest size bounds. A manifest lists one small entry per blob, and
|
|
// blobs are large (the default target is 10 GB), so even a manifest for a
|
|
// petabyte-scale backup is a few megabytes. These caps are far above any
|
|
// manifest the writer can emit, yet stop a crafted, highly compressible
|
|
// manifest from expanding without limit when decoded: the manifest is
|
|
// fetched from the store, which is not trusted, and json.Decode buffers
|
|
// the whole value in memory.
|
|
const (
|
|
manifestMaxCompressed = 256 * 1024 * 1024 // 256 MiB
|
|
manifestMaxDecompressed = 1024 * 1024 * 1024 // 1 GiB
|
|
)
|
|
|
|
// Manifest represents the structure of a snapshot's blob manifest
|
|
//
|
|
//nolint:tagliatelle // snake_case is the established on-disk manifest format
|
|
type Manifest struct {
|
|
SnapshotID string `json:"snapshot_id"`
|
|
Timestamp string `json:"timestamp"`
|
|
BlobCount int `json:"blob_count"`
|
|
TotalCompressedSize int64 `json:"total_compressed_size"`
|
|
Blobs []BlobInfo `json:"blobs"`
|
|
}
|
|
|
|
// BlobInfo represents information about a single blob in the manifest
|
|
//
|
|
//nolint:tagliatelle // snake_case is the established on-disk manifest format
|
|
type BlobInfo struct {
|
|
Hash string `json:"hash"`
|
|
CompressedSize int64 `json:"compressed_size"`
|
|
}
|
|
|
|
// DecodeManifest decodes a manifest from a reader containing compressed
|
|
// JSON, reading through byte limits on both the compressed input and the
|
|
// decompressed output so an untrusted manifest cannot exhaust memory.
|
|
func DecodeManifest(r io.Reader) (*Manifest, error) {
|
|
return decodeManifest(r, manifestMaxCompressed, manifestMaxDecompressed)
|
|
}
|
|
|
|
// decodeManifest is DecodeManifest with explicit limits, so tests can drive
|
|
// the bounds with small inputs instead of gigabyte-scale ones.
|
|
func decodeManifest(
|
|
r io.Reader, maxCompressed, maxDecompressed int64,
|
|
) (*Manifest, error) {
|
|
// Decompress using zstd, bounding how many compressed bytes are read.
|
|
zr, err := zstd.NewReader(blobgen.LimitReader(r, maxCompressed))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating zstd reader: %w", err)
|
|
}
|
|
defer zr.Close()
|
|
|
|
// Decode JSON manifest, bounding how far the compressed input may
|
|
// expand: json.Decode buffers the whole value, so without this a
|
|
// small, highly compressible manifest could expand to gigabytes.
|
|
var manifest Manifest
|
|
|
|
err = json.NewDecoder(blobgen.LimitReader(zr, maxDecompressed)).Decode(&manifest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decoding manifest: %w", err)
|
|
}
|
|
|
|
return &manifest, nil
|
|
}
|
|
|
|
// EncodeManifest encodes a manifest to compressed JSON
|
|
func EncodeManifest(manifest *Manifest, compressionLevel int) ([]byte, error) {
|
|
// Marshal to JSON
|
|
jsonData, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling manifest: %w", err)
|
|
}
|
|
|
|
// Compress using zstd
|
|
var compressedBuf bytes.Buffer
|
|
|
|
writer, err := zstd.NewWriter(&compressedBuf,
|
|
zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(compressionLevel)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating zstd writer: %w", err)
|
|
}
|
|
|
|
_, err = writer.Write(jsonData)
|
|
if err != nil {
|
|
_ = writer.Close()
|
|
|
|
return nil, fmt.Errorf("writing compressed data: %w", err)
|
|
}
|
|
|
|
err = writer.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("closing zstd writer: %w", err)
|
|
}
|
|
|
|
return compressedBuf.Bytes(), nil
|
|
}
|