mpcsign was an operator action on a public path

The proxy branch forwards /api/swaps/+ whole, and among those legs is
mpcsign/:swapId, which asks the MPC to sign for a swap id the caller names. The
backend router it arrives at holds no authentication of any kind
(app/server/src/routes/swaps.ts), and no hook in the SPA calls it — the client
reaches getsig, payout and transfer. So it was an operator action reachable by
anyone on the port the ingress publishes. It moves to the operator listener,
declared before the wildcard because routes match in registration order.

The rig now builds the SPA catch-all main.go installs at /*, which changes what
the listener tests can honestly assert. Three of them read status == 404, and
production never answers 404 on an unrouted path — the catch-all answers 200
with HTML first, so those assertions held only because the rig was missing a
route. They assert the substance instead: no swap id or signed destination tx
in the body, no scrape, no deposit poll. Each still fails when its route is put
back on the public listener.
This commit is contained in:
2026-09-01 20:55:46 -07:00
parent f050efbaf0
commit 61960e4457
4 changed files with 89 additions and 14 deletions
+41
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
@@ -149,3 +150,43 @@ func TestNoBooleanFlagIsBuiltFromASetnessTest(t *testing.T) {
}
}
}
// mpcsign asks the MPC to sign for a swap id the caller names, and the backend
// router it reaches authenticates nobody (app/server/src/routes/swaps.ts holds
// no auth middleware). The SPA never calls it — its hooks reach getsig, payout
// and transfer — so it is an operator action wearing a public path.
//
// The assertion is that the request never REACHES the backend, not that some
// status came back: production hangs a SPA catch-all after these routes, so an
// unrouted path answers 200 with HTML rather than 404, and a status assertion
// would pass for a reason production does not share.
func TestMPCSignNeverReachesTheBackendFromThePublicListener(t *testing.T) {
var got []string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.URL.Path)
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()
api := NewAPI(Config{}, backend.URL, nil, nil, nil, nil)
public, admin := listeners(api)
fireRequest(t, public, "POST", "/api/swaps/mpcsign/abc", nil)
if len(got) != 0 {
t.Fatalf("the public listener forwarded mpcsign to the backend as %v", got)
}
// A sibling leg the SPA does call still forwards, so the refusal above is
// mpcsign's and not the whole prefix going dark.
fireRequest(t, public, "POST", "/api/swaps/payout/abc", nil)
if len(got) == 0 {
t.Fatal("payout stopped reaching the backend; the prefix was taken down, not one leg")
}
// And mpcsign is served on the operator listener, not moved to nowhere.
before := len(got)
fireRequest(t, admin, "POST", "/api/swaps/mpcsign/abc", nil)
if len(got) == before {
t.Fatal("mpcsign reaches the backend from neither listener; it was moved to nowhere")
}
}
+20
View File
@@ -227,6 +227,16 @@ func (a *API) Register(app *zip.App) {
// (app/server/src/server.ts), so a /v1/bridge/swaps/… request
// arrives at /swaps/… — a path space with no routes on it,
// whatever the sub-path spells.
// mpcsign asks the MPC to sign for a swap id the caller names, and
// the backend router it reaches carries no authentication of any
// kind. It has no caller in the SPA — the hooks reach getsig,
// payout and transfer, never this — so it is an operator action
// wearing a public path, and it is registered on the operator
// listener below instead. Declared BEFORE the wildcard, because
// routes match in registration order and the wildcard would
// otherwise swallow it.
app.All("/api/swaps/mpcsign/+", absent)
app.All("/api/swaps/+", proxied)
}
if a.bchain != nil {
@@ -253,6 +263,12 @@ func (a *API) Register(app *zip.App) {
// release wallets and says which of them cannot sign right now. The deposit
// poll turns one request into a source-chain RPC call with caller-chosen
// arguments. Each is worth having and none is worth answering anonymously.
// absent answers as though nothing were mounted, so a path served elsewhere
// reads from the public edge exactly like one that does not exist.
func absent(c *zip.Ctx) error {
return c.JSON(http.StatusNotFound, map[string]string{"error": "not_found"})
}
func (a *API) RegisterAdmin(app *zip.App) {
// Prometheus metrics including bridge_classical_compat_total.
app.Get("/metrics", a.metrics)
@@ -278,6 +294,10 @@ func (a *API) RegisterAdmin(app *zip.App) {
app.Get("/api/swaps", a.proxied())
}
// Where mpcsign lives now. See the note beside its refusal on the public
// listener.
app.All("/api/swaps/mpcsign/+", a.proxied())
// The deposit poll reads the source-chain balance at an address. Not
// part of the SDK's happy path — BridgeVM owns deposit advancement in
// the target architecture.
+17 -11
View File
@@ -45,12 +45,12 @@ func TestPublicListenerHasNoSwapList(t *testing.T) {
rig := newRig(t, nil, nil, nil)
sw := seedSigned(t, rig)
// Create keeps this path, so a refusal here reads 405 rather than 404.
// What is asserted is the BODY, not the status. main.go hangs a SPA
// catch-all after these routes, so a path this listener does not serve
// answers 200 with HTML rather than 404 — a status assertion here would
// pass for a reason production does not share.
for _, path := range []string{"/v1/bridge/swaps", "/api/swaps"} {
status, body := fireRequest(t, rig.app, http.MethodGet, path, nil)
if status == http.StatusOK {
t.Errorf("GET %s on the public listener answered 200; body=%s", path, body)
}
_, body := fireRequest(t, rig.app, http.MethodGet, path, nil)
if strings.Contains(string(body), sw.ID) || strings.Contains(string(body), sw.DestRawTx) {
t.Errorf("GET %s handed out a swap id or a signed destination tx: %s", path, body)
}
@@ -59,18 +59,24 @@ func TestPublicListenerHasNoSwapList(t *testing.T) {
func TestPublicListenerHasNoMetrics(t *testing.T) {
rig := newRig(t, nil, nil, nil)
status, body := fireRequest(t, rig.app, http.MethodGet, "/metrics", nil)
if status != http.StatusNotFound {
t.Errorf("GET /metrics on the public listener answered %d; body=%s", status, body)
_, body := fireRequest(t, rig.app, http.MethodGet, "/metrics", nil)
// The scrape carries release-wallet ids and a live signal of which wallets
// can currently sign. Its absence is what matters, not the status the SPA
// catch-all happens to answer with.
if strings.Contains(string(body), "bridge_") {
t.Errorf("GET /metrics on the public listener returned a scrape; body=%s", body)
}
}
func TestPublicListenerHasNoDepositCheck(t *testing.T) {
rig := newRig(t, nil, nil, &depositcheck.Client{Timeout: time.Second})
body, _ := json.Marshal(checkDepositReq{Network: "ETHEREUM_SEPOLIA", Address: "0xabc", Amount: 1})
status, resp := fireRequest(t, rig.app, http.MethodPost, "/v1/bridge/check-deposit", body)
if status != http.StatusNotFound {
t.Errorf("POST /v1/bridge/check-deposit on the public listener answered %d; body=%s", status, resp)
_, resp := fireRequest(t, rig.app, http.MethodPost, "/v1/bridge/check-deposit", body)
// An unauthenticated caller must not be able to drive a source-chain RPC
// poll at an address it chooses. The poll answers JSON; the SPA answers
// HTML, so a JSON body here means the handler ran.
if json.Valid(resp) {
t.Errorf("POST /v1/bridge/check-deposit on the public listener ran the poll; body=%s", resp)
}
}
+11 -3
View File
@@ -22,12 +22,12 @@ import (
"testing"
"time"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
"github.com/luxfi/bridge/internal/bchain"
"github.com/luxfi/bridge/internal/depositcheck"
"github.com/luxfi/bridge/internal/mchain"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
// =============================================================================
@@ -47,10 +47,18 @@ type testRig struct {
}
// listeners builds the pair for an already-wired API.
// listeners builds the two apps main.go builds, in the order main.go builds
// them. The order is the point: routes match as registered, and main.go hangs a
// SPA catch-all at /* after everything else (main.go:1121). A rig without it
// answers 404 where production answers the SPA, so a test asserting == 404
// passes for a reason production does not share.
func listeners(api *API) (public, admin *zip.App) {
public = zip.New(zip.Config{AppName: "lux-bridge-test", DisableStartupMessage: true})
public.Use(middleware.Recover(), middleware.RequestID())
api.Register(public)
public.All("/*", func(c *zip.Ctx) error {
return c.String(http.StatusOK, "<!doctype html><title>spa</title>")
})
admin = zip.New(zip.Config{AppName: "lux-bridge-test-admin", DisableStartupMessage: true})
admin.Use(middleware.Recover(), middleware.RequestID())
api.RegisterAdmin(admin)