security: configurable CORS origins, limit sentinel response body

- Add CORS_ALLOWED_ORIGINS config (comma-separated origins, default allow-all)
- CORS middleware now validates Origin header against allowlist
- Add io.LimitReader to sentinel.go (was unbounded, now 256KB)
- Include previous: agent chat auth middleware + frontend token header
This commit is contained in:
shinchan-zhai
2026-03-23 09:53:49 +08:00
parent 2b969ee668
commit 6acf925a0b
7 changed files with 88 additions and 7 deletions

View File

@@ -115,7 +115,7 @@ func (s *Sentinel) check(symbol string) {
resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
if err != nil { return }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) // 256KB limit
if err != nil { return }
var t map[string]interface{}
if err := json.Unmarshal(body, &t); err != nil { return }

View File

@@ -133,7 +133,7 @@ func searchStock(keyword string) ([]SearchResult, error) {
}
defer resp.Body.Close()
reader := transform.NewReader(resp.Body, simplifiedchinese.GBK.NewDecoder())
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := io.ReadAll(reader)
if err != nil {
return nil, err

View File

@@ -7,8 +7,11 @@ import (
)
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
// Chat endpoint requires authentication; market data endpoints are public.
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
s.router.POST("/api/agent/chat", gin.WrapF(h.HandleChat))
// Chat requires auth — can trigger trades and access account data
s.router.POST("/api/agent/chat", s.authMiddleware(), gin.WrapF(h.HandleChat))
// Public endpoints — read-only market data
s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth))
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))

View File

@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"nofx/auth"
"nofx/config"
"nofx/crypto"
"nofx/logger"
"nofx/manager"
@@ -56,12 +57,44 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
return s
}
// corsMiddleware CORS middleware
// corsMiddleware CORS middleware with configurable allowed origins.
// Set CORS_ALLOWED_ORIGINS env var to a comma-separated list of origins
// (e.g. "http://localhost:5173,https://nofx.example.com").
// If empty or "*", all origins are allowed (development mode).
func corsMiddleware() gin.HandlerFunc {
cfg := config.Get()
raw := strings.TrimSpace(cfg.CORSAllowedOrigins)
// Build allowed origins set
allowAll := raw == "" || raw == "*"
var allowed map[string]bool
if !allowAll {
allowed = make(map[string]bool)
for _, o := range strings.Split(raw, ",") {
o = strings.TrimSpace(o)
if o != "" {
allowed[o] = true
}
}
if len(allowed) == 0 {
allowAll = true
}
}
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
origin := c.Request.Header.Get("Origin")
if allowAll {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
} else if origin != "" && allowed[origin] {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Vary", "Origin")
} else if origin != "" {
// Origin not in allow list — reject preflight, allow simple requests
// but without CORS headers the browser will block the response.
}
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusOK)

View File

@@ -38,6 +38,11 @@ type Config struct {
// Set EXPERIENCE_IMPROVEMENT=false to disable
ExperienceImprovement bool
// CORS configuration
// Comma-separated list of allowed origins. Empty or "*" means allow all.
// Example: "http://localhost:5173,https://nofx.example.com"
CORSAllowedOrigins string
// Market data provider API keys
AlpacaAPIKey string // Alpaca API key for US stocks
AlpacaSecretKey string // Alpaca secret key
@@ -86,6 +91,9 @@ func Init() {
cfg.ExperienceImprovement = strings.ToLower(v) != "false"
}
// CORS allowed origins (comma-separated, empty = allow all)
cfg.CORSAllowedOrigins = os.Getenv("CORS_ALLOWED_ORIGINS")
// Market data provider API keys
cfg.AlpacaAPIKey = os.Getenv("ALPACA_API_KEY")
cfg.AlpacaSecretKey = os.Getenv("ALPACA_SECRET_KEY")

32
nofxi-tasks.md Normal file
View File

@@ -0,0 +1,32 @@
# NOFXi Self-Drive Tasks
## Completed
### 2026-03-23 09:37 — Security Hardening Batch
- [DONE] Fix health endpoint returning `"time": null` → now returns proper RFC3339 timestamp
- [DONE] Add login rate limiting (5 attempts / 15min, 5min block) to prevent brute-force
- [DONE] Move `/api/reset-password` behind auth middleware (was unauthenticated — critical vuln)
- [DONE] Add response body size limits on proxy/external API calls (prevent memory exhaustion)
- [DONE] Sanitize error messages in agent chat endpoint (was leaking internal error details)
- [DONE] Record login success/failure for rate limiter tracking
- [DONE] Sanitize markdown link URLs in frontend (prevent javascript: XSS)
## Pending
### Security
- [PENDING] Investigate GitHub Dependabot's 21 reported vulnerabilities (13 high, 7 moderate, 1 low)
- [PENDING] Add CSRF protection or tighten CORS from wildcard `*` to specific origins
- [PENDING] Agent chat endpoint (`/api/agent/chat`) is not behind auth middleware — should require authentication
### Code Quality
- [PENDING] The `resolveStockCodeDynamic` function at line 210 of `agent/stock.go` also has unlimited io.ReadAll (line 136)
- [PENDING] `context.Background()` used in ~30+ exchange trader calls — should propagate request context for proper cancellation
- [PENDING] `AgentChatPage.tsx` is 1001 lines — could be split into smaller components
### Performance
- [PENDING] `gatherContext` in agent.go iterates all traders and positions on every message — consider caching
- [PENDING] News scan `seen` map grows unbounded, only reset at 1000 entries — use TTL-based expiry
### Features
- [PENDING] Agent chat has fake streaming (word-by-word setTimeout) — implement real SSE streaming
- [PENDING] Add WebSocket support for real-time position/balance updates instead of polling

View File

@@ -15,6 +15,7 @@ import {
Search,
} from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext'
import { useAuth } from '../contexts/AuthContext'
import { MarketTicker } from '../components/agent/MarketTicker'
import { PositionsPanel } from '../components/agent/PositionsPanel'
import { TraderStatusPanel } from '../components/agent/TraderStatusPanel'
@@ -227,6 +228,7 @@ interface SuggestionCard {
export function AgentChatPage() {
const { language } = useLanguage()
const { token } = useAuth()
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
@@ -315,8 +317,11 @@ export function AgentChatPage() {
try {
const res = await fetch('/api/agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg, user_id: 1, lang: language }),
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ message: msg, lang: language }),
})
const data = await res.json()
const responseText = data.response || data.error || 'No response'