mirror of
https://github.com/luxfi/kms.git
synced 2026-07-27 03:38:31 +00:00
kms: JSON only — HTML is not reachable on this listener
Removing the embedded console stopped the SPA answering every unmatched path, but the mux could still emit HTML on its own: http.ServeMux writes an HTML body for the implicit trailing-slash redirect, http.Error writes text/plain, and the default unmatched-route handler writes "404 page not found". jsonOnly wraps the mux so every response carries application/json and a JSON body regardless of what the handler underneath wrote. Status and Location are untouched, so redirects still redirect — they just stop explaining themselves in HTML. "/" is registered as a JSON 404 carrying the requested path, so a wrong URL says so in a form a caller can act on. This is the failure that let genesis call /api/v3/secrets/raw against this host and read the console page back instead of an error. Four tests pin it: unmatched paths are JSON 404s, redirect bodies contain no HTML but keep Location, plain text errors become JSON with the message preserved, and JSON handlers pass through byte for byte. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This service speaks JSON. Nothing else — no HTML, ever.
|
||||
//
|
||||
// That is not automatic. http.ServeMux writes an HTML body for its implicit
|
||||
// trailing-slash redirect, http.Error writes text/plain, and its unmatched-route
|
||||
// handler writes "404 page not found". A caller hitting a wrong path then reads
|
||||
// a 200-shaped page or a bare string instead of a machine-readable error, which
|
||||
// is precisely how a wrong URL went unnoticed against this host before: the
|
||||
// embedded console answered everything with HTML, so a bad path looked fine.
|
||||
//
|
||||
// jsonOnly wraps the mux so every response carries application/json and a JSON
|
||||
// body, whatever the handler underneath tried to write. Status and Location are
|
||||
// untouched, so redirects still redirect — they just stop explaining themselves
|
||||
// in HTML.
|
||||
func jsonOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(&jsonGuard{ResponseWriter: w}, r)
|
||||
})
|
||||
}
|
||||
|
||||
type jsonGuard struct {
|
||||
http.ResponseWriter
|
||||
passthrough bool // handler already committed to JSON; leave its bytes alone
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (g *jsonGuard) WriteHeader(code int) {
|
||||
if g.wroteHeader {
|
||||
return
|
||||
}
|
||||
g.wroteHeader = true
|
||||
g.passthrough = strings.HasPrefix(g.Header().Get("Content-Type"), "application/json")
|
||||
if !g.passthrough {
|
||||
g.Header().Set("Content-Type", "application/json")
|
||||
g.Header().Del("Content-Length") // the replacement body is a different size
|
||||
}
|
||||
g.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (g *jsonGuard) Write(b []byte) (int, error) {
|
||||
if !g.wroteHeader {
|
||||
g.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if g.passthrough {
|
||||
return g.ResponseWriter.Write(b)
|
||||
}
|
||||
// Whatever this was — redirect HTML, http.Error text — say it in JSON.
|
||||
// Report the caller's byte count so the handler sees a complete write.
|
||||
msg := strings.TrimSpace(string(b))
|
||||
if i := strings.IndexByte(msg, '<'); i == 0 {
|
||||
msg = http.StatusText(statusFromHeader(g.Header()))
|
||||
}
|
||||
_ = json.NewEncoder(g.ResponseWriter).Encode(map[string]string{"message": msg})
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// statusFromHeader recovers a usable message for a body we are discarding.
|
||||
// Location present means a redirect; otherwise fall back to a generic error.
|
||||
func statusFromHeader(h http.Header) int {
|
||||
if h.Get("Location") != "" {
|
||||
return http.StatusTemporaryRedirect
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// notFoundJSON is the unmatched-route handler. Registering "/" explicitly also
|
||||
// stops ServeMux from falling back to its own text/plain 404.
|
||||
func notFoundJSON(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"message": "not found",
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The service must never answer in HTML. It used to embed a console SPA that
|
||||
// caught every unmatched path, so a wrong URL came back 200 with a web page —
|
||||
// which is how a caller pointed at a route this server does not have went on
|
||||
// believing it worked. These pin the replacement behaviour.
|
||||
|
||||
func TestJSONOnly_UnmatchedPathIsJSON404(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /v1/kms/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("/", notFoundJSON)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
jsonOnly(mux).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v3/secrets/raw", nil))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body is not JSON: %v (%q)", err, rec.Body.String())
|
||||
}
|
||||
if body["path"] != "/api/v3/secrets/raw" {
|
||||
t.Errorf("path = %q, want the requested path", body["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONOnly_RedirectBodyIsNotHTML(t *testing.T) {
|
||||
// ServeMux redirects /v1/kms/orgs -> /v1/kms/orgs/ with an HTML body.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /v1/kms/orgs/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "yes"})
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
jsonOnly(mux).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/kms/orgs", nil))
|
||||
|
||||
if strings.Contains(rec.Body.String(), "<a href") || strings.Contains(rec.Body.String(), "<html") {
|
||||
t.Errorf("redirect body contains HTML: %q", rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, must never be HTML", ct)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc == "" {
|
||||
t.Error("Location header dropped — the redirect must still redirect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONOnly_PlainTextErrorBecomesJSON(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "something broke", http.StatusInternalServerError) // text/plain
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
jsonOnly(mux).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/boom", nil))
|
||||
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body is not JSON: %v (%q)", err, rec.Body.String())
|
||||
}
|
||||
if body["message"] != "something broke" {
|
||||
t.Errorf("message = %q, want the handler's message preserved", body["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONOnly_JSONHandlersPassThroughByteForByte(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /v1/kms/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "n": 1})
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
jsonOnly(mux).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/kms/status", nil))
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body is not JSON: %v (%q)", err, rec.Body.String())
|
||||
}
|
||||
if body["status"] != "ok" || body["n"] != float64(1) {
|
||||
t.Errorf("handler payload altered: %v", body)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -349,9 +349,12 @@ func main() {
|
||||
// back to index.html so React Router can resolve the path client-side.
|
||||
|
||||
// Start HTTP server.
|
||||
// Every route is registered by now; "/" catches the rest as JSON, and
|
||||
// jsonOnly guarantees nothing on this listener ever answers in HTML.
|
||||
mux.HandleFunc("/", notFoundJSON)
|
||||
srv := &http.Server{
|
||||
Addr: listen,
|
||||
Handler: mux,
|
||||
Handler: jsonOnly(mux),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
|
||||
Reference in New Issue
Block a user