docs(storage): pin the script-chunk PNG upload and split the uploads deploy note (quick-261028-r16)
- TestPNGScriptChunkNaming (no Docker) and TestStoreScriptChunkPNG (Postgres) pin the todo exploit: a decodable PNG with a script tEXt chunk uploaded in image mode as x.html is stored and served as .png, bytes unchanged - storage deploy note separates static servers (nosniff, nginx extension allow-list, 404 otherwise) from public buckets, which send the stored sniffed Content-Type; attachments.md points to it
This commit is contained in:
@@ -4,11 +4,14 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -29,6 +32,32 @@ func smokePNG(t *testing.T) []byte {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// scriptText is the payload of the stored-XSS todo: a page that reads the
|
||||
// application's cookies if a browser renders the upload as HTML.
|
||||
const scriptText = "<script>alert(document.cookie)</script>"
|
||||
|
||||
// pngWithScriptText returns a real, decodable PNG that carries scriptText in
|
||||
// a tEXt chunk right after IHDR. It passes attach.IsAllowedImage, which is
|
||||
// why the disk name, not the image guard, has to keep it from being served
|
||||
// as HTML.
|
||||
func pngWithScriptText(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
base := smokePNG(t)
|
||||
// 8-byte signature, then IHDR: 4 length, 4 type, 13 data, 4 CRC.
|
||||
const split = 8 + 4 + 4 + 13 + 4
|
||||
if len(base) < split || string(base[12:16]) != "IHDR" {
|
||||
t.Fatalf("unexpected PNG layout: % x", base[:min(len(base), 16)])
|
||||
}
|
||||
data := append([]byte("Comment\x00"), scriptText...)
|
||||
chunk := binary.BigEndian.AppendUint32(nil, uint32(len(data)))
|
||||
typed := append([]byte("tEXt"), data...)
|
||||
chunk = append(chunk, typed...)
|
||||
chunk = binary.BigEndian.AppendUint32(chunk, crc32.ChecksumIEEE(typed))
|
||||
out := append([]byte{}, base[:split]...)
|
||||
out = append(out, chunk...)
|
||||
return append(out, base[split:]...)
|
||||
}
|
||||
|
||||
func bucketKeys(t *testing.T, bucket *blob.Bucket) []string {
|
||||
t.Helper()
|
||||
var keys []string
|
||||
@@ -345,6 +374,69 @@ func TestStoreDiskExtensionSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPNGScriptChunkNaming pins the naming half of the stored-XSS todo
|
||||
// without Docker: a write-scoped client uploads a valid image under an .html
|
||||
// name. The PNG with a script tEXt chunk decodes, passes attach.IsAllowedImage
|
||||
// and sniffs as image/png, so attach.DiskExtension names it .png, not .html.
|
||||
func TestPNGScriptChunkNaming(t *testing.T) {
|
||||
fixture := pngWithScriptText(t)
|
||||
if _, err := png.Decode(bytes.NewReader(fixture)); err != nil {
|
||||
t.Fatalf("fixture does not decode: %v", err)
|
||||
}
|
||||
if !attach.IsAllowedImage(fixture) {
|
||||
t.Fatal("fixture is refused by IsAllowedImage")
|
||||
}
|
||||
if !bytes.Contains(fixture, []byte(scriptText)) {
|
||||
t.Fatal("fixture lost the script payload")
|
||||
}
|
||||
ct := http.DetectContentType(fixture)
|
||||
if ct != "image/png" {
|
||||
t.Fatalf("sniffed %q, want image/png", ct)
|
||||
}
|
||||
if got := attach.DiskExtension(ct, "html"); got != "png" {
|
||||
t.Fatalf("DiskExtension(%q, html) = %q, want png", ct, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreScriptChunkPNG runs the stored-XSS todo scenario through Store on
|
||||
// Postgres: a write-scoped client uploads a valid image under an .html name
|
||||
// in image mode, with html allowed so the extension check is not what stops
|
||||
// it. The file is stored and served as .png with content_type image/png,
|
||||
// file_name keeps x.html, and the bytes, script chunk included, are stored
|
||||
// as uploaded: only the name makes them harmless.
|
||||
func TestStoreScriptChunkPNG(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("requires testcontainers postgres")
|
||||
}
|
||||
ctx := t.Context()
|
||||
gdb := attachGorm(t)
|
||||
if err := lagoon.Migrate(gdb, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := memblob.OpenBucket(nil)
|
||||
t.Cleanup(func() { _ = bucket.Close() })
|
||||
|
||||
fixture := pngWithScriptText(t)
|
||||
f, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: "x.html", Body: bytes.NewReader(fixture), Public: true},
|
||||
attach.Limits{Image: true, Extensions: []string{"png", "html"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(f.DiskName, ".png") || !strings.HasSuffix(f.URL(), ".png") {
|
||||
t.Fatalf("disk name %q, URL %q, want .png", f.DiskName, f.URL())
|
||||
}
|
||||
if f.FileName != "x.html" || f.ContentType != "image/png" {
|
||||
t.Fatalf("file_name %q, content_type %q, want x.html image/png", f.FileName, f.ContentType)
|
||||
}
|
||||
got, err := bucket.ReadAll(ctx, attach.BlobKey(f.DiskName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, fixture) {
|
||||
t.Fatalf("stored %d bytes differ from the %d uploaded", len(got), len(fixture))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreDiskExtension (plan 05 of phase 19.3, D-15) runs the stored-XSS
|
||||
// regression table through Store on Postgres: the disk name and the URL end
|
||||
// in the extension DiskExtension picks for the sniffed bytes, file_name keeps
|
||||
|
||||
Reference in New Issue
Block a user