Heavy security and file splitting
CI / Go Tests (push) Successful in 15s
CI / Build (push) Successful in 9s
Format / gofmt (push) Successful in 7s
Release Artifacts / Validate release tag (push) Successful in 2s
Release Artifacts / Build and release executables (push) Successful in 15s
Release Artifacts / Build and release Docker image (push) Successful in 28s

This commit is contained in:
2026-06-01 20:15:28 -05:00
parent bdbe1a9416
commit ad4355df17
27 changed files with 1540 additions and 1166 deletions
+89 -14
View File
@@ -69,16 +69,36 @@ func (s *Server) Routes() http.Handler {
mux.Handle("GET /assets/", http.FileServerFS(webassets.StaticFS()))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(webassets.StaticFS())))
// Special-case well-known paths to return small plain-text responses instead of full SPA 404 shell.
// This addresses low-priority audit item for robots.txt etc.
mux.Handle("GET /robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("User-agent: *\nDisallow: /\n"))
}))
mux.Handle("GET /sitemap.xml", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
}))
// Serve the real favicon (added to eliminate 404 noise). Placed in static root via public/ in Vite build.
mux.Handle("GET /favicon.svg", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, webassets.StaticFS(), "favicon.svg")
}))
mux.Handle("GET /{path...}", http.HandlerFunc(s.notFoundPage))
return AccessLogMiddleware(
accessLogged := AccessLogMiddleware(
s.accessLogger,
s.cfg.Security.TrustProxyHeaders,
s.cfg.Security.TrustedProxyPrefixes,
)(mux)
return SecurityHeadersMiddleware(s.cfg.Security.HSTSEnabled, accessLogged)
}
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
s.serveSPAWithStatus(w, http.StatusOK)
s.serveSPAWithStatus(w, http.StatusOK, nil)
}
func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) {
@@ -91,19 +111,22 @@ func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) notFoundPage(w http.ResponseWriter, _ *http.Request) {
s.serveSPAWithStatus(w, http.StatusNotFound)
s.serveSPAWithStatus(w, http.StatusNotFound, nil)
}
func (s *Server) expiredScratchPage(w http.ResponseWriter) {
s.serveSPAWithStatus(w, http.StatusGone)
s.serveSPAWithStatus(w, http.StatusGone, nil)
}
func (s *Server) serveSPAWithStatus(w http.ResponseWriter, status int) {
func (s *Server) serveSPAWithStatus(w http.ResponseWriter, status int, modifier func([]byte) []byte) {
b, err := fs.ReadFile(webassets.StaticFS(), "index.html")
if err != nil {
http.Error(w, "failed to render page", http.StatusInternalServerError)
return
}
if modifier != nil {
b = modifier(b)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(b)
@@ -117,17 +140,36 @@ func (s *Server) scratchPage(w http.ResponseWriter, r *http.Request) {
}
meta, err := s.store.Get(id)
if err != nil {
s.expiredScratchPage(w)
return
}
if s.isExpired(meta) {
_ = s.store.Delete(id)
s.expiredScratchPage(w)
if err != nil || s.isExpired(meta) {
if err == nil {
_ = s.store.Delete(id)
}
// For /s/{id} 410/expired (including never-existed), inject id/filename based title+meta even on 410 shell.
// This provides good SEO/social for share links and bots (even on 410), per audit rec.
// We use the id (and name if we had meta before delete) for title.
goneTitle := id + " • Scratchbox"
if err == nil && meta.OriginalName != "" {
goneTitle = meta.OriginalName + " • Scratchbox"
}
s.serveSPAWithStatus(w, http.StatusGone, func(b []byte) []byte {
return injectMeta(b, goneTitle, goneTitle, "This scratch is no longer available.")
})
return
}
s.serveSPA(w, r)
// Valid scratch: serve shell with per-scratch title and og meta injected server-side.
// (Client-side <svelte:head> also updates for SPA, but server inj benefits crawlers/JS-disabled.)
title := id + " • Scratchbox"
if meta.OriginalName != "" {
title = meta.OriginalName + " • Scratchbox"
}
desc := "Temporary scratch file shared on Scratchbox."
if meta.OriginalName != "" {
desc = meta.OriginalName + " — temporary file share on Scratchbox."
}
s.serveSPAWithStatus(w, http.StatusOK, func(b []byte) []byte {
return injectMeta(b, title, title, desc)
})
}
func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
@@ -180,7 +222,7 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
}
}
meta, err := s.store.CreateWithOriginalName(r.Context(), reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration)
meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
@@ -332,3 +374,36 @@ func (s *Server) isUploadAllowedForRequest(r *http.Request) bool {
return false
}
// injectMeta performs lightweight server-side injection of <title> and social meta tags
// into the static index.html shell bytes. Uses simple string replace (safe for this tiny known shell,
// no new deps). Generic pages keep the static metas from the HTML template; /s/{id} get dynamic.
func injectMeta(html []byte, pageTitle, ogTitle, ogDescription string) []byte {
if pageTitle == "" {
pageTitle = "Scratchbox"
}
if ogTitle == "" {
ogTitle = pageTitle
}
if ogDescription == "" {
ogDescription = "Minimal temporary file sharing. Upload once, share the link, auto-expires."
}
s := string(html)
// Override <title>
s = strings.Replace(s, "<title>Scratchbox</title>", "<title>"+htmlEscape(pageTitle)+"</title>", 1)
// Override the static og/twitter metas added to the shells (first occurrence only; safe).
s = strings.Replace(s, `<meta property="og:title" content="Scratchbox" />`, `<meta property="og:title" content="`+htmlEscape(ogTitle)+`" />`, 1)
s = strings.Replace(s, `<meta name="twitter:title" content="Scratchbox" />`, `<meta name="twitter:title" content="`+htmlEscape(ogTitle)+`" />`, 1)
s = strings.Replace(s, `<meta name="description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />`, `<meta name="description" content="`+htmlEscape(ogDescription)+`" />`, 1)
s = strings.Replace(s, `<meta property="og:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />`, `<meta property="og:description" content="`+htmlEscape(ogDescription)+`" />`, 1)
s = strings.Replace(s, `<meta name="twitter:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />`, `<meta name="twitter:description" content="`+htmlEscape(ogDescription)+`" />`, 1)
return []byte(s)
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
@@ -0,0 +1,249 @@
package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func TestConcurrentUploadsRemainConsistentAndReadable(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024 * 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
const uploads = 80
type createdScratch struct {
id string
body string
}
resultsCh := make(chan createdScratch, uploads)
errCh := make(chan error, uploads)
var wg sync.WaitGroup
for i := 0; i < uploads; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
body := fmt.Sprintf("parallel-upload-%03d-%s", i, strings.Repeat("x", i%17))
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString(body))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 8200+i)
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
errCh <- fmt.Errorf("create status = %d, want %d", createRec.Code, http.StatusCreated)
return
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
errCh <- fmt.Errorf("decode create payload error: %w", err)
return
}
id, _ := payload["id"].(string)
if id == "" {
errCh <- fmt.Errorf("create payload missing id")
return
}
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
metaRec := httptest.NewRecorder()
handler.ServeHTTP(metaRec, metaReq)
if metaRec.Code != http.StatusOK {
errCh <- fmt.Errorf("metadata status = %d, want %d", metaRec.Code, http.StatusOK)
return
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusOK {
errCh <- fmt.Errorf("raw status = %d, want %d", rawRec.Code, http.StatusOK)
return
}
if got := rawRec.Body.String(); got != body {
errCh <- fmt.Errorf("raw body mismatch = %q, want %q", got, body)
return
}
resultsCh <- createdScratch{id: id, body: body}
}(i)
}
wg.Wait()
close(resultsCh)
close(errCh)
for err := range errCh {
t.Error(err)
}
seen := make(map[string]struct{}, uploads)
for result := range resultsCh {
if _, exists := seen[result.id]; exists {
t.Errorf("duplicate scratch id generated: %s", result.id)
continue
}
seen[result.id] = struct{}{}
}
if len(seen) != uploads {
t.Fatalf("unique IDs = %d, want %d", len(seen), uploads)
}
}
func TestParallelClientsCanReadSameScratchAcrossAllReadRoutes(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
const body = "parallel readers"
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString(body))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = "127.0.0.1:6010"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
paths := []string{
"/api/raw/" + id,
"/s/" + id,
"/api/scratch/" + id,
}
const requests = 120
errCh := make(chan error, requests)
var wg sync.WaitGroup
for i := 0; i < requests; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
path := paths[i%len(paths)]
req := httptest.NewRequest(http.MethodGet, path, nil)
req.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 6100+i)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
errCh <- fmt.Errorf("path %s status = %d, want %d", path, rec.Code, http.StatusOK)
return
}
switch {
case strings.HasPrefix(path, "/api/raw/"):
if got := rec.Body.String(); got != body {
errCh <- fmt.Errorf("raw body = %q, want %q", got, body)
}
case strings.HasPrefix(path, "/s/"):
if !strings.Contains(rec.Body.String(), `id="app"`) {
errCh <- fmt.Errorf("view body missing SPA marker")
}
case strings.HasPrefix(path, "/api/scratch/"):
var gotPayload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &gotPayload); err != nil {
errCh <- fmt.Errorf("metadata decode error: %w", err)
return
}
if gotID, _ := gotPayload["id"].(string); gotID != id {
errCh <- fmt.Errorf("metadata id = %q, want %q", gotID, id)
}
}
}(i)
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Error(err)
}
}
func TestParallelClientsExpiredScratchReadsReturnGone(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: 80 * time.Millisecond,
rateLimitEnable: false,
})
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("expires soon"))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = "127.0.0.1:7010"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
time.Sleep(130 * time.Millisecond)
paths := []string{
"/api/raw/" + id,
"/s/" + id,
"/api/scratch/" + id,
}
const requests = 90
errCh := make(chan error, requests)
var wg sync.WaitGroup
for i := 0; i < requests; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
path := paths[i%len(paths)]
req := httptest.NewRequest(http.MethodGet, path, nil)
req.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 7100+i)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusGone {
errCh <- fmt.Errorf("expired path %s status = %d, want %d", path, rec.Code, http.StatusGone)
return
}
if strings.HasPrefix(path, "/s/") && !strings.Contains(rec.Body.String(), `id="app"`) {
errCh <- fmt.Errorf("expired view body missing SPA marker")
}
}(i)
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Error(err)
}
}
@@ -0,0 +1,375 @@
package httpapi
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCreateReadThenExpireScratch(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: 120 * time.Millisecond,
rateLimitEnable: false,
})
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch"))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = "127.0.0.1:9999"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
metaReq.RemoteAddr = "127.0.0.1:9999"
metaRec := httptest.NewRecorder()
handler.ServeHTTP(metaRec, metaReq)
if metaRec.Code != http.StatusOK {
t.Fatalf("read metadata status = %d, want %d", metaRec.Code, http.StatusOK)
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
rawReq.RemoteAddr = "127.0.0.1:9999"
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusOK {
t.Fatalf("read raw status = %d, want %d", rawRec.Code, http.StatusOK)
}
if got := rawRec.Body.String(); got != "hello scratch" {
t.Fatalf("raw body = %q, want %q", got, "hello scratch")
}
time.Sleep(180 * time.Millisecond)
expiredReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
expiredReq.RemoteAddr = "127.0.0.1:9999"
expiredRec := httptest.NewRecorder()
handler.ServeHTTP(expiredRec, expiredReq)
if expiredRec.Code != http.StatusGone {
t.Fatalf("expired status = %d, want %d", expiredRec.Code, http.StatusGone)
}
}
func TestUploadedScratchViewAndRawBeforeAndAfterExpiry(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: 120 * time.Millisecond,
rateLimitEnable: false,
})
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello from upload flow"))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = "127.0.0.1:5090"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil)
viewReq.RemoteAddr = "127.0.0.1:5091"
viewRec := httptest.NewRecorder()
handler.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view-before-expiry status = %d, want %d", viewRec.Code, http.StatusOK)
}
if !strings.Contains(viewRec.Body.String(), `id="app"`) {
t.Fatalf("expected SPA shell body for /s/{id}, got %q", viewRec.Body.String())
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
rawReq.RemoteAddr = "127.0.0.1:5092"
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusOK {
t.Fatalf("raw-before-expiry status = %d, want %d", rawRec.Code, http.StatusOK)
}
if got := rawRec.Body.String(); got != "hello from upload flow" {
t.Fatalf("raw-before-expiry body = %q, want %q", got, "hello from upload flow")
}
time.Sleep(180 * time.Millisecond)
viewExpiredReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil)
viewExpiredReq.RemoteAddr = "127.0.0.1:5093"
viewExpiredRec := httptest.NewRecorder()
handler.ServeHTTP(viewExpiredRec, viewExpiredReq)
if viewExpiredRec.Code != http.StatusGone {
t.Fatalf("view-after-expiry status = %d, want %d", viewExpiredRec.Code, http.StatusGone)
}
if !strings.Contains(viewExpiredRec.Body.String(), `id="app"`) {
t.Fatalf("expected SPA shell body for expired /s/{id}, got %q", viewExpiredRec.Body.String())
}
rawExpiredReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
rawExpiredReq.RemoteAddr = "127.0.0.1:5094"
rawExpiredRec := httptest.NewRecorder()
handler.ServeHTTP(rawExpiredRec, rawExpiredReq)
if rawExpiredRec.Code != http.StatusGone {
t.Fatalf("raw-after-expiry status = %d, want %d", rawExpiredRec.Code, http.StatusGone)
}
if rawExpiredRec.Body.Len() != 0 {
t.Fatalf("expected empty raw body after expiry, got %q", rawExpiredRec.Body.String())
}
}
func TestGetScratchNotFoundReturnsGone(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
viewReq := httptest.NewRequest(http.MethodGet, "/s/does-not-exist", nil)
viewReq.RemoteAddr = "127.0.0.1:5095"
viewRec := httptest.NewRecorder()
handler.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusGone {
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusGone)
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/does-not-exist", nil)
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusGone {
t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusGone)
}
if rawRec.Body.Len() != 0 {
t.Fatalf("expected empty response body, got %q", rawRec.Body.String())
}
}
func TestViewAndRawUnknownIDsReturnGone(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
viewReq := httptest.NewRequest(http.MethodGet, "/s/never-uploaded-id", nil)
viewReq.RemoteAddr = "127.0.0.1:5095"
viewRec := httptest.NewRecorder()
handler.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusGone {
t.Fatalf("view-never-uploaded status = %d, want %d", viewRec.Code, http.StatusGone)
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/never-uploaded-id", nil)
rawReq.RemoteAddr = "127.0.0.1:5096"
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusGone {
t.Fatalf("raw-never-uploaded status = %d, want %d", rawRec.Code, http.StatusGone)
}
if rawRec.Body.Len() != 0 {
t.Fatalf("expected empty raw body for never-uploaded id, got %q", rawRec.Body.String())
}
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/never-uploaded-id", nil)
metaReq.RemoteAddr = "127.0.0.1:5097"
metaRec := httptest.NewRecorder()
handler.ServeHTTP(metaRec, metaReq)
if metaRec.Code != http.StatusGone {
t.Fatalf("metadata-never-uploaded status = %d, want %d", metaRec.Code, http.StatusGone)
}
}
func TestCreateScratchRejectsMalformedMultipart(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
req := httptest.NewRequest(http.MethodPost, "/api/scratch", strings.NewReader("not-multipart"))
req.Header.Set("Content-Type", "multipart/form-data; boundary=missing")
req.RemoteAddr = "127.0.0.1:5057"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestUnknownPathReturnsNotFoundPage(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
req := httptest.NewRequest(http.MethodGet, "/this-path-does-not-exist", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
if !strings.Contains(rec.Body.String(), `id="app"`) {
t.Fatalf("unexpected not found body: %q", rec.Body.String())
}
}
// TestWellKnownPathsReturnPlainNotSPA verifies low-pri audit fix: robots/sitemap return small
// plain text (no full SPA html shell +404).
func TestWellKnownPathsReturnPlainNotSPA(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
// robots: 200 plain with disallow
req := httptest.NewRequest(http.MethodGet, "/robots.txt", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("/robots status=%d want 200", rec.Code)
}
ct := rec.Header().Get("Content-Type")
if !strings.Contains(ct, "text/plain") {
t.Fatalf("/robots ct=%q want text/plain", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "User-agent: *") || strings.Contains(body, "id=\"app\"") || strings.Contains(body, "<!doctype") {
t.Fatalf("/robots body unexpected (should be small plain disallow, no SPA): %q", body)
}
// sitemap: 404 plain (no html)
req2 := httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil)
rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusNotFound {
t.Fatalf("/sitemap status=%d want 404", rec2.Code)
}
ct2 := rec2.Header().Get("Content-Type")
if !strings.Contains(ct2, "text/plain") {
t.Fatalf("/sitemap ct=%q want text/plain", ct2)
}
body2 := rec2.Body.String()
if strings.Contains(body2, "id=\"app\"") || strings.Contains(body2, "<!doctype") || len(body2) > 100 {
t.Fatalf("/sitemap body unexpected (small plain 404, no SPA): %q", body2)
}
// normal unknown path still gets SPA 404 shell
req3 := httptest.NewRequest(http.MethodGet, "/not-a-wellknown", nil)
rec3 := httptest.NewRecorder()
handler.ServeHTTP(rec3, req3)
if rec3.Code != http.StatusNotFound {
t.Fatalf("generic 404 status=%d want 404", rec3.Code)
}
if !strings.Contains(rec3.Body.String(), `id="app"`) {
t.Fatalf("generic 404 should still be SPA shell")
}
}
func TestFaviconServed(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
req := httptest.NewRequest(http.MethodGet, "/favicon.svg", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("favicon status = %d, want 200", rec.Code)
}
ct := rec.Header().Get("Content-Type")
if !strings.Contains(ct, "image/svg+xml") {
t.Fatalf("favicon ct=%q want image/svg+xml", ct)
}
}
func TestScratchPageServerMetaInjection(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
// create one with filename
var body bytes.Buffer
writer := multipart.NewWriter(&body)
file, _ := writer.CreateFormFile("file", "report.pdf")
file.Write([]byte("pdf-bytes"))
writer.Close()
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
createReq.Header.Set("Content-Type", writer.FormDataContentType())
createReq.RemoteAddr = "127.0.0.1:7777"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d", createRec.Code)
}
var payload map[string]any
json.Unmarshal(createRec.Body.Bytes(), &payload)
id := payload["id"].(string)
// view page should have injected title/meta
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil)
viewRec := httptest.NewRecorder()
handler.ServeHTTP(viewRec, viewReq)
bodyStr := viewRec.Body.String()
if !strings.Contains(bodyStr, "<title>report.pdf • Scratchbox</title>") {
t.Fatalf("expected injected title with filename, got head: %s", bodyStr[:min(800, len(bodyStr))])
}
if !strings.Contains(bodyStr, `property="og:title"`) {
t.Fatalf("expected og:title meta")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,79 @@
package httpapi
import (
"io"
"log"
"net/http"
"path/filepath"
"testing"
"time"
"scratchbox/internal/config"
"scratchbox/internal/storage"
)
var testStorageKey = []byte("0123456789abcdef0123456789abcdef")
type testServerOptions struct {
maxUploadBytes int64
defaultTTL time.Duration
rateLimitEnable bool
}
func newTestHandler(t *testing.T, opts testServerOptions) http.Handler {
t.Helper()
handler, _ := newTestHandlerAndStore(t, opts)
return handler
}
func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler, *storage.FilesystemStore) {
t.Helper()
dataDir := filepath.Join(t.TempDir(), "data")
store, err := storage.NewFilesystemStore(dataDir, testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
cfg := config.Config{
Server: config.ServerConfig{
ListenAddr: ":0",
},
Limits: config.LimitsConfig{
MaxUploadSizeBytes: opts.maxUploadBytes,
DefaultTTLDuration: opts.defaultTTL,
RawCacheMaxBytes: 64 * 1024 * 1024,
RawCacheMaxEntries: 32,
},
Storage: config.StorageConfig{
DataDir: dataDir,
},
Security: config.SecurityConfig{
RateLimitUI: config.RateLimitConfig{
Enabled: opts.rateLimitEnable,
RequestsPerMinute: 120,
Burst: 1,
},
RateLimitAPIRead: config.RateLimitConfig{
Enabled: opts.rateLimitEnable,
RequestsPerMinute: 120,
Burst: 1,
},
RateLimitAPIWrite: config.RateLimitConfig{
Enabled: opts.rateLimitEnable,
RequestsPerMinute: 120,
Burst: 1,
},
HSTSEnabled: true,
},
}
srv := &Server{
cfg: cfg,
store: store,
logger: log.New(io.Discard, "", 0),
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
}
return srv.Routes(), store
}
@@ -0,0 +1,101 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestReadRoutesAreRateLimitedWhenEnabled(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: true,
})
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch"))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = "127.0.0.2:9111"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
firstRawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
firstRawReq.RemoteAddr = "127.0.0.1:9111"
firstRawRec := httptest.NewRecorder()
handler.ServeHTTP(firstRawRec, firstRawReq)
if firstRawRec.Code != http.StatusOK {
t.Fatalf("first raw status = %d, want %d", firstRawRec.Code, http.StatusOK)
}
secondRawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
secondRawReq.RemoteAddr = "127.0.0.1:9111"
secondRawRec := httptest.NewRecorder()
handler.ServeHTTP(secondRawRec, secondRawReq)
if secondRawRec.Code != http.StatusTooManyRequests {
t.Fatalf("second raw status = %d, want %d", secondRawRec.Code, http.StatusTooManyRequests)
}
}
// TestSecurityHeadersOnAllResponses verifies the new always-on security headers middleware
// (wired early, applies to UI/API/assets/plain/other responses per audit).
func TestSecurityHeadersOnAllResponses(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
cases := []struct {
path string
method string
}{
{"/", "GET"},
{"/u", "GET"},
{"/api/config", "GET"},
{"/this-is-404", "GET"},
{"/robots.txt", "GET"},
{"/favicon.svg", "GET"},
}
for _, c := range cases {
req := httptest.NewRequest(c.method, c.path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("path=%s X-Content-Type-Options=%q want nosniff", c.path, got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" {
t.Errorf("path=%s Referrer-Policy=%q want strict-origin-when-cross-origin", c.path, got)
}
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
t.Errorf("path=%s X-Frame-Options=%q want DENY", c.path, got)
}
if got := rec.Header().Get("Strict-Transport-Security"); !strings.Contains(got, "max-age=31536000") {
t.Errorf("path=%s HSTS=%q want max-age", c.path, got)
}
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "default-src 'self'") || !strings.Contains(csp, "frame-ancestors 'none'") {
t.Errorf("path=%s CSP=%q missing expected directives", c.path, csp)
}
}
}
File diff suppressed because it is too large Load Diff
+40 -2
View File
@@ -15,8 +15,6 @@ import (
"scratchbox/internal/storage"
)
var testStorageKey = []byte("0123456789abcdef0123456789abcdef")
func TestMultipartFileCount(t *testing.T) {
t.Parallel()
@@ -61,6 +59,7 @@ func TestNewServerInitializes(t *testing.T) {
RateLimitUI: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIRead: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIWrite: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
HSTSEnabled: true,
},
}
@@ -132,6 +131,7 @@ func TestWriteCreateErrorBranchesWithConfig(t *testing.T) {
RateLimitUI: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIRead: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIWrite: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
HSTSEnabled: true,
},
}
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0), log.New(io.Discard, "", 0)); err != nil {
@@ -147,3 +147,41 @@ func containsAll(haystack string, needles ...string) bool {
}
return true
}
// TestInjectMetaEscapesSpecialChars exercises the server-side meta injection
// (used for /s/{id} and 410 pages) with filenames/titles containing HTML special
// chars. This provides basic coverage/golden-like check for the replace+escape
// logic against the exact literals in the committed static shell (addresses
// fragility note without changing to full template).
func TestInjectMetaEscapesSpecialChars(t *testing.T) {
t.Parallel()
// Minimal shell containing exactly the replace targets used by injectMeta.
// (Real index.html from Vite is larger but uses these literal strings.)
shell := []byte(`<!doctype html>
<html><head>
<title>Scratchbox</title>
<meta property="og:title" content="Scratchbox" />
<meta name="twitter:title" content="Scratchbox" />
<meta name="description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
<meta property="og:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
<meta name="twitter:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
</head><body></body></html>`)
tricky := `report & "notes" <2026> 'foo'`
gotBytes := injectMeta(shell, tricky, tricky, tricky)
got := string(gotBytes)
// Must have escaped the specials.
if !strings.Contains(got, `&amp;`) || !strings.Contains(got, `&lt;`) || !strings.Contains(got, `&gt;`) || !strings.Contains(got, `&quot;`) {
t.Fatalf("injectMeta did not escape specials in output: %q", got)
}
// Title etc updated (escaped form present).
if !strings.Contains(got, `report &amp; &quot;notes&quot; &lt;2026&gt;`) {
t.Fatalf("injectMeta title not updated or badly escaped: %q", got)
}
// Original static should be gone (count=1 replaces).
if strings.Contains(got, `<title>Scratchbox</title>`) {
t.Fatalf("static title not replaced")
}
}
+39 -1
View File
@@ -18,6 +18,15 @@ type visitor struct {
lastSeen time.Time
}
// rateLimiterMaxVisitors caps the per-limiter visitor map (token buckets keyed by client IP).
// Prevents unbounded memory growth under many distinct clients (or DoS) while prune
// still removes stale (>ttl) entries on every Allow. 1024 is generous for the intended
// LAN/minimal-public use; scans are O(cap) worst case.
const (
rateLimiterMaxVisitors = 1024
rateLimiterPruneTTL = 10 * time.Minute
)
type RateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
@@ -31,7 +40,7 @@ func NewRateLimiter(requestsPerMinute int, burst int) *RateLimiter {
visitors: map[string]*visitor{},
rate: rate.Limit(float64(requestsPerMinute) / 60.0),
burst: burst,
ttl: 10 * time.Minute,
ttl: rateLimiterPruneTTL,
}
}
@@ -53,6 +62,13 @@ func (r *RateLimiter) Allow(ip string) bool {
limiter: rate.NewLimiter(r.rate, r.burst),
}
r.visitors[ip] = v
if len(r.visitors) > rateLimiterMaxVisitors {
// evict arbitrary entry (prune already removed stales); keeps map bounded
for k := range r.visitors {
delete(r.visitors, k)
break
}
}
}
v.lastSeen = now
@@ -235,3 +251,25 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
// SecurityHeadersMiddleware sets safe default security headers on all responses.
// HSTS is conditional on the hstsEnabled toggle (default true in config for public proxy+TLS use;
// false allows pure local HTTP as noted in README LAN Deployment Notes).
// Other headers always-on per minimal security defaults.
// CSP uses 'unsafe-inline' for style-src (and data: for media) due to current Vite/Svelte
// build output; this is an acknowledged defense-in-depth tradeoff for the minimal trusted UI
// (script-src remains strict 'self'; no user-controlled content in styles).
func SecurityHeadersMiddleware(hstsEnabled bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("X-Frame-Options", "DENY")
if hstsEnabled {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self';")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
+57
View File
@@ -321,3 +321,60 @@ func TestAccessLogMiddlewareOnlyAllowsConfiguredRoutes(t *testing.T) {
t.Fatalf("expected access log for allowed route, got %q", buf.String())
}
}
// TestSecurityHeadersMiddlewareSetsDefaults verifies the new middleware sets expected headers
// (X-Content-Type-Options, Referrer, CSP, HSTS conditional, etc.) on wrapped responses.
func TestSecurityHeadersMiddlewareSetsDefaults(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// HSTS enabled (default)
handler := SecurityHeadersMiddleware(true, next)
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d want 200", rec.Code)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options=%q want nosniff", got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" {
t.Fatalf("Referrer-Policy=%q want strict-origin-when-cross-origin", got)
}
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
t.Fatalf("X-Frame-Options=%q want DENY", got)
}
hsts := rec.Header().Get("Strict-Transport-Security")
if !strings.Contains(hsts, "max-age=31536000") {
t.Fatalf("HSTS (enabled)=%q missing max-age", hsts)
}
csp := rec.Header().Get("Content-Security-Policy")
if csp == "" || !strings.Contains(csp, "default-src 'self'") || !strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("CSP=%q missing required", csp)
}
pp := rec.Header().Get("Permissions-Policy")
if !strings.Contains(pp, "camera=()") {
t.Fatalf("Permissions-Policy=%q", pp)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/plain" {
t.Fatalf("Content-Type was overwritten? got %q", ct)
}
// HSTS disabled
handlerNoHSTS := SecurityHeadersMiddleware(false, next)
rec2 := httptest.NewRecorder()
handlerNoHSTS.ServeHTTP(rec2, req)
if hsts2 := rec2.Header().Get("Strict-Transport-Security"); hsts2 != "" {
t.Fatalf("HSTS (disabled) should be absent, got %q", hsts2)
}
// other headers still present
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options (no hsts)=%q want nosniff", got)
}
}
+16 -3
View File
@@ -32,6 +32,19 @@ type rawContentLoad struct {
err error
}
// copyBytes returns a defensive copy so callers cannot mutate the cache's backing
// slice (which would affect concurrent/future hits for the same ID). Cost is
// acceptable: cache is small (default 32 entries) and holds decompressed raw
// content for range requests only.
func copyBytes(b []byte) []byte {
if b == nil {
return nil
}
c := make([]byte, len(b))
copy(c, b)
return c
}
func newRawContentCache(maxEntries int, maxBytes int64) *rawContentCache {
if maxEntries <= 0 {
maxEntries = defaultRawCacheMaxEntries
@@ -53,14 +66,14 @@ func (c *rawContentCache) GetOrLoad(id string, loader func() ([]byte, error)) ([
if elem, ok := c.items[id]; ok {
c.lru.MoveToFront(elem)
item := elem.Value.(*rawContentItem)
data := item.data
data := copyBytes(item.data)
c.mu.Unlock()
return data, nil, true
}
if load, ok := c.inFlight[id]; ok {
c.mu.Unlock()
<-load.wait
return load.data, load.err, false
return copyBytes(load.data), load.err, false
}
load := &rawContentLoad{wait: make(chan struct{})}
@@ -79,7 +92,7 @@ func (c *rawContentCache) GetOrLoad(id string, loader func() ([]byte, error)) ([
c.mu.Unlock()
close(load.wait)
return data, err, false
return copyBytes(data), err, false
}
func (c *rawContentCache) Delete(id string) {