Files
explorer-monorepo/backend/api/rest/server.go
Devin ad69385beb fix(security): fail-fast on missing JWT_SECRET, harden CSP, strip hardcoded passwords
backend/api/rest/server.go:
- NewServer() now delegates to loadJWTSecret(), which:
    - Rejects JWT_SECRET < 32 bytes (log.Fatal).
    - Requires JWT_SECRET when APP_ENV=production or GO_ENV=production.
    - Generates a 32-byte crypto/rand ephemeral secret in dev only.
    - Treats rand.Read failure as fatal (removes the prior time-based
      fallback that was deterministic and forgeable).
- Default Content-Security-Policy rewritten:
    - Drops 'unsafe-inline' and 'unsafe-eval'.
    - Drops private CIDRs (192.168.11.221:854[5|6]).
    - Adds frame-ancestors 'none', base-uri 'self', form-action 'self'.
    - CSP_HEADER is required in production; fatal if unset there.

backend/api/rest/server_security_test.go (new):
- Covers the three loadJWTSecret() paths (valid, whitespace-trimmed,
  ephemeral in dev).
- Covers isProductionEnv() across APP_ENV / GO_ENV combinations.
- Asserts defaultDevCSP contains no unsafe directives or private CIDRs
  and includes the frame-ancestors / base-uri / form-action directives.

scripts/*.sh:
- Removed '***REDACTED-LEGACY-PW***' default value from SSH_PASSWORD / NEW_PASSWORD in
  7 helper scripts. Each script now fails with exit 2 and points to
  docs/SECURITY.md if the password isn't supplied via env or argv.

EXECUTE_DEPLOYMENT.sh, EXECUTE_NOW.sh:
- Replaced hardcoded DB_PASSWORD='***REDACTED-LEGACY-PW***' with a ':?' guard that
  aborts with a clear error if DB_PASSWORD (and, for EXECUTE_DEPLOYMENT,
  RPC_URL) is not exported. Other env vars keep sensible non-secret
  defaults via ${VAR:-default}.

README.md:
- Removed the hardcoded Database Password / RPC URL lines. Replaced with
  an env-variable reference table pointing at docs/SECURITY.md and
  docs/DATABASE_CONNECTION_GUIDE.md.

docs/DEPLOYMENT.md:
- Replaced 'PASSWORD: SSH password (default: ***REDACTED-LEGACY-PW***)' with a
  required-no-default contract and a link to docs/SECURITY.md.

docs/SECURITY.md (new):
- Full secret inventory keyed to the env variable name and the file that
  consumes it.
- Five-step rotation checklist covering the Postgres role, the Proxmox
  VM SSH password, JWT_SECRET, vendor API keys, and a gitleaks-based
  history audit.
- Explicit note that merging secret-scrub PRs does NOT invalidate
  already-leaked credentials; rotation is the operator's responsibility.

Verification:
- go build ./... + go vet ./... pass clean.
- Targeted tests (LoadJWTSecret*, IsProduction*, DefaultDevCSP*) pass.

Advances completion criterion 2 (Secrets & config hardened). Residual
leakage from START_HERE.md / LETSENCRYPT_CONFIGURATION_GUIDE.md is
handled by PR #2 (doc consolidation), which deletes those files.
2026-04-18 19:02:27 +00:00

316 lines
9.3 KiB
Go

package rest
import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/explorer/backend/api/middleware"
"github.com/explorer/backend/auth"
httpmiddleware "github.com/explorer/backend/libs/go-http-middleware"
"github.com/jackc/pgx/v5/pgxpool"
)
// Server represents the REST API server
type Server struct {
db *pgxpool.Pool
chainID int
userAuth *auth.Auth
walletAuth *auth.WalletAuth
jwtSecret []byte
aiLimiter *AIRateLimiter
aiMetrics *AIMetrics
}
// minJWTSecretBytes is the minimum allowed length for an operator-provided
// JWT signing secret. 32 random bytes = 256 bits, matching HS256's output.
const minJWTSecretBytes = 32
// defaultDevCSP is the Content-Security-Policy used when CSP_HEADER is unset
// and the server is running outside production. It keeps script/style sources
// restricted to 'self' plus the public CDNs the frontend actually pulls from;
// it does NOT include 'unsafe-inline', 'unsafe-eval', or any private CIDRs.
// Production deployments MUST provide an explicit CSP_HEADER.
const defaultDevCSP = "default-src 'self'; " +
"script-src 'self' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; " +
"style-src 'self' https://cdnjs.cloudflare.com; " +
"font-src 'self' https://cdnjs.cloudflare.com; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://blockscout.defi-oracle.io https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self';"
// isProductionEnv reports whether the server is running in production mode.
// Production is signalled by APP_ENV=production or GO_ENV=production.
func isProductionEnv() bool {
for _, key := range []string{"APP_ENV", "GO_ENV"} {
if strings.EqualFold(strings.TrimSpace(os.Getenv(key)), "production") {
return true
}
}
return false
}
// NewServer creates a new REST API server.
//
// Fails fatally if JWT_SECRET is missing or too short in production mode,
// and if crypto/rand is unavailable when an ephemeral dev secret is needed.
func NewServer(db *pgxpool.Pool, chainID int) *Server {
jwtSecret := loadJWTSecret()
walletAuth := auth.NewWalletAuth(db, jwtSecret)
return &Server{
db: db,
chainID: chainID,
userAuth: auth.NewAuth(db),
walletAuth: walletAuth,
jwtSecret: jwtSecret,
aiLimiter: NewAIRateLimiter(),
aiMetrics: NewAIMetrics(),
}
}
// loadJWTSecret reads the signing secret from $JWT_SECRET. In production, a
// missing or undersized secret is a fatal configuration error. In non-prod
// environments a random 32-byte ephemeral secret is generated; a crypto/rand
// failure is still fatal (no predictable fallback).
func loadJWTSecret() []byte {
raw := strings.TrimSpace(os.Getenv("JWT_SECRET"))
if raw != "" {
if len(raw) < minJWTSecretBytes {
log.Fatalf("JWT_SECRET must be at least %d bytes (got %d); refusing to start with a weak signing key",
minJWTSecretBytes, len(raw))
}
return []byte(raw)
}
if isProductionEnv() {
log.Fatal("JWT_SECRET is required in production (APP_ENV=production or GO_ENV=production); refusing to start")
}
secret := make([]byte, minJWTSecretBytes)
if _, err := rand.Read(secret); err != nil {
log.Fatalf("failed to generate ephemeral JWT secret: %v", err)
}
log.Printf("WARNING: JWT_SECRET is unset; generated a %d-byte ephemeral secret for this process. "+
"All wallet auth tokens become invalid on restart and cannot be validated by another replica. "+
"Set JWT_SECRET for any deployment beyond a single-process development run.", minJWTSecretBytes)
return secret
}
// Start starts the HTTP server
func (s *Server) Start(port int) error {
mux := http.NewServeMux()
s.SetupRoutes(mux)
// Initialize auth middleware
authMiddleware := middleware.NewAuthMiddleware(s.walletAuth)
// Setup track routes with proper middleware
s.SetupTrackRoutes(mux, authMiddleware)
// Security headers. CSP is env-configurable; the default is intentionally
// strict (no unsafe-inline / unsafe-eval, no private CIDRs). Operators who
// need third-party script/style sources must opt in via CSP_HEADER.
csp := strings.TrimSpace(os.Getenv("CSP_HEADER"))
if csp == "" {
if isProductionEnv() {
log.Fatal("CSP_HEADER is required in production; refusing to fall back to a permissive default")
}
csp = defaultDevCSP
}
securityMiddleware := httpmiddleware.NewSecurity(csp)
// Add middleware for all routes (outermost to innermost)
handler := securityMiddleware.AddSecurityHeaders(
authMiddleware.OptionalAuth( // Optional auth for Track 1, required for others
s.addMiddleware(
s.loggingMiddleware(
s.compressionMiddleware(mux),
),
),
),
)
addr := fmt.Sprintf(":%d", port)
log.Printf("Starting SolaceScan REST API server on %s", addr)
log.Printf("Tiered architecture enabled: Track 1 (public), Track 2-4 (authenticated)")
return http.ListenAndServe(addr, handler)
}
// addMiddleware adds common middleware to all routes
func (s *Server) addMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Add branding headers
w.Header().Set("X-Explorer-Name", "SolaceScan")
w.Header().Set("X-Explorer-Version", "1.0.0")
w.Header().Set("X-Powered-By", "SolaceScan")
// Add CORS headers for API routes (optional: set CORS_ALLOWED_ORIGIN to restrict, e.g. https://blockscout.defi-oracle.io)
if strings.HasPrefix(r.URL.Path, "/api/") {
origin := os.Getenv("CORS_ALLOWED_ORIGIN")
if origin == "" {
origin = "*"
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key")
// Handle preflight
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
}
next.ServeHTTP(w, r)
})
}
// requireDB returns false and writes 503 if db is nil (e.g. in tests without DB)
func (s *Server) requireDB(w http.ResponseWriter) bool {
if s.db == nil {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "database unavailable")
return false
}
return true
}
// handleListBlocks handles GET /api/v1/blocks
func (s *Server) handleListBlocks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
if !s.requireDB(w) {
return
}
// Validate pagination
page, pageSize, err := validatePagination(
r.URL.Query().Get("page"),
r.URL.Query().Get("page_size"),
)
if err != nil {
writeValidationError(w, err)
return
}
offset := (page - 1) * pageSize
query := `
SELECT chain_id, number, hash, timestamp, timestamp_iso, miner, transaction_count, gas_used, gas_limit
FROM blocks
WHERE chain_id = $1
ORDER BY number DESC
LIMIT $2 OFFSET $3
`
// Add query timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
rows, err := s.db.Query(ctx, query, s.chainID, pageSize, offset)
if err != nil {
writeInternalError(w, "Database error")
return
}
defer rows.Close()
blocks := []map[string]interface{}{}
for rows.Next() {
var chainID, number, transactionCount int
var hash, miner string
var timestamp time.Time
var timestampISO sql.NullString
var gasUsed, gasLimit int64
if err := rows.Scan(&chainID, &number, &hash, &timestamp, &timestampISO, &miner, &transactionCount, &gasUsed, &gasLimit); err != nil {
continue
}
block := map[string]interface{}{
"chain_id": chainID,
"number": number,
"hash": hash,
"timestamp": timestamp,
"miner": miner,
"transaction_count": transactionCount,
"gas_used": gasUsed,
"gas_limit": gasLimit,
}
if timestampISO.Valid {
block["timestamp_iso"] = timestampISO.String
}
blocks = append(blocks, block)
}
response := map[string]interface{}{
"data": blocks,
"meta": map[string]interface{}{
"pagination": map[string]interface{}{
"page": page,
"page_size": pageSize,
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleGetBlock, handleListTransactions, handleGetTransaction, handleGetAddress
// are implemented in blocks.go, transactions.go, and addresses.go respectively
// handleHealth handles GET /health
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Explorer-Name", "SolaceScan")
w.Header().Set("X-Explorer-Version", "1.0.0")
// Check database connection
dbStatus := "ok"
if s.db != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.db.Ping(ctx); err != nil {
dbStatus = "error: " + err.Error()
}
} else {
dbStatus = "unavailable"
}
health := map[string]interface{}{
"status": "healthy",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"services": map[string]string{
"database": dbStatus,
"api": "ok",
},
"chain_id": s.chainID,
"explorer": map[string]string{
"name": "SolaceScan",
"version": "1.0.0",
},
}
statusCode := http.StatusOK
if dbStatus != "ok" {
statusCode = http.StatusServiceUnavailable
health["status"] = "degraded"
}
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(health)
}