Files
dchain/node/api_relay.go
vsecoder 8082dd0bf7 fix(node): rate-limit relay HTTP endpoints
Relay routes were not wrapped in any guards — /relay/broadcast accepted
unlimited writes from any IP, and /relay/inbox could be scraped at line
rate. Combined with the per-recipient FIFO eviction (MailboxPerRecipientCap=500),
an unauthenticated attacker could wipe a victim's real messages by
spamming 500 garbage envelopes. This commit wraps writes in
withSubmitTxGuards (10/s per IP + 256 KiB body cap) and reads in
withReadLimit (20/s per IP) — the same limits already used for
/api/tx and /api/address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:54:08 +03:00

335 lines
9.5 KiB
Go

package node
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"go-blockchain/blockchain"
"go-blockchain/relay"
)
// RelayConfig holds dependencies for the relay HTTP API.
type RelayConfig struct {
Mailbox *relay.Mailbox
// Send seals a message for recipientX25519PubHex and broadcasts it.
// Returns the envelope ID. nil disables POST /relay/send.
Send func(recipientPubHex string, msg []byte) (string, error)
// Broadcast publishes a pre-sealed Envelope on gossipsub and stores it in the mailbox.
// nil disables POST /relay/broadcast.
Broadcast func(env *relay.Envelope) error
// ContactRequests returns incoming contact records for the given Ed25519 pubkey.
ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error)
}
// registerRelayRoutes wires relay mailbox endpoints onto mux.
//
// POST /relay/send {recipient_pub, msg_b64}
// POST /relay/broadcast {envelope: <Envelope JSON>}
// GET /relay/inbox ?pub=<x25519hex>[&since=<unix_ts>][&limit=N]
// GET /relay/inbox/count ?pub=<x25519hex>
// DELETE /relay/inbox/{envID} ?pub=<x25519hex>
// GET /relay/contacts ?pub=<ed25519hex>
func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
// Writes go through withSubmitTxGuards: per-IP rate limit (10/s, burst 20)
// + 256 KiB body cap. Without these, a single attacker could spam
// 500 envelopes per victim in a few seconds and evict every real message
// via the mailbox FIFO cap.
mux.HandleFunc("/relay/send", withSubmitTxGuards(relaySend(rc)))
mux.HandleFunc("/relay/broadcast", withSubmitTxGuards(relayBroadcast(rc)))
// Reads go through withReadLimit: per-IP rate limit (20/s, burst 40).
// Protects against inbox-scraping floods from a single origin.
mux.HandleFunc("/relay/inbox/count", withReadLimit(relayInboxCount(rc)))
mux.HandleFunc("/relay/inbox/", withReadLimit(relayInboxDelete(rc)))
mux.HandleFunc("/relay/inbox", withReadLimit(relayInboxList(rc)))
mux.HandleFunc("/relay/contacts", withReadLimit(relayContacts(rc)))
}
// relayInboxList handles GET /relay/inbox?pub=<hex>[&since=<ts>][&limit=N]
func relayInboxList(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
pub := r.URL.Query().Get("pub")
if pub == "" {
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
return
}
since := int64(0)
if s := r.URL.Query().Get("since"); s != "" {
if v, err := parseInt64(s); err == nil && v > 0 {
since = v
}
}
limit := queryIntMin0(r, "limit")
if limit == 0 {
limit = 50
}
envelopes, err := rc.Mailbox.List(pub, since, limit)
if err != nil {
jsonErr(w, err, 500)
return
}
type item struct {
ID string `json:"id"`
SenderPub string `json:"sender_pub"`
RecipientPub string `json:"recipient_pub"`
FeeUT uint64 `json:"fee_ut,omitempty"`
SentAt int64 `json:"sent_at"`
SentAtHuman string `json:"sent_at_human"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
}
out := make([]item, 0, len(envelopes))
for _, env := range envelopes {
out = append(out, item{
ID: env.ID,
SenderPub: env.SenderPub,
RecipientPub: env.RecipientPub,
FeeUT: env.FeeUT,
SentAt: env.SentAt,
SentAtHuman: time.Unix(env.SentAt, 0).UTC().Format(time.RFC3339),
Nonce: env.Nonce,
Ciphertext: env.Ciphertext,
})
}
hasMore := len(out) == limit
jsonOK(w, map[string]any{
"pub": pub,
"count": len(out),
"has_more": hasMore,
"items": out,
})
}
}
// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<hex>
func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
// Also serve GET /relay/inbox/{id} for convenience (fetch single envelope)
if r.Method == http.MethodGet {
relayInboxList(rc)(w, r)
return
}
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
envID := strings.TrimPrefix(r.URL.Path, "/relay/inbox/")
if envID == "" {
jsonErr(w, fmt.Errorf("envelope ID required in path"), 400)
return
}
pub := r.URL.Query().Get("pub")
if pub == "" {
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
return
}
if err := rc.Mailbox.Delete(pub, envID); err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]string{"id": envID, "status": "deleted"})
}
}
// relayInboxCount handles GET /relay/inbox/count?pub=<hex>
func relayInboxCount(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
pub := r.URL.Query().Get("pub")
if pub == "" {
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
return
}
count, err := rc.Mailbox.Count(pub)
if err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]any{"pub": pub, "count": count})
}
}
// relaySend handles POST /relay/send
//
// Request body:
//
// {
// "recipient_pub": "<hex X25519 pub key>",
// "msg_b64": "<base64-encoded plaintext>",
// }
//
// WARNING — NOT END-TO-END ENCRYPTED.
// The relay node seals the message using its OWN X25519 keypair, not the
// sender's. That means:
// - The relay can read the plaintext (msg_b64 arrives in the clear).
// - The recipient cannot authenticate the sender — they only see "a
// message from the relay".
// For real E2E messaging, clients should seal the envelope themselves and
// use POST /relay/broadcast instead. This endpoint is retained only for
// backwards compatibility with legacy integrations and for bootstrap
// scenarios where the sender doesn't have a long-lived X25519 key yet.
func relaySend(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
if rc.Send == nil {
jsonErr(w, fmt.Errorf("relay send not available on this node"), 503)
return
}
var req struct {
RecipientPub string `json:"recipient_pub"`
MsgB64 string `json:"msg_b64"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
return
}
if req.RecipientPub == "" {
jsonErr(w, fmt.Errorf("recipient_pub is required"), 400)
return
}
if req.MsgB64 == "" {
jsonErr(w, fmt.Errorf("msg_b64 is required"), 400)
return
}
msg, err := decodeBase64(req.MsgB64)
if err != nil {
jsonErr(w, fmt.Errorf("msg_b64: %w", err), 400)
return
}
if len(msg) == 0 {
jsonErr(w, fmt.Errorf("msg_b64: empty message"), 400)
return
}
envID, err := rc.Send(req.RecipientPub, msg)
if err != nil {
jsonErr(w, fmt.Errorf("send failed: %w", err), 500)
return
}
jsonOK(w, map[string]string{
"id": envID,
"recipient_pub": req.RecipientPub,
"status": "sent",
})
}
}
// decodeBase64 accepts both standard and URL-safe base64.
func decodeBase64(s string) ([]byte, error) {
// Try URL-safe first (no padding required), then standard.
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return b, nil
}
return base64.StdEncoding.DecodeString(s)
}
// relayBroadcast handles POST /relay/broadcast
//
// Request body: {"envelope": <relay.Envelope JSON>}
//
// Light clients use this to publish pre-sealed envelopes without a direct
// libp2p connection. The relay node stores it in the mailbox and gossips it.
func relayBroadcast(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
if rc.Broadcast == nil {
jsonErr(w, fmt.Errorf("relay broadcast not available on this node"), 503)
return
}
var req struct {
Envelope *relay.Envelope `json:"envelope"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
return
}
if req.Envelope == nil {
jsonErr(w, fmt.Errorf("envelope is required"), 400)
return
}
if req.Envelope.ID == "" {
jsonErr(w, fmt.Errorf("envelope.id is required"), 400)
return
}
if len(req.Envelope.Ciphertext) == 0 {
jsonErr(w, fmt.Errorf("envelope.ciphertext is required"), 400)
return
}
if err := rc.Broadcast(req.Envelope); err != nil {
jsonErr(w, fmt.Errorf("broadcast failed: %w", err), 500)
return
}
jsonOK(w, map[string]string{
"id": req.Envelope.ID,
"status": "broadcast",
})
}
}
// relayContacts handles GET /relay/contacts?pub=<ed25519hex>
//
// Returns all incoming contact requests for the given Ed25519 public key.
func relayContacts(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
if rc.ContactRequests == nil {
jsonErr(w, fmt.Errorf("contacts not available on this node"), 503)
return
}
pub := r.URL.Query().Get("pub")
if pub == "" {
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
return
}
contacts, err := rc.ContactRequests(pub)
if err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]any{
"pub": pub,
"count": len(contacts),
"contacts": contacts,
})
}
}
// parseInt64 parses a string as int64.
func parseInt64(s string) (int64, error) {
var v int64
if err := json.Unmarshal([]byte(s), &v); err != nil {
return 0, err
}
return v, nil
}