chore(security): pin JWT algorithm + SameSite and harden OAuth2 state cookie

- Set GinJWTMiddleware.SigningAlgorithm to "HS256" explicitly so a future
  library default change (or an alg:none confusion attempt) cannot weaken
  token validation. This matches the current gin-jwt default, so behaviour
  is unchanged.
- Set CookieSameSite to Lax: same as the modern-browser default, but
  pinned so server-side intent is clear and CSRF on cross-site POST is
  blocked while top-level GET (OAuth callback) still works.
- Move the nz-o2s OAuth2 state cookie into writeOauth2StateCookie and
  set HttpOnly=true. The frontend does not read this cookie, so HttpOnly
  is strictly an XSS-hardening win with no behaviour change.

JWT Cookie HttpOnly/Secure are intentionally left default for now: the
frontend reads \`!!document.cookie\` to display login state and many
deployments terminate TLS at an upstream proxy — flipping those would
require a coordinated frontend change.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
naiba
2026-05-18 15:17:59 +00:00
co-authored by naiba/CloudCode
parent 7c493e8f0b
commit fb120074ac
3 changed files with 76 additions and 9 deletions
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -115,3 +116,48 @@ func TestTerminalStreamRejectsUnknownStreamID(t *testing.T) {
success, _ := decodeCommonResponseError(t, w.Body.Bytes())
assert.False(t, success, "unknown stream id must produce an error response")
}
// JWT cookie security: SigningAlgorithm must be pinned to HS256 (defense
// against future algorithm-confusion regressions in the library) and the
// JWT cookie must use SameSite=Lax so cross-site GET navigations don't
// silently mint requests with the user's session. HttpOnly/Secure are NOT
// asserted here because the frontend currently reads `!!document.cookie`
// to display login state and many deployments terminate TLS at a proxy —
// flipping those would break user-visible behaviour and is tracked
// separately.
func TestJWTInitParamsPinsAlgorithmAndSameSite(t *testing.T) {
ensureLocalizerForStreamTests(t)
if singleton.Conf == nil {
singleton.Conf = &singleton.ConfigClass{
Config: &model.Config{JWTSecretKey: "test-secret-for-jwt-config-assertions"},
}
}
params := initParams()
if params.SigningAlgorithm != "HS256" {
t.Fatalf("SigningAlgorithm must be pinned to HS256, got %q", params.SigningAlgorithm)
}
if params.CookieSameSite != http.SameSiteLaxMode {
t.Fatalf("CookieSameSite must be Lax for OAuth-callback compatibility + CSRF safety, got %v", params.CookieSameSite)
}
}
// nz-o2s carries the OAuth2 state binding that authenticates the callback.
// The frontend never reads it, so HttpOnly is safe to enable and shuts the
// door on XSS attempting to steal the state.
func TestWriteOauth2StateCookieIsHttpOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
writeOauth2StateCookie(c, "test-key")
header := w.Header().Get("Set-Cookie")
if !strings.Contains(header, "nz-o2s=test-key") {
t.Fatalf("expected nz-o2s cookie in response, got %q", header)
}
if !strings.Contains(header, "HttpOnly") {
t.Fatalf("nz-o2s must be HttpOnly to prevent XSS reading OAuth state, got %q", header)
}
}