Bound download expansion and escape control chars on the terminal (closes #164)
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 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 recorded uncompressed_size (not the restoring host 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
This commit was merged in pull request #197.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
//nolint:testpackage // exercises the unexported decodeManifest bounds
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// testSnapshotID is a stand-in snapshot ID reused across the bound cases.
|
||||
const testSnapshotID = "host_home_2026-01-01T00:00:00Z"
|
||||
|
||||
// TestDecodeManifestRoundTrip is the baseline: with generous bounds a
|
||||
// manifest the writer produced decodes back unchanged.
|
||||
func TestDecodeManifestRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := &Manifest{
|
||||
SnapshotID: testSnapshotID,
|
||||
Timestamp: "2026-01-01T00:00:00Z",
|
||||
BlobCount: 2,
|
||||
TotalCompressedSize: 42,
|
||||
Blobs: []BlobInfo{
|
||||
{Hash: "aa", CompressedSize: 21},
|
||||
{Hash: "bb", CompressedSize: 21},
|
||||
},
|
||||
}
|
||||
|
||||
compressed, err := EncodeManifest(want, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := decodeManifest(
|
||||
bytes.NewReader(compressed), manifestMaxCompressed, manifestMaxDecompressed)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
// TestDecodeManifestBoundsDecompressedOutput feeds a valid but highly
|
||||
// compressible manifest — one whose timestamp is a megabyte of the same
|
||||
// character — through a small decompressed bound. The compressed form is
|
||||
// tiny, so only the decompressed bound stops it; decoding must fail within
|
||||
// that bound rather than expanding the value in memory.
|
||||
func TestDecodeManifestBoundsDecompressedOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bomb := &Manifest{
|
||||
SnapshotID: testSnapshotID,
|
||||
Timestamp: strings.Repeat("a", 1<<20),
|
||||
}
|
||||
|
||||
compressed, err := EncodeManifest(bomb, 3)
|
||||
require.NoError(t, err)
|
||||
require.Less(t, len(compressed), 4096,
|
||||
"the compressible manifest must be small compressed")
|
||||
|
||||
_, err = decodeManifest(bytes.NewReader(compressed), 1<<20, 4096)
|
||||
require.ErrorIs(t, err, blobgen.ErrOutputTooLarge)
|
||||
}
|
||||
|
||||
// TestDecodeManifestBoundsCompressedInput checks the compressed-input
|
||||
// bound fires independently: a valid manifest with a generous decompressed
|
||||
// bound but a tiny compressed bound still fails.
|
||||
func TestDecodeManifestBoundsCompressedInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manifest := &Manifest{
|
||||
SnapshotID: testSnapshotID,
|
||||
Timestamp: strings.Repeat("a", 4096),
|
||||
}
|
||||
|
||||
compressed, err := EncodeManifest(manifest, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = decodeManifest(bytes.NewReader(compressed), 8, manifestMaxDecompressed)
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user