mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-16 01:06:59 +08:00
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:
@@ -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))
|
resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
|
||||||
if err != nil { return }
|
if err != nil { return }
|
||||||
defer resp.Body.Close()
|
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 }
|
if err != nil { return }
|
||||||
var t map[string]interface{}
|
var t map[string]interface{}
|
||||||
if err := json.Unmarshal(body, &t); err != nil { return }
|
if err := json.Unmarshal(body, &t); err != nil { return }
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func searchStock(keyword string) ([]SearchResult, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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)
|
body, err := io.ReadAll(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
|
// 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) {
|
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/health", gin.WrapF(h.HandleHealth))
|
||||||
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
|
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
|
||||||
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))
|
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"nofx/auth"
|
"nofx/auth"
|
||||||
|
"nofx/config"
|
||||||
"nofx/crypto"
|
"nofx/crypto"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/manager"
|
"nofx/manager"
|
||||||
@@ -56,12 +57,44 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
|
|||||||
return s
|
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 {
|
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) {
|
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-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
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" {
|
if c.Request.Method == "OPTIONS" {
|
||||||
c.AbortWithStatus(http.StatusOK)
|
c.AbortWithStatus(http.StatusOK)
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ type Config struct {
|
|||||||
// Set EXPERIENCE_IMPROVEMENT=false to disable
|
// Set EXPERIENCE_IMPROVEMENT=false to disable
|
||||||
ExperienceImprovement bool
|
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
|
// Market data provider API keys
|
||||||
AlpacaAPIKey string // Alpaca API key for US stocks
|
AlpacaAPIKey string // Alpaca API key for US stocks
|
||||||
AlpacaSecretKey string // Alpaca secret key
|
AlpacaSecretKey string // Alpaca secret key
|
||||||
@@ -86,6 +91,9 @@ func Init() {
|
|||||||
cfg.ExperienceImprovement = strings.ToLower(v) != "false"
|
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
|
// Market data provider API keys
|
||||||
cfg.AlpacaAPIKey = os.Getenv("ALPACA_API_KEY")
|
cfg.AlpacaAPIKey = os.Getenv("ALPACA_API_KEY")
|
||||||
cfg.AlpacaSecretKey = os.Getenv("ALPACA_SECRET_KEY")
|
cfg.AlpacaSecretKey = os.Getenv("ALPACA_SECRET_KEY")
|
||||||
|
|||||||
32
nofxi-tasks.md
Normal file
32
nofxi-tasks.md
Normal 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
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { MarketTicker } from '../components/agent/MarketTicker'
|
import { MarketTicker } from '../components/agent/MarketTicker'
|
||||||
import { PositionsPanel } from '../components/agent/PositionsPanel'
|
import { PositionsPanel } from '../components/agent/PositionsPanel'
|
||||||
import { TraderStatusPanel } from '../components/agent/TraderStatusPanel'
|
import { TraderStatusPanel } from '../components/agent/TraderStatusPanel'
|
||||||
@@ -227,6 +228,7 @@ interface SuggestionCard {
|
|||||||
|
|
||||||
export function AgentChatPage() {
|
export function AgentChatPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
|
const { token } = useAuth()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
|
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
@@ -315,8 +317,11 @@ export function AgentChatPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/agent/chat', {
|
const res = await fetch('/api/agent/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
body: JSON.stringify({ message: msg, user_id: 1, lang: language }),
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: msg, lang: language }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const responseText = data.response || data.error || 'No response'
|
const responseText = data.response || data.error || 'No response'
|
||||||
|
|||||||
Reference in New Issue
Block a user