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
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:
+89
-14
@@ -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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
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
@@ -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, `&`) || !strings.Contains(got, `<`) || !strings.Contains(got, `>`) || !strings.Contains(got, `"`) {
|
||||
t.Fatalf("injectMeta did not escape specials in output: %q", got)
|
||||
}
|
||||
// Title etc updated (escaped form present).
|
||||
if !strings.Contains(got, `report & "notes" <2026>`) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user