// Package node — HTTP-level guards: body-size limits, timestamp windows, and // a tiny per-IP token-bucket rate limiter. // // These are intentionally lightweight, dependency-free, and fail open if // misconfigured. They do not replace proper production fronting (reverse // proxy with rate-limit module), but they close the most obvious abuse // vectors when the node is exposed directly. package node import ( "fmt" "net" "net/http" "strings" "sync" "time" "go-blockchain/blockchain" ) // ─── Limits ────────────────────────────────────────────────────────────────── // MaxTxRequestBytes caps the size of a single POST /api/tx body. A signed // transaction with a modest payload sits well under 16 KiB; we allow 64 KiB // to accommodate small WASM deploys submitted by trusted signers. // // Note: DEPLOY_CONTRACT with a large WASM binary uses the same endpoint and // therefore the same cap. If you deploy bigger contracts, raise this cap on // the nodes that accept deploys, or (better) add a dedicated upload route. const MaxTxRequestBytes int64 = 64 * 1024 // TxTimestampSkew is the maximum accepted deviation between a transaction's // declared timestamp and the node's current wall clock. Transactions outside // the window are rejected at the API layer to harden against replay + clock // skew attacks (we do not retain old txIDs forever, so a very old but // otherwise valid tx could otherwise slip in after its dedup entry rotates). const TxTimestampSkew = 1 * time.Hour // ValidateTxTimestamp returns an error if tx.Timestamp is further than // TxTimestampSkew from now. The zero-time is also rejected. // // Exported so the WS gateway (and other in-process callers) can reuse the // same validation as the HTTP /api/tx path without importing the file-local // helper. func ValidateTxTimestamp(tx *blockchain.Transaction) error { if tx.Timestamp.IsZero() { return fmt.Errorf("timestamp is required") } now := time.Now().UTC() delta := tx.Timestamp.Sub(now) if delta < 0 { delta = -delta } if delta > TxTimestampSkew { return fmt.Errorf("timestamp %s is outside ±%s window of current time %s", tx.Timestamp.Format(time.RFC3339), TxTimestampSkew, now.Format(time.RFC3339)) } return nil } // ─── Rate limiter ──────────────────────────────────────────────────────────── // ipRateLimiter is a best-effort token bucket keyed by source IP. Buckets are // created lazily and garbage-collected by a background sweep. type ipRateLimiter struct { rate float64 // tokens added per second burst float64 // bucket capacity sweepEvery time.Duration // how often to GC inactive buckets inactiveTTL time.Duration // drop buckets idle longer than this mu sync.Mutex buckets map[string]*bucket stopCh chan struct{} } type bucket struct { tokens float64 lastSeen time.Time } // newIPRateLimiter constructs a limiter: each IP gets `burst` tokens that refill // at `rate` per second. func newIPRateLimiter(rate, burst float64) *ipRateLimiter { l := &ipRateLimiter{ rate: rate, burst: burst, sweepEvery: 2 * time.Minute, inactiveTTL: 10 * time.Minute, buckets: make(map[string]*bucket), stopCh: make(chan struct{}), } go l.sweepLoop() return l } // Allow deducts one token for the given IP; returns false if the bucket is empty. func (l *ipRateLimiter) Allow(ip string) bool { now := time.Now() l.mu.Lock() defer l.mu.Unlock() b, ok := l.buckets[ip] if !ok { b = &bucket{tokens: l.burst, lastSeen: now} l.buckets[ip] = b } elapsed := now.Sub(b.lastSeen).Seconds() b.tokens += elapsed * l.rate if b.tokens > l.burst { b.tokens = l.burst } b.lastSeen = now if b.tokens < 1 { return false } b.tokens-- return true } func (l *ipRateLimiter) sweepLoop() { tk := time.NewTicker(l.sweepEvery) defer tk.Stop() for { select { case <-l.stopCh: return case now := <-tk.C: l.mu.Lock() for ip, b := range l.buckets { if now.Sub(b.lastSeen) > l.inactiveTTL { delete(l.buckets, ip) } } l.mu.Unlock() } } } // clientIP extracts the best-effort originating IP. X-Forwarded-For is // respected but only the leftmost entry is trusted; do not rely on this // behind untrusted proxies (configure a real reverse proxy for production). func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if i := strings.IndexByte(xff, ','); i >= 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } if xr := r.Header.Get("X-Real-IP"); xr != "" { return strings.TrimSpace(xr) } ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return ip } // Package-level singletons (best-effort; DoS hardening is a defense-in-depth // measure, not a primary security boundary). var ( // Allow up to 10 tx submissions/s per IP with a burst of 20. submitTxLimiter = newIPRateLimiter(10, 20) // Inbox / contacts polls: allow 20/s per IP with a burst of 40. readLimiter = newIPRateLimiter(20, 40) ) // withSubmitTxGuards composes size + rate-limit protection around apiSubmitTx. // It rejects oversize bodies and flooding IPs before any JSON decoding happens, // so an attacker cannot consume CPU decoding 10 MB of nothing. func withSubmitTxGuards(inner http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { ip := clientIP(r) if !submitTxLimiter.Allow(ip) { w.Header().Set("Retry-After", "2") jsonErr(w, fmt.Errorf("rate limit exceeded"), http.StatusTooManyRequests) return } r.Body = http.MaxBytesReader(w, r.Body, MaxTxRequestBytes) } inner.ServeHTTP(w, r) } } // withReadLimit is the equivalent for inbox / contacts read endpoints. It only // applies rate limiting (no body size cap needed for GETs). func withReadLimit(inner http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !readLimiter.Allow(clientIP(r)) { w.Header().Set("Retry-After", "1") jsonErr(w, fmt.Errorf("rate limit exceeded"), http.StatusTooManyRequests) return } inner.ServeHTTP(w, r) } } // ── Access-token gating ────────────────────────────────────────────────────── // // Single-node operators often want their node "private" — only they (and // apps they explicitly configure) can submit transactions or read state // through its HTTP API. Two flavours: // // 1. Semi-public: anyone can GET chain state (netstats, blocks, txs), // but only clients with a valid Bearer token can POST /api/tx or // send WS `submit_tx`. Default when `--api-token` is set. // // 2. Fully private: EVERY endpoint requires the token. Use `--api-private` // together with `--api-token`. Good for a personal node whose data // the operator considers sensitive (e.g. who they're chatting with). // // Both modes gate via a shared secret (HTTP `Authorization: Bearer ` // or WS hello-time check). There's no multi-user access control here — // operators wanting role-based auth should front the node with their // usual reverse-proxy auth (mTLS, OAuth, basicauth). This is the // "just keep randos off my box" level of access control. var ( // accessToken is the shared secret required by gated endpoints. // Empty = gating disabled. accessToken string // accessPrivate gates READ endpoints too, not just write. Only // applies when accessToken is non-empty. accessPrivate bool ) // SetAPIAccess configures the token + private-mode flags. Called once at // node startup. Pass empty token to disable gating entirely (public node). func SetAPIAccess(token string, private bool) { accessToken = strings.TrimSpace(token) accessPrivate = private } // checkAccessToken returns nil if the request carries the configured // Bearer token, or an error describing the problem. When accessToken // is empty (public node) it always returns nil. // // Accepts `Authorization: Bearer ` or the `?token=` query // parameter — the latter lets operators open `https://node.example.com/ // api/netstats?token=...` directly in a browser while testing a private // node without needing custom headers. func checkAccessToken(r *http.Request) error { if accessToken == "" { return nil } // Header takes precedence. auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { if strings.TrimPrefix(auth, "Bearer ") == accessToken { return nil } return fmt.Errorf("invalid bearer token") } if q := r.URL.Query().Get("token"); q != "" { if q == accessToken { return nil } return fmt.Errorf("invalid ?token= query param") } return fmt.Errorf("missing bearer token; pass Authorization: Bearer or ?token=...") } // withWriteTokenGuard wraps a write handler (submit tx, etc.) so it // requires the access token whenever one is configured. Independent of // accessPrivate — writes are ALWAYS gated when a token is set. func withWriteTokenGuard(inner http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := checkAccessToken(r); err != nil { w.Header().Set("WWW-Authenticate", `Bearer realm="dchain"`) jsonErr(w, err, http.StatusUnauthorized) return } inner.ServeHTTP(w, r) } } // withReadTokenGuard gates a read endpoint. Only enforced when // accessPrivate is true; otherwise falls through to the inner handler // so public nodes keep working as before. func withReadTokenGuard(inner http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if accessToken != "" && accessPrivate { if err := checkAccessToken(r); err != nil { w.Header().Set("WWW-Authenticate", `Bearer realm="dchain"`) jsonErr(w, err, http.StatusUnauthorized) return } } inner.ServeHTTP(w, r) } } // AccessTokenForWS returns the current token + private-mode flag so the // WS hub can apply the same policy to websocket submit_tx ops and, when // private, to connection upgrades themselves. func AccessTokenForWS() (token string, private bool) { return accessToken, accessPrivate }