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:
zeekay
2026-07-25 17:30:37 -07:00
co-authored by Hanzo Dev
parent a6a933dd92
commit d333c66bae
3 changed files with 183 additions and 1 deletions
+80
View File
@@ -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,
})
}
+99
View File
@@ -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
View File
@@ -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,