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 'L@kers2010' 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='L@ker$2010' 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: L@kers2010)' 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.
316 lines
9.3 KiB
Go
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, ×tamp, ×tampISO, &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)
|
|
}
|