🛡️ Sentinel: [HIGH] Fix timing attack vulnerability in Pico WebSocket auth

Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-03-19 17:39:30 +00:00
parent e99a5ae7f3
commit 50d4b99e29
3 changed files with 115 additions and 2 deletions

View file

@ -7,3 +7,8 @@
**Vulnerability:** Go's standard `http.Post`, `http.PostForm`, `http.Get`, and the default `http.Client` do not have timeouts configured by default. **Vulnerability:** Go's standard `http.Post`, `http.PostForm`, `http.Get`, and the default `http.Client` do not have timeouts configured by default.
**Learning:** These defaults can leave the application vulnerable to resource exhaustion or indefinite hangs if the external service (like an OAuth provider) is slow, unresponsive, or experiencing an outage. **Learning:** These defaults can leave the application vulnerable to resource exhaustion or indefinite hangs if the external service (like an OAuth provider) is slow, unresponsive, or experiencing an outage.
**Prevention:** Always instantiate `http.Client` explicitly with a sensible `Timeout` (e.g., `Timeout: 15 * time.Second`) before making outbound HTTP requests, instead of using the default package-level convenience functions. **Prevention:** Always instantiate `http.Client` explicitly with a sensible `Timeout` (e.g., `Timeout: 15 * time.Second`) before making outbound HTTP requests, instead of using the default package-level convenience functions.
## 2025-03-19 - [HIGH] Fix Timing Attack Vulnerability in Pico WebSocket Auth
**Vulnerability:** The Pico WebSocket authentication handler was using standard string equality (`==`) to compare the provided bearer token against the configured secret token.
**Learning:** String equality operators in Go return early as soon as a character mismatch is found. This allows an attacker to measure the time it takes for the server to reject the connection and iteratively guess the token character by character (a timing attack).
**Prevention:** Always use `subtle.ConstantTimeCompare` from the `crypto/subtle` package when comparing secrets, tokens, passwords, or cryptographic signatures to ensure the comparison time depends only on the length of the secret, not the contents.

View file

@ -2,6 +2,7 @@ package pico
import ( import (
"context" "context"
"crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -293,14 +294,14 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
// Check Authorization header // Check Authorization header
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
if after, ok := strings.CutPrefix(auth, "Bearer "); ok { if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
if after == token { if subtle.ConstantTimeCompare([]byte(after), []byte(token)) == 1 {
return true return true
} }
} }
// Check query parameter only when explicitly allowed // Check query parameter only when explicitly allowed
if c.config.AllowTokenQuery { if c.config.AllowTokenQuery {
if r.URL.Query().Get("token") == token { if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("token")), []byte(token)) == 1 {
return true return true
} }
} }

View file

@ -0,0 +1,107 @@
package pico
import (
"net/http"
"net/http/httptest"
"testing"
"jane/pkg/config"
)
func TestPicoChannel_Authenticate(t *testing.T) {
tests := []struct {
name string
token string
allowTokenQuery bool
setupRequest func(*http.Request)
want bool
}{
{
name: "empty token config",
token: "",
allowTokenQuery: false,
setupRequest: func(r *http.Request) {
r.Header.Set("Authorization", "Bearer valid-token")
},
want: false,
},
{
name: "valid authorization header",
token: "valid-token",
allowTokenQuery: false,
setupRequest: func(r *http.Request) {
r.Header.Set("Authorization", "Bearer valid-token")
},
want: true,
},
{
name: "invalid authorization header",
token: "valid-token",
allowTokenQuery: false,
setupRequest: func(r *http.Request) {
r.Header.Set("Authorization", "Bearer invalid-token")
},
want: false,
},
{
name: "missing authorization header prefix",
token: "valid-token",
allowTokenQuery: false,
setupRequest: func(r *http.Request) {
r.Header.Set("Authorization", "valid-token")
},
want: false,
},
{
name: "valid query parameter token - allowed",
token: "valid-token",
allowTokenQuery: true,
setupRequest: func(r *http.Request) {
q := r.URL.Query()
q.Add("token", "valid-token")
r.URL.RawQuery = q.Encode()
},
want: true,
},
{
name: "invalid query parameter token - allowed",
token: "valid-token",
allowTokenQuery: true,
setupRequest: func(r *http.Request) {
q := r.URL.Query()
q.Add("token", "invalid-token")
r.URL.RawQuery = q.Encode()
},
want: false,
},
{
name: "valid query parameter token - not allowed",
token: "valid-token",
allowTokenQuery: false,
setupRequest: func(r *http.Request) {
q := r.URL.Query()
q.Add("token", "valid-token")
r.URL.RawQuery = q.Encode()
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &PicoChannel{
config: config.PicoConfig{
Token: tt.token,
AllowTokenQuery: tt.allowTokenQuery,
},
}
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
tt.setupRequest(req)
if got := c.authenticate(req); got != tt.want {
t.Errorf("authenticate() = %v, want %v", got, tt.want)
}
})
}
}