fix: audit round 2 — session cookie Secure kondisional, logout clear cookie, IDOR report owner-scoping (migrasi 0008), health force login-gate, SSE stream login-gate, FE: Gate undefined-flicker, auth-expired global redirect, X-User-Key demo dihapus, error handling alerts/routines/watchlist, chart label, encodeURIComponent, Screener onMount
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s

This commit is contained in:
asepharyana
2026-09-15 23:19:56 +07:00
parent 96be4d0922
commit 07ece10474
24 changed files with 160 additions and 91 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ func TestBriefing(t *testing.T) {
if rec.Code == http.StatusNotFound {
// No briefing yet: run the routine via engine path instead.
u, _ := s.DB.CheckLocalUser("tester", "password1234")
rows, _ := s.DB.ListRoutines(u.UserKey)
rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 {
t.Fatal("seed has no routines")
}
+14 -5
View File
@@ -94,7 +94,7 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
Secure: true, SameSite: http.SameSiteLaxMode,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
http.Redirect(w, r, "/", http.StatusFound)
@@ -123,6 +123,7 @@ func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: "", Path: "/", HttpOnly: true,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
@@ -162,8 +163,16 @@ func localCreds(r *http.Request) (string, string, bool) {
return strings.ToLower(strings.TrimSpace(req.Username)), req.Password, true
}
// isHTTPS checks X-Forwarded-Proto (Caddy) or raw TLS.
func isHTTPS(r *http.Request) bool {
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
return true
}
return r.TLS != nil
}
// mintSession creates a session + sets the fs_session cookie.
func (s *Server) mintSession(w http.ResponseWriter, user *store.User) bool {
func (s *Server) mintSession(w http.ResponseWriter, r *http.Request, user *store.User) bool {
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
if err != nil {
writeErr(w, http.StatusBadGateway, "session store unavailable")
@@ -171,7 +180,7 @@ func (s *Server) mintSession(w http.ResponseWriter, user *store.User) bool {
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
Secure: true, SameSite: http.SameSiteLaxMode,
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
return true
@@ -225,7 +234,7 @@ func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) {
return
}
s.seedWatchlistSemua(user.UserKey)
if !s.mintSession(w, user) {
if !s.mintSession(w, r, user) {
return
}
writeJSON(w, http.StatusCreated, map[string]any{"user": s.userJSON(user)})
@@ -245,7 +254,7 @@ func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.seedWatchlistSemua(user.UserKey)
if !s.mintSession(w, user) {
if !s.mintSession(w, r, user) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": s.userJSON(user)})
+3 -3
View File
@@ -28,13 +28,13 @@ func (s *Server) Chat(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnprocessableEntity, "message is required")
return
}
// Scope grounding: report citations when scoped.
// Scope grounding: report citations when scoped (owner-scoped).
var ground, citesRaw string
if req.Scope != nil && req.Scope.ReportID > 0 {
var cites string
var at string
err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=?`,
req.Scope.ReportID).Scan(&ground, &cites, &at)
err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=? AND user_key=?`,
req.Scope.ReportID, s.userKey(r)).Scan(&ground, &cites, &at)
if err != nil {
writeErr(w, http.StatusNotFound, "report not found")
return
+1 -1
View File
@@ -140,7 +140,7 @@ func (s *Server) FlowForeign(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal,
"citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])},
"start": startOf(dates), "end": dates[len(dates)-1],
"start": startOf(dates), "end": dates[len(dates)-1],
})
}
+1 -1
View File
@@ -3,10 +3,10 @@ package api
import (
"bytes"
"encoding/json"
"time"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Gated routes 401 without session; public routes stay 200.
+6 -1
View File
@@ -8,9 +8,14 @@ import (
)
// Health serves GET /api/health: last cycle time + credits spent today +
// scheduler state + stale flags (docs/API.md).
// scheduler state + stale flags (docs/API.md). force=1 runs a probe cycle —
// only for logged-in users (anon force would burn Sectors credits = DoS).
func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("force") == "1" {
if _, ok := s.sessionUser(r); !ok {
writeErr(w, http.StatusUnauthorized, "login dulu untuk memaksa siklus")
return
}
_ = s.Sched.RunCycle(r.Context()) // synchronous probe cycle
}
lastCycle, schedOK := s.Sched.Status()
+6 -6
View File
@@ -26,7 +26,7 @@ func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnprocessableEntity, "question is required")
return
}
payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID)
payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID, s.userKey(r))
if err != nil {
writeErr(w, http.StatusNotFound, "no report for "+ticker+" yet — POST /api/report/"+ticker+" first")
return
@@ -49,13 +49,13 @@ func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
}
// loadReport fetches (payload, citations, generated_at, id) for an explicit
// report id or the latest report for a ticker.
func (s *Server) loadReport(ticker string, id int64) (string, string, string, int64, error) {
// report id (scoped to the caller's userKey) or the latest report for a ticker.
func (s *Server) loadReport(ticker string, id int64, userKey string) (string, string, string, int64, error) {
if id > 0 {
var t, p, c, at string
var rid int64
err := s.DB.QueryRow(`SELECT id, ticker, payload_json, citations_json, generated_at
FROM reports WHERE id=?`, id).Scan(&rid, &t, &p, &c, &at)
FROM reports WHERE id=? AND user_key=?`, id, userKey).Scan(&rid, &t, &p, &c, &at)
if err != nil {
return "", "", "", 0, err
}
@@ -69,8 +69,8 @@ func (s *Server) loadReport(ticker string, id int64) (string, string, string, in
return "", "", "", 0, err
}
var rid int64
_ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? ORDER BY id DESC LIMIT 1`,
ticker).Scan(&rid)
_ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? AND user_key=? ORDER BY id DESC LIMIT 1`,
ticker, userKey).Scan(&rid)
return p, c, at, rid, nil
}
+1 -1
View File
@@ -19,7 +19,7 @@ func (s *Server) BuildReport(w http.ResponseWriter, r *http.Request) {
if profile == "" {
profile = "moderate"
}
rep, id, err := s.Builder.Build(r.Context(), ticker, profile)
rep, id, err := s.Builder.Build(r.Context(), ticker, profile, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "report: "+err.Error())
return
+5 -5
View File
@@ -45,8 +45,8 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client)
sched := scheduler.New(cfg, db, cache, s)
srv := &Server{
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
Validate: validator.New(),
Hub: NewHub(),
Validate: validator.New(),
Hub: NewHub(),
StartedAt: time.Now(),
}
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
@@ -72,15 +72,15 @@ func (s *Server) Router() http.Handler {
r.Post("/auth/signup", s.AuthSignup)
r.Post("/auth/login", s.AuthLogin)
r.Get("/version", s.Version)
r.Get("/stream", s.Stream)
// Publik baca: dashboard bisa dibuka tanpa login.
// Publik baca: dashboard bisa dibuka tanpa login. Fitur + filter di bawah
// wajib login (session cookie, tanpa demo bypass).
r.Get("/flow/summary", s.FlowSummary)
r.Get("/flow/broker", s.FlowBroker)
r.Get("/flow/foreign", s.FlowForeign)
r.Get("/briefing/today", s.BriefingToday)
// Fitur + filter: wajib login (session cookie, tanpa demo bypass).
r.Group(func(r chi.Router) {
r.Use(s.requireLogin)
r.Get("/stream", s.Stream)
r.Post("/screen", s.Screen)
r.Get("/routines", s.ListRoutines)
r.Post("/routines", s.CreateRoutine)