bridge: mount /admin/swaps only when asked

Both handlers rewind or overwrite any swap by id and authenticate
nobody. Their own comments said "operator-only ... no auth ... do not
expose externally", but they were registered beside the public routes,
so what actually kept them unreachable was that they sit at the app
root while the edge forwards /v1/bridge and /api. That is a property of
the ingress rules rather than of this program.

BRIDGE_ADMIN_ROUTES mounts them. Off by default, so an unrouted path
answers 404 because the route does not exist.

Tested as a pair: absent by default, present when enabled. Either alone
would pass if the flag mounted nothing. Both use a seeded swap, since
the handler answers an unknown id with the same 404 the router gives an
unmounted route.
This commit is contained in:
2026-08-30 17:10:41 -07:00
parent 13b6b73cbc
commit 1863dc3653
4 changed files with 128 additions and 2 deletions
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"context"
"net/http"
"testing"
"time"
"github.com/zap-proto/zip"
middleware "github.com/zap-proto/zip/middleware"
"github.com/luxfi/bridge/internal/bchain"
)
// The two /admin/swaps handlers mutate any swap by id and authenticate nobody —
// their own comments say "operator-only … no auth … do not expose externally".
// They were mounted on the same app as the public routes, so the only thing
// keeping them unreachable was that they sit at the app root while the edge
// forwards /v1/bridge and /api. That is a property of the ingress rules, not of
// this program, and it holds until someone adds a catch-all.
//
// The pair below is the difference between a comment and a control. The first
// fails if the routes come back by default. The second fails if the flag stops
// mounting them — which would otherwise make the first pass for a reason that
// has nothing to do with the gate.
//
// Both use a swap that exists. An absent id is answered 404 by the handler
// itself, which is the same status the router gives for a route that was never
// mounted, so an unknown id cannot tell "gated" from "reachable and empty".
const seededSwap = "swap-under-test"
func adminRig(t *testing.T, enable bool) *zip.App {
t.Helper()
store := NewInMemoryStore()
if err := store.Create(context.Background(), &Swap{
ID: seededSwap,
Status: SwapStatusCompleted,
}); err != nil {
t.Fatalf("seed swap: %v", err)
}
// A bchain client is required, not decorative: the whole native-swap block
// — admin routes included — is behind `a.store != nil && a.bchain != nil`.
// It never dials; the router is what is under test.
api := NewAPI(Config{}, "", bchain.New("http://127.0.0.1:0", time.Second), nil, nil, store)
if enable {
api.EnableAdminRoutes()
}
app := zip.New(zip.Config{AppName: "lux-bridge-test-admin", DisableStartupMessage: true})
app.Use(middleware.Recover(), middleware.RequestID())
api.Register(app)
return app
}
// Mounted at the app root, not under /v1/bridge or /api.
var adminPaths = []string{
"/admin/swaps/" + seededSwap + "/reset",
"/admin/swaps/" + seededSwap + "/inject-raw-tx",
}
func TestAdminSwapRoutesAreAbsentByDefault(t *testing.T) {
app := adminRig(t, false)
for _, path := range adminPaths {
status, _ := fireRequest(t, app, "POST", path, nil)
if status != http.StatusNotFound {
t.Fatalf("POST %s answered %d with the flag off — an uncredentialed swap mutator is reachable by default", path, status)
}
}
}
func TestAdminSwapRoutesMountWhenAskedFor(t *testing.T) {
app := adminRig(t, true)
for _, path := range adminPaths {
status, _ := fireRequest(t, app, "POST", path, nil)
if status == http.StatusNotFound {
t.Fatalf("POST %s answered 404 with the flag on — the flag mounts nothing, so the default-off test passes for the wrong reason", path)
}
}
}
// And the thing the gate is actually protecting: with the routes mounted,
// anyone who can reach the listener can rewind a swap's status and blank its
// signature. No header, no token, no session. This test states that plainly, so
// the default-off posture above reads as a decision rather than a preference.
func TestResetRewindsASwapWithNoCredential(t *testing.T) {
app := adminRig(t, true)
status, _ := fireRequest(t, app, "POST", "/admin/swaps/"+seededSwap+"/reset", nil)
if status != http.StatusOK {
t.Fatalf("reset answered %d; expected the handler to accept an unauthenticated caller", status)
}
}
+26 -2
View File
@@ -45,6 +45,10 @@ type API struct {
mchain *mchain.Client // optional; when set, swap creation with use_deposit_address=true mints an MPC address
depcheck *depositcheck.Client // optional; powers the /v1/bridge/check-deposit diagnostic endpoint
// adminRoutes mounts the uncredentialed /admin/swaps handlers. Off unless
// BRIDGE_ADMIN_ROUTES is set, because they take any swap id and no proof.
adminRoutes bool
// releaseStore is the per-destination-network release-wallet
// registry. Optional: when set, swapsCreateNative stamps the new
// swap with the long-lived MPC wallet that will pay out the
@@ -135,6 +139,11 @@ func NewAPI(
// main.go after config + flags are parsed; the default is
// BridgeClassicalCompat (the user-facing bridge UI talks to external
// L1s on classical primitives).
// EnableAdminRoutes mounts the operator handlers. They authenticate nobody, so
// this is a decision to make once, deliberately, on a listener that is not
// public — never a default.
func (a *API) EnableAdminRoutes() { a.adminRoutes = true }
func (a *API) SetProfile(p *bridge.BridgeProfile) {
if p != nil {
a.profile = p
@@ -203,12 +212,27 @@ func (a *API) Register(app *zip.App) {
// Admin: force a swap to re-sign (used to recover after a
// destination-chain reject of the previously-built raw tx).
app.Post("/admin/swaps/:id/reset", a.swapsResetNative)
// Registered only when asked for. Both handlers below mutate any
// swap by id with no credential — their own comments say
// "operator-only … no auth … do not expose externally", and a
// comment is not a control: they were mounted on the same app as
// the public routes, and the only thing keeping them unreachable
// was that the edge happened not to route /admin. That is an
// accident of ingress, not a decision, and it changes with an
// unrelated edit.
//
// Off by default, so production serves 404 because the route does
// not exist rather than because something upstream declined to
// forward it. An operator who needs them sets BRIDGE_ADMIN_ROUTES
// deliberately, and that is a decision with a record.
if a.adminRoutes {
app.Post("/admin/swaps/:id/reset", a.swapsResetNative)
// Admin: inject a caller-supplied DestRawTx so the broadcast
// driver can push it on the next tick. Used when mpcd dedupes
// a re-sign request but the operator already has a corrected
// raw tx (e.g. low-s canonicalization of a prior signature).
app.Post("/admin/swaps/:id/inject-raw-tx", a.swapsInjectRawTxNative)
app.Post("/admin/swaps/:id/inject-raw-tx", a.swapsInjectRawTxNative)
}
} else {
app.All("/v1/bridge/quote", proxied)
app.All("/v1/bridge/swaps", proxied)
+6
View File
@@ -114,6 +114,8 @@ func main() {
"Tenant identifier the MPC daemon multiplexes keygen by. Default \"bridge\".")
depCheckTimeout := flag.Duration("deposit-check-timeout", 10*time.Second,
"per-request timeout for the /v1/bridge/check-deposit ops endpoint (source-chain RPC poll)")
adminRoutes := flag.Bool("admin-routes", envOr("BRIDGE_ADMIN_ROUTES", "") != "",
"mount /admin/swaps/:id/{reset,inject-raw-tx}. These take any swap id and no credential — their own comments say do not expose externally — so they are off unless asked for, and belong on a listener that is not public.")
disableDepositCheck := flag.Bool("disable-deposit-check", false,
"disable the /v1/bridge/check-deposit ops endpoint entirely (default: enabled)")
sourceRPCOverrides := flag.String("source-rpc-overrides", envOr("BRIDGE_SOURCE_RPC_OVERRIDES", ""),
@@ -1097,6 +1099,10 @@ func main() {
})
// API routes (/v1/bridge/* + /metrics).
if *adminRoutes {
api.EnableAdminRoutes()
logger.Warn("admin swap routes mounted", "note", "no credential, any swap id; keep this listener off the public edge")
}
api.Register(app)
// SPA + brand assets — install the existing http.Handler as a
+5
View File
@@ -107,6 +107,11 @@ func newRigCfg(t *testing.T, cfg Config, bclient *bchain.Client) *testRig {
}
store := NewInMemoryStore()
api := NewAPI(cfg, "", bclient, nil, nil, store)
// The /admin/swaps handlers are off unless asked for (see admin_routes_test.go).
// Tests that exercise them ask here; without this the router answers 404 and a
// test of the inject handler's withdrawal refusal would be reading the
// router's answer rather than the handler's.
api.EnableAdminRoutes()
app := zip.New(zip.Config{AppName: "lux-bridge-test", DisableStartupMessage: true})
app.Use(middleware.Recover(), middleware.RequestID())
api.Register(app)