mirror of
https://github.com/renorris/openfsd
synced 2026-08-13 04:55:42 +08:00
fix: address review feedback for sweatbox instructor page
Cover scenario/unpause/multipart/error paths; fail closed on scenario JSON parse; cap flash msg length; remove dead sticky form fields; tighten XSS assertions; MaxBytesReader on small form POSTs.
This commit is contained in:
@@ -142,11 +142,6 @@ type sweatboxPage struct {
|
||||
ArrCount int
|
||||
DepCount int
|
||||
Aircraft []sweatboxAircraftRow
|
||||
|
||||
// Sticky form values (re-render on soft failure)
|
||||
Command string
|
||||
AptText string
|
||||
AirText string
|
||||
}
|
||||
|
||||
func pageUserFromClaims(claims *auth.CustomClaims) *pageUser {
|
||||
|
||||
@@ -20,6 +20,12 @@ import (
|
||||
// Max body for airport/scenario form payloads (mirrors FSD service HTTP 2 MiB).
|
||||
const sweatboxWebMaxBody = 2 << 20
|
||||
|
||||
// Max body for small sweatbox form POSTs (command/pause/delete — not file uploads).
|
||||
const sweatboxWebSmallFormMaxBody = 64 << 10
|
||||
|
||||
// Max freeform flash message length embedded in redirect Location query.
|
||||
const sweatboxFlashMsgMaxRunes = 240
|
||||
|
||||
// handleFrontendSweatbox GET /sweatbox — server-rendered instructor page.
|
||||
// Works with JS disabled: aircraft table from FSD GET /sweatbox/state + forms.
|
||||
func (s *Server) handleFrontendSweatbox(c *gin.Context) {
|
||||
@@ -41,7 +47,7 @@ func (s *Server) newSweatboxPage(c *gin.Context) sweatboxPage {
|
||||
}
|
||||
|
||||
func (s *Server) applySweatboxFlash(c *gin.Context, page *sweatboxPage) {
|
||||
msg := strings.TrimSpace(c.Query("msg"))
|
||||
msg := truncateRunes(strings.TrimSpace(c.Query("msg")), sweatboxFlashMsgMaxRunes)
|
||||
switch c.Query("flash") {
|
||||
case "ok":
|
||||
if msg == "" {
|
||||
@@ -141,6 +147,8 @@ func (s *Server) populateSweatboxState(c *gin.Context, page *sweatboxPage) {
|
||||
|
||||
// handleFrontendSweatboxAirport POST /sweatbox/airport
|
||||
func (s *Server) handleFrontendSweatboxAirport(c *gin.Context) {
|
||||
// Cap before CSRF form parse so oversized bodies fail closed early.
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody+4096)
|
||||
if !s.validateCSRF(c) {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
@@ -193,6 +201,8 @@ func (s *Server) handleFrontendSweatboxAirport(c *gin.Context) {
|
||||
|
||||
// handleFrontendSweatboxScenario POST /sweatbox/scenario
|
||||
func (s *Server) handleFrontendSweatboxScenario(c *gin.Context) {
|
||||
// Cap before CSRF form parse so oversized bodies fail closed early.
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody+4096)
|
||||
if !s.validateCSRF(c) {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
@@ -220,7 +230,10 @@ func (s *Server) handleFrontendSweatboxScenario(c *gin.Context) {
|
||||
switch status {
|
||||
case http.StatusOK:
|
||||
var res server.SweatboxScenarioResponse
|
||||
_ = json.Unmarshal(respBody, &res)
|
||||
if err := json.Unmarshal(respBody, &res); err != nil {
|
||||
s.redirectSweatboxFlash(c, "err", "Unable to parse scenario response")
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("Scenario loaded: %d aircraft", res.Loaded)
|
||||
if len(res.Errors) > 0 {
|
||||
msg = fmt.Sprintf("%s (%d warning(s))", msg, len(res.Errors))
|
||||
@@ -241,6 +254,9 @@ func (s *Server) handleFrontendSweatboxScenario(c *gin.Context) {
|
||||
|
||||
// handleFrontendSweatboxCommand POST /sweatbox/command
|
||||
func (s *Server) handleFrontendSweatboxCommand(c *gin.Context) {
|
||||
if !s.limitSweatboxSmallForm(c) {
|
||||
return
|
||||
}
|
||||
if !s.validateCSRF(c) {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
@@ -300,6 +316,9 @@ func (s *Server) handleFrontendSweatboxCommand(c *gin.Context) {
|
||||
|
||||
// handleFrontendSweatboxPause POST /sweatbox/pause
|
||||
func (s *Server) handleFrontendSweatboxPause(c *gin.Context) {
|
||||
if !s.limitSweatboxSmallForm(c) {
|
||||
return
|
||||
}
|
||||
if !s.validateCSRF(c) {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
@@ -321,6 +340,9 @@ func (s *Server) handleFrontendSweatboxPause(c *gin.Context) {
|
||||
|
||||
// handleFrontendSweatboxUnpause POST /sweatbox/unpause
|
||||
func (s *Server) handleFrontendSweatboxUnpause(c *gin.Context) {
|
||||
if !s.limitSweatboxSmallForm(c) {
|
||||
return
|
||||
}
|
||||
if !s.validateCSRF(c) {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
@@ -342,6 +364,9 @@ func (s *Server) handleFrontendSweatboxUnpause(c *gin.Context) {
|
||||
|
||||
// handleFrontendSweatboxDelete POST /sweatbox/delete
|
||||
func (s *Server) handleFrontendSweatboxDelete(c *gin.Context) {
|
||||
if !s.limitSweatboxSmallForm(c) {
|
||||
return
|
||||
}
|
||||
if !s.validateCSRF(c) {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
@@ -372,6 +397,9 @@ func (s *Server) handleFrontendSweatboxDelete(c *gin.Context) {
|
||||
|
||||
// handleFrontendSweatboxDeleteAll POST /sweatbox/delete-all
|
||||
func (s *Server) handleFrontendSweatboxDeleteAll(c *gin.Context) {
|
||||
if !s.limitSweatboxSmallForm(c) {
|
||||
return
|
||||
}
|
||||
if !s.validateCSRF(c) {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
@@ -396,6 +424,21 @@ func (s *Server) handleFrontendSweatboxDeleteAll(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// limitSweatboxSmallForm caps POST body for non-upload sweatbox forms.
|
||||
// Returns false when the body is too large (flash + redirect already issued).
|
||||
func (s *Server) limitSweatboxSmallForm(c *gin.Context) bool {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebSmallFormMaxBody)
|
||||
// Force parse so MaxBytesReader surfaces early for form-urlencoded.
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
if isRequestTooLarge(err) {
|
||||
s.redirectSweatboxFlash(c, "err", "Request body too large")
|
||||
return false
|
||||
}
|
||||
// Leave form empty on other parse errors; handlers validate required fields.
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FSD service HTTP proxy helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -428,16 +471,34 @@ func (s *Server) fsdSweatboxDo(method, path, contentType string, body io.Reader)
|
||||
func (s *Server) redirectSweatboxFlash(c *gin.Context, flash, msg string) {
|
||||
u := "/sweatbox?flash=" + url.QueryEscape(flash)
|
||||
if msg != "" {
|
||||
u += "&msg=" + url.QueryEscape(msg)
|
||||
u += "&msg=" + url.QueryEscape(truncateRunes(msg, sweatboxFlashMsgMaxRunes))
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, u)
|
||||
}
|
||||
|
||||
// readSweatboxFormPayload prefers an uploaded file, else named text fields.
|
||||
func readSweatboxFormPayload(c *gin.Context, fileField string, textFields ...string) ([]byte, error) {
|
||||
// Cap body before parsing multipart/urlencoded.
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody+4096)
|
||||
// truncateRunes shortens s to at most max runes, appending "…" when truncated.
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 || s == "" {
|
||||
return ""
|
||||
}
|
||||
if max == 1 {
|
||||
// Single-rune budget: prefer ellipsis over a partial character.
|
||||
r := []rune(s)
|
||||
if len(r) <= 1 {
|
||||
return s
|
||||
}
|
||||
return "…"
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
// readSweatboxFormPayload prefers an uploaded file, else named text fields.
|
||||
// Callers should already wrap the request body with MaxBytesReader.
|
||||
func readSweatboxFormPayload(c *gin.Context, fileField string, textFields ...string) ([]byte, error) {
|
||||
ct := c.ContentType()
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := c.Request.ParseMultipartForm(sweatboxWebMaxBody); err != nil {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/renorris/openfsd/internal/server"
|
||||
"github.com/renorris/openfsd/pkg/protocol"
|
||||
@@ -146,11 +150,19 @@ func TestSweatboxCommandEmptyRedirectsErrorFlash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweatboxWithMockFSDStateAndForms(t *testing.T) {
|
||||
// Mock FSD service HTTP sweatbox surface.
|
||||
var lastCommand server.SweatboxCommandRequest
|
||||
var airportBody string
|
||||
var paused bool
|
||||
// sweatboxMock tracks FSD service HTTP interactions for instructor UI tests.
|
||||
type sweatboxMock struct {
|
||||
paused bool
|
||||
lastCommand server.SweatboxCommandRequest
|
||||
airportBody string
|
||||
airportPath string
|
||||
scenarioBody string
|
||||
commandSoftFail bool
|
||||
airportConflict bool
|
||||
scenarioBadJSON bool
|
||||
}
|
||||
|
||||
func (m *sweatboxMock) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/sweatbox/state", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
@@ -159,7 +171,7 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) {
|
||||
}
|
||||
st := server.SweatboxStateJSON{
|
||||
ICAO: "KBTV",
|
||||
Paused: paused,
|
||||
Paused: m.paused,
|
||||
Elapsed: 65,
|
||||
ArrCount: 1,
|
||||
DepCount: 2,
|
||||
@@ -180,20 +192,51 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) {
|
||||
_ = json.NewEncoder(w).Encode(st)
|
||||
})
|
||||
mux.HandleFunc("/sweatbox/airport", func(w http.ResponseWriter, r *http.Request) {
|
||||
m.airportPath = r.URL.RequestURI()
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
airportBody = string(b)
|
||||
m.airportBody = string(b)
|
||||
if m.airportConflict {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"errors": []string{"aircraft are present; use replace"},
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"icao": "KBTV", "surfaces": 3, "errors": []string{}})
|
||||
})
|
||||
mux.HandleFunc("/sweatbox/scenario", func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
m.scenarioBody = string(b)
|
||||
if m.scenarioBadJSON {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("not-json"))
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(server.SweatboxScenarioResponse{
|
||||
Loaded: 3,
|
||||
Errors: []string{"line 9: skipped"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/sweatbox/command", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&lastCommand)
|
||||
_ = json.NewEncoder(w).Encode(server.SweatboxCommandResponse{OK: true, Message: "ok: " + lastCommand.Command})
|
||||
_ = json.NewDecoder(r.Body).Decode(&m.lastCommand)
|
||||
if m.commandSoftFail {
|
||||
_ = json.NewEncoder(w).Encode(server.SweatboxCommandResponse{
|
||||
OK: false,
|
||||
Message: "Unknown command: xyz",
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(server.SweatboxCommandResponse{
|
||||
OK: true,
|
||||
Message: "ok: " + m.lastCommand.Command,
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/sweatbox/pause", func(w http.ResponseWriter, r *http.Request) {
|
||||
paused = true
|
||||
m.paused = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("/sweatbox/unpause", func(w http.ResponseWriter, r *http.Request) {
|
||||
paused = false
|
||||
m.paused = false
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("/sweatbox/aircraft/", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -210,7 +253,12 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) {
|
||||
}
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
})
|
||||
fsd := httptest.NewServer(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
func TestSweatboxWithMockFSDStateAndForms(t *testing.T) {
|
||||
m := &sweatboxMock{}
|
||||
fsd := httptest.NewServer(m.handler())
|
||||
t.Cleanup(fsd.Close)
|
||||
|
||||
ts := newTestServer(t)
|
||||
@@ -256,8 +304,8 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) {
|
||||
if !strings.Contains(loc, "flash=ok") {
|
||||
t.Fatalf("Location=%q", loc)
|
||||
}
|
||||
if lastCommand.Command != "ops" || lastCommand.Callsign != "AAL123" {
|
||||
t.Fatalf("lastCommand=%+v", lastCommand)
|
||||
if m.lastCommand.Command != "ops" || m.lastCommand.Callsign != "AAL123" {
|
||||
t.Fatalf("lastCommand=%+v", m.lastCommand)
|
||||
}
|
||||
w, cookies = authedGET(t, ts, loc, cookies)
|
||||
if !strings.Contains(w.Body.String(), "ok: ops") {
|
||||
@@ -274,8 +322,41 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) {
|
||||
if !strings.Contains(w.Header().Get("Location"), "flash=airport_ok") {
|
||||
t.Fatalf("Location=%q", w.Header().Get("Location"))
|
||||
}
|
||||
if !strings.Contains(airportBody, "icao=KBTV") {
|
||||
t.Fatalf("airportBody=%q", airportBody)
|
||||
if !strings.Contains(m.airportBody, "icao=KBTV") {
|
||||
t.Fatalf("airportBody=%q", m.airportBody)
|
||||
}
|
||||
|
||||
// Airport with replace=1 proxy query
|
||||
form = url.Values{}
|
||||
form.Set("apt_text", "icao=KBTV\n")
|
||||
form.Set("replace", "on")
|
||||
w, cookies = formPOST(t, ts, "/sweatbox/airport", form, cookies)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("airport replace status %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(m.airportPath, "replace=1") {
|
||||
t.Fatalf("airportPath=%q want ?replace=1", m.airportPath)
|
||||
}
|
||||
|
||||
// Scenario paste
|
||||
form = url.Values{}
|
||||
form.Set("air_text", "AAL123:B738/F:J:I:KBTV:KBOS:29000:DCT:rmk:2200:S:44.4:-73.1:335:0:360\n")
|
||||
w, cookies = formPOST(t, ts, "/sweatbox/scenario", form, cookies)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("scenario status %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Location"), "flash=scenario_ok") {
|
||||
t.Fatalf("Location=%q", w.Header().Get("Location"))
|
||||
}
|
||||
if !strings.Contains(m.scenarioBody, "AAL123") {
|
||||
t.Fatalf("scenarioBody=%q", m.scenarioBody)
|
||||
}
|
||||
w, cookies = authedGET(t, ts, w.Header().Get("Location"), cookies)
|
||||
if !strings.Contains(w.Body.String(), "Scenario loaded: 3 aircraft") {
|
||||
t.Fatalf("expected scenario flash, body=%s", clip(w.Body.String(), 400))
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "1 warning") {
|
||||
t.Fatalf("expected warning count in flash, body=%s", clip(w.Body.String(), 400))
|
||||
}
|
||||
|
||||
// Pause
|
||||
@@ -287,10 +368,23 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) {
|
||||
if !strings.Contains(w.Header().Get("Location"), "flash=paused") {
|
||||
t.Fatalf("Location=%q", w.Header().Get("Location"))
|
||||
}
|
||||
if !paused {
|
||||
if !m.paused {
|
||||
t.Fatal("expected mock paused")
|
||||
}
|
||||
|
||||
// Unpause
|
||||
form = url.Values{}
|
||||
w, cookies = formPOST(t, ts, "/sweatbox/unpause", form, cookies)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unpause status %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Location"), "flash=unpaused") {
|
||||
t.Fatalf("Location=%q", w.Header().Get("Location"))
|
||||
}
|
||||
if m.paused {
|
||||
t.Fatal("expected mock unpaused")
|
||||
}
|
||||
|
||||
// Delete aircraft
|
||||
form = url.Values{}
|
||||
form.Set("callsign", "AAL123")
|
||||
@@ -324,6 +418,99 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweatboxMultipartFileUploadAndErrors(t *testing.T) {
|
||||
m := &sweatboxMock{}
|
||||
fsd := httptest.NewServer(m.handler())
|
||||
t.Cleanup(fsd.Close)
|
||||
|
||||
ts := newTestServer(t)
|
||||
ts.cfg.FsdHttpServiceAddress = fsd.URL
|
||||
admin := createTestUser(t, ts, "pw", int(protocol.NetworkRatingAdministator))
|
||||
cookies := formLogin(t, ts, admin.CID, "pw")
|
||||
// Ensure CSRF cookie
|
||||
_, cookies = authedGET(t, ts, "/sweatbox", cookies)
|
||||
|
||||
// Multipart airport file upload
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
_ = mw.WriteField("csrf_token", csrfFromCookies(cookies))
|
||||
fw, err := mw.CreateFormFile("file", "KBTV.apt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte("icao=KBTV\nmagnetic variation=16\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/sweatbox/airport", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Cookie", cookieHeader(cookies))
|
||||
w := httptest.NewRecorder()
|
||||
ts.engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("multipart airport status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Location"), "flash=airport_ok") {
|
||||
t.Fatalf("Location=%q", w.Header().Get("Location"))
|
||||
}
|
||||
if !strings.Contains(m.airportBody, "icao=KBTV") {
|
||||
t.Fatalf("airportBody from multipart=%q", m.airportBody)
|
||||
}
|
||||
cookies = mergeCookies(cookies, w.Result())
|
||||
|
||||
// Soft-fail command
|
||||
m.commandSoftFail = true
|
||||
form := url.Values{}
|
||||
form.Set("command", "xyz")
|
||||
w, cookies = formPOST(t, ts, "/sweatbox/command", form, cookies)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("soft-fail command status %d", w.Code)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.Contains(loc, "flash=err") {
|
||||
t.Fatalf("Location=%q want flash=err", loc)
|
||||
}
|
||||
w, cookies = authedGET(t, ts, loc, cookies)
|
||||
if !strings.Contains(w.Body.String(), "Unknown command: xyz") {
|
||||
t.Fatalf("expected soft-fail message, body=%s", clip(w.Body.String(), 400))
|
||||
}
|
||||
|
||||
// Airport conflict surfaces firstJSONError from errors[]
|
||||
m.airportConflict = true
|
||||
form = url.Values{}
|
||||
form.Set("apt_text", "icao=KBTV\n")
|
||||
w, cookies = formPOST(t, ts, "/sweatbox/airport", form, cookies)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("conflict airport status %d", w.Code)
|
||||
}
|
||||
loc = w.Header().Get("Location")
|
||||
if !strings.Contains(loc, "flash=err") {
|
||||
t.Fatalf("Location=%q", loc)
|
||||
}
|
||||
w, cookies = authedGET(t, ts, loc, cookies)
|
||||
if !strings.Contains(w.Body.String(), "aircraft are present") {
|
||||
t.Fatalf("expected conflict error flash, body=%s", clip(w.Body.String(), 400))
|
||||
}
|
||||
|
||||
// Scenario bad JSON → parse error flash
|
||||
m.scenarioBadJSON = true
|
||||
form = url.Values{}
|
||||
form.Set("air_text", "AAL1:B738:J:I:KBTV:KBOS:100:DCT::2200:S:0:0:0:0:0\n")
|
||||
w, cookies = formPOST(t, ts, "/sweatbox/scenario", form, cookies)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("bad scenario JSON status %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Location"), "flash=err") {
|
||||
t.Fatalf("Location=%q want flash=err", w.Header().Get("Location"))
|
||||
}
|
||||
w, _ = authedGET(t, ts, w.Header().Get("Location"), cookies)
|
||||
if !strings.Contains(w.Body.String(), "Unable to parse scenario response") {
|
||||
t.Fatalf("expected parse error, body=%s", clip(w.Body.String(), 400))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweatboxDisabledMessagingWhenFSD404(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
// No /sweatbox routes → 404
|
||||
@@ -356,7 +543,7 @@ func TestSweatboxXSSEscapedInFlashAndTable(t *testing.T) {
|
||||
ICAO: "KBTV",
|
||||
Aircraft: []server.SweatboxAircraftJSON{
|
||||
{
|
||||
Callsign: `<script>alert(1)</script>`,
|
||||
Callsign: `"><script>alert(1)</script>`,
|
||||
Type: `B738"><img src=x>`,
|
||||
Status: `parked`,
|
||||
Instruction: `<b>bad</b>`,
|
||||
@@ -381,14 +568,25 @@ func TestSweatboxXSSEscapedInFlashAndTable(t *testing.T) {
|
||||
|
||||
w, cookies := authedGET(t, ts, "/sweatbox", cookies)
|
||||
body := w.Body.String()
|
||||
|
||||
// No raw executable script tags.
|
||||
if strings.Contains(body, "<script>alert(1)</script>") {
|
||||
t.Fatal("unescaped callsign script in HTML")
|
||||
}
|
||||
if !strings.Contains(body, "<script>") && !strings.Contains(body, """) {
|
||||
// html/template escapes < as <
|
||||
if strings.Contains(body, "<script>alert") {
|
||||
t.Fatal("executable script present")
|
||||
}
|
||||
if strings.Contains(body, "<script>alert") {
|
||||
t.Fatal("executable script present in table HTML")
|
||||
}
|
||||
// Positive assertions: html/template entity-escapes both content and attribute contexts.
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Fatalf("expected <script> entity escape in body: %s", clip(body, 800))
|
||||
}
|
||||
// Callsign is also used in delete form value="…"; quote must be escaped.
|
||||
if !strings.Contains(body, """) && !strings.Contains(body, """) {
|
||||
t.Fatalf("expected quote entity escape for attribute-safe callsign, body=%s", clip(body, 800))
|
||||
}
|
||||
// Hidden callsign input should not contain raw quote breakout.
|
||||
if strings.Contains(body, `name="callsign" value=""><script>`) {
|
||||
t.Fatal("unescaped callsign broke out of value attribute")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
@@ -399,6 +597,89 @@ func TestSweatboxXSSEscapedInFlashAndTable(t *testing.T) {
|
||||
if strings.Contains(body, `<script>alert("xss")</script>`) {
|
||||
t.Fatal("unescaped flash script")
|
||||
}
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Fatalf("expected escaped flash script entities, body=%s", clip(body, 500))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweatboxFlashMsgTruncated(t *testing.T) {
|
||||
// Unit-level: truncateRunes keeps Location headers bounded.
|
||||
long := strings.Repeat("a", sweatboxFlashMsgMaxRunes+50)
|
||||
got := truncateRunes(long, sweatboxFlashMsgMaxRunes)
|
||||
if utf8.RuneCountInString(got) != sweatboxFlashMsgMaxRunes {
|
||||
t.Fatalf("rune count %d want %d", utf8.RuneCountInString(got), sweatboxFlashMsgMaxRunes)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Fatalf("want ellipsis suffix, got %q", got[len(got)-3:])
|
||||
}
|
||||
|
||||
// Integration: oversized FSD command message is truncated in redirect.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/sweatbox/state", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(server.SweatboxStateJSON{ICAO: "KBTV", Aircraft: []server.SweatboxAircraftJSON{}})
|
||||
})
|
||||
mux.HandleFunc("/sweatbox/command", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(server.SweatboxCommandResponse{
|
||||
OK: true,
|
||||
Message: strings.Repeat("x", 500),
|
||||
})
|
||||
})
|
||||
fsd := httptest.NewServer(mux)
|
||||
t.Cleanup(fsd.Close)
|
||||
|
||||
ts := newTestServer(t)
|
||||
ts.cfg.FsdHttpServiceAddress = fsd.URL
|
||||
admin := createTestUser(t, ts, "pw", int(protocol.NetworkRatingAdministator))
|
||||
cookies := formLogin(t, ts, admin.CID, "pw")
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("command", "ops")
|
||||
w, _ := formPOST(t, ts, "/sweatbox/command", form, cookies)
|
||||
loc := w.Header().Get("Location")
|
||||
u, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := u.Query().Get("msg")
|
||||
if utf8.RuneCountInString(msg) > sweatboxFlashMsgMaxRunes {
|
||||
t.Fatalf("msg runes %d > max %d", utf8.RuneCountInString(msg), sweatboxFlashMsgMaxRunes)
|
||||
}
|
||||
if len(loc) > 2048 {
|
||||
t.Fatalf("Location header too long: %d", len(loc))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstJSONError(t *testing.T) {
|
||||
if got := firstJSONError([]byte(`{"message":" hello "}`), "fb"); got != "hello" {
|
||||
t.Fatalf("message field: %q", got)
|
||||
}
|
||||
if got := firstJSONError([]byte(`{"errors":["e1","e2"]}`), "fb"); got != "e1" {
|
||||
t.Fatalf("errors[0]: %q", got)
|
||||
}
|
||||
if got := firstJSONError([]byte(`{"error":"boom"}`), "fb"); got != "boom" {
|
||||
t.Fatalf("error field: %q", got)
|
||||
}
|
||||
if got := firstJSONError([]byte(`not-json`), "fb"); got != "fb" {
|
||||
t.Fatalf("fallback: %q", got)
|
||||
}
|
||||
if got := firstJSONError([]byte(`{}`), "fb"); got != "fb" {
|
||||
t.Fatalf("empty object fallback: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRequestTooLarge(t *testing.T) {
|
||||
if !isRequestTooLarge(&http.MaxBytesError{Limit: 10}) {
|
||||
t.Fatal("MaxBytesError should match")
|
||||
}
|
||||
if !isRequestTooLarge(fmt.Errorf("wrap: %w", &http.MaxBytesError{Limit: 1})) {
|
||||
t.Fatal("wrapped MaxBytesError should match")
|
||||
}
|
||||
if isRequestTooLarge(fmt.Errorf("other")) {
|
||||
t.Fatal("unrelated error should not match")
|
||||
}
|
||||
if isRequestTooLarge(nil) {
|
||||
t.Fatal("nil should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSweatboxElapsed(t *testing.T) {
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
<div class="mb-2">
|
||||
<label for="sbx-command" class="form-label">Command</label>
|
||||
<input type="text" class="form-control" id="sbx-command" name="command" required
|
||||
value="{{ .Command }}" autocomplete="off" spellcheck="false"
|
||||
value="" autocomplete="off" spellcheck="false"
|
||||
placeholder="add AAL1 B738 J I KBTV KBOS … or p / un / ops / del AAL1">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Run command</button>
|
||||
@@ -147,7 +147,7 @@
|
||||
<div class="mb-2">
|
||||
<label for="sbx-apt-text" class="form-label">Or paste .apt text</label>
|
||||
<textarea class="form-control form-control-sm" id="sbx-apt-text" name="apt_text" rows="6"
|
||||
placeholder="icao=KBTV …">{{ .AptText }}</textarea>
|
||||
placeholder="icao=KBTV …"></textarea>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="replace" value="on" id="sbx-apt-replace">
|
||||
@@ -171,7 +171,7 @@
|
||||
<div class="mb-2">
|
||||
<label for="sbx-air-text" class="form-label">Or paste .air text</label>
|
||||
<textarea class="form-control form-control-sm" id="sbx-air-text" name="air_text" rows="6"
|
||||
placeholder="AAL123:B738/F:J:I:…">{{ .AirText }}</textarea>
|
||||
placeholder="AAL123:B738/F:J:I:…"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary">Load scenario</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user