mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
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:
@@ -22,18 +22,30 @@ func initParams() *jwt.GinJWTMiddleware {
|
|||||||
Key: []byte(singleton.Conf.JWTSecretKey),
|
Key: []byte(singleton.Conf.JWTSecretKey),
|
||||||
CookieName: "nz-jwt",
|
CookieName: "nz-jwt",
|
||||||
SendCookie: true,
|
SendCookie: true,
|
||||||
Timeout: time.Hour * time.Duration(singleton.Conf.JWTTimeout),
|
// Pin the signing algorithm so a future library default change (or an
|
||||||
MaxRefresh: time.Hour * time.Duration(singleton.Conf.JWTTimeout),
|
// `alg: none` confusion attempt) cannot weaken token validation.
|
||||||
IdentityKey: model.CtxKeyAuthorizedUser,
|
SigningAlgorithm: "HS256",
|
||||||
PayloadFunc: payloadFunc(),
|
// Lax keeps OAuth callback redirects (top-level GET navigations from
|
||||||
|
// the provider domain) working while blocking cross-site POST CSRF.
|
||||||
|
// HttpOnly/Secure are intentionally left default: the frontend reads
|
||||||
|
// `!!document.cookie` for login-state display and many deployments
|
||||||
|
// terminate TLS at a proxy upstream — both warrant a separate change.
|
||||||
|
CookieSameSite: http.SameSiteLaxMode,
|
||||||
|
Timeout: time.Hour * time.Duration(singleton.Conf.JWTTimeout),
|
||||||
|
MaxRefresh: time.Hour * time.Duration(singleton.Conf.JWTTimeout),
|
||||||
|
IdentityKey: model.CtxKeyAuthorizedUser,
|
||||||
|
PayloadFunc: payloadFunc(),
|
||||||
|
|
||||||
IdentityHandler: identityHandler(),
|
IdentityHandler: identityHandler(),
|
||||||
Authenticator: authenticator(),
|
Authenticator: authenticator(),
|
||||||
Authorizator: authorizator(),
|
Authorizator: authorizator(),
|
||||||
Unauthorized: unauthorized(),
|
Unauthorized: unauthorized(),
|
||||||
TokenLookup: "header: Authorization, query: token, cookie: nz-jwt",
|
// query: token still accepted because the WebSocket browser API
|
||||||
TokenHeadName: "Bearer",
|
// cannot set Authorization headers; removing it would break the
|
||||||
TimeFunc: time.Now,
|
// /ws/* routes until the frontend migrates to cookie auth.
|
||||||
|
TokenLookup: "header: Authorization, query: token, cookie: nz-jwt",
|
||||||
|
TokenHeadName: "Bearer",
|
||||||
|
TimeFunc: time.Now,
|
||||||
|
|
||||||
LoginResponse: func(c *gin.Context, code int, token string, expire time.Time) {
|
LoginResponse: func(c *gin.Context, code int, token string, expire time.Time) {
|
||||||
c.JSON(http.StatusOK, model.CommonResponse[model.LoginResponse]{
|
c.JSON(http.StatusOK, model.CommonResponse[model.LoginResponse]{
|
||||||
|
|||||||
@@ -66,12 +66,21 @@ func oauth2redirect(c *gin.Context) (*model.Oauth2LoginResponse, error) {
|
|||||||
}, cache.DefaultExpiration)
|
}, cache.DefaultExpiration)
|
||||||
|
|
||||||
url := o2conf.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
url := o2conf.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||||
// CodeQL go/cookie-secure-not-set: 根据请求协议动态设置 Secure 属性,避免 HTTP 环境下 Cookie 无法使用
|
writeOauth2StateCookie(c, stateKey)
|
||||||
c.SetCookie("nz-o2s", stateKey, 60*5, "", "", c.Request.URL.Scheme == "https" || c.Request.TLS != nil, false)
|
|
||||||
|
|
||||||
return &model.Oauth2LoginResponse{Redirect: url}, nil
|
return &model.Oauth2LoginResponse{Redirect: url}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeOauth2StateCookie sets the nz-o2s cookie used to authenticate the
|
||||||
|
// OAuth2 callback. Secure is set when the request arrives over HTTPS;
|
||||||
|
// HttpOnly is enabled unconditionally — the frontend does not read this
|
||||||
|
// cookie, only the dashboard's callback handler does, so HTTP-only access
|
||||||
|
// is strictly an XSS-hardening win.
|
||||||
|
func writeOauth2StateCookie(c *gin.Context, stateKey string) {
|
||||||
|
secure := c.Request.URL.Scheme == "https" || c.Request.TLS != nil
|
||||||
|
c.SetCookie("nz-o2s", stateKey, 60*5, "", "", secure, true)
|
||||||
|
}
|
||||||
|
|
||||||
// @Summary Unbind Oauth2
|
// @Summary Unbind Oauth2
|
||||||
// @Description Unbind Oauth2
|
// @Description Unbind Oauth2
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -115,3 +116,48 @@ func TestTerminalStreamRejectsUnknownStreamID(t *testing.T) {
|
|||||||
success, _ := decodeCommonResponseError(t, w.Body.Bytes())
|
success, _ := decodeCommonResponseError(t, w.Body.Bytes())
|
||||||
assert.False(t, success, "unknown stream id must produce an error response")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user