Skip to content
quarry Artifact [f65100298c]
Login

Artifact [f65100298c]

Artifact f65100298c549dd5302a3271285fdf6d5f4ca516c7849094c8f5e32d3525703a:


package web

import (
	"context"
	"encoding/base64"
	"net/http/httptest"
	"strings"
	"testing"

	"craftdesign.group/quarry/internal/ops"
)

type viewIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Hash string `json:"hash" validate:"required,min=1,max=64"`
}

type viewOut struct {
	Name    string `json:"name"`
	Size    int    `json:"size"`
	Content string `json:"content"`
}

// viewRegistry answers attachment.get with bytes the test chose, so the
// route's own decision — sniff, then serve or refuse — is what is under
// test rather than a store.
func viewRegistry(t *testing.T, files map[string][]byte) *ops.Registry {
	t.Helper()
	r := ops.NewRegistry()
	if err := ops.Register(r, ops.Op[viewIn, viewOut]{
		Name:  "attachment.get",
		Caps:  "jr",
		Doc:   "test stub",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/attachment/{hash}",
		Run: func(_ context.Context, in viewIn) (viewOut, error) {
			raw, ok := files[in.Hash]
			if !ok {
				return viewOut{}, ops.ErrNotFound
			}
			return viewOut{Name: in.Hash + ".bin", Size: len(raw),
				Content: base64.StdEncoding.EncodeToString(raw)}, nil
		},
	}); err != nil {
		t.Fatal(err)
	}
	// The not-found page carries the ordinary chrome, which reads the
	// version through the registry like every other page.
	if err := ops.Register(r, ops.Op[struct{}, struct {
		Version string `json:"version"`
	}]{
		Name:  "meta.version",
		Doc:   "test stub",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/version",
		Run: func(context.Context, struct{}) (struct {
			Version string `json:"version"`
		}, error) {
			return struct {
				Version string `json:"version"`
			}{Version: "t"}, nil
		},
	}); err != nil {
		t.Fatal(err)
	}
	return r
}

// A minimal PNG: the signature is what the route may decide from, and
// nothing else about the file is.
var pngBytes = append([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, []byte("IHDRrest")...)

func TestAttachmentViewServesSniffedImageInline(t *testing.T) {
	r := viewRegistry(t, map[string][]byte{"png1": pngBytes})
	rec := httptest.NewRecorder()
	Handler(r, nil, nil).ServeHTTP(rec,
		httptest.NewRequest("GET", "/r/test/attachment/png1/view", nil))
	if rec.Code != 200 {
		t.Fatalf("FAIL: status %d", rec.Code)
	}
	h := rec.Header()
	if h.Get("Content-Type") != "image/png" {
		t.Errorf("FAIL: content-type %q", h.Get("Content-Type"))
	}
	if h.Get("X-Content-Type-Options") != "nosniff" {
		t.Errorf("FAIL: nosniff missing")
	}
	if h.Get("Content-Security-Policy") != "default-src 'none'; sandbox" {
		t.Errorf("FAIL: csp %q", h.Get("Content-Security-Policy"))
	}
	if !strings.HasPrefix(h.Get("Content-Disposition"), "inline; filename*=UTF-8''") {
		t.Errorf("FAIL: disposition %q", h.Get("Content-Disposition"))
	}
	if h.Get("Cache-Control") != "public, max-age=31536000, immutable" {
		t.Errorf("FAIL: cache-control %q", h.Get("Cache-Control"))
	}
	if rec.Body.Len() != len(pngBytes) {
		t.Errorf("FAIL: served %d bytes, want %d", rec.Body.Len(), len(pngBytes))
	}
	t.Log("ok: a sniffed PNG is served inline under a denying CSP")
}

// The allowlist is by MAGIC BYTES. An SVG is XML however it is named or
// labelled, so it can never reach this route's inline path — which is
// the whole reason the route does not read the stored type.
func TestAttachmentViewRefusesNonImages(t *testing.T) {
	r := viewRegistry(t, map[string][]byte{
		"svg1":  []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`),
		"html1": []byte("<!doctype html><script>alert(1)</script>"),
		"short": []byte{0x89},
	})
	for _, hash := range []string{"svg1", "html1", "short", "missing"} {
		rec := httptest.NewRecorder()
		Handler(r, nil, nil).ServeHTTP(rec,
			httptest.NewRequest("GET", "/r/test/attachment/"+hash+"/view", nil))
		if rec.Code != 404 {
			t.Errorf("FAIL: %s answered %d, want 404", hash, rec.Code)
		}
	}
	t.Log("ok: anything that does not sniff as an allowed image is 404, SVG included")
}

// The other three formats on the allowlist, each by its own signature.
func TestAttachmentViewAllowlist(t *testing.T) {
	files := map[string][]byte{
		"jpg1":  append([]byte{0xff, 0xd8, 0xff, 0xe0}, []byte("0000JFIF")...),
		"gif1":  []byte("GIF89a............"),
		"webp1": append([]byte("RIFF....WEBPVP8 "), []byte("rest")...),
	}
	want := map[string]string{"jpg1": "image/jpeg", "gif1": "image/gif", "webp1": "image/webp"}
	r := viewRegistry(t, files)
	for hash, ctype := range want {
		rec := httptest.NewRecorder()
		Handler(r, nil, nil).ServeHTTP(rec,
			httptest.NewRequest("GET", "/r/test/attachment/"+hash+"/view", nil))
		if rec.Code != 200 || rec.Header().Get("Content-Type") != ctype {
			t.Errorf("FAIL: %s answered %d %q, want 200 %q",
				hash, rec.Code, rec.Header().Get("Content-Type"), ctype)
		}
	}
	t.Log("ok: PNG, JPEG, GIF and WebP each sniff to their own type")
}