Security(Go): Login rate limit + WS origin allowlist + REST bearer auth
This commit is contained in:
@@ -79,18 +79,27 @@ func (h *wsHub) requireAdmin(c *wsClient, raw []byte, replyCmd string) (ok bool)
|
||||
// ----- handlers ------------------------------------------------------------
|
||||
|
||||
func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) {
|
||||
// Throttle the salt-probe surface together with login: an attacker
|
||||
// who can poll get_salt freely would otherwise still learn nothing
|
||||
// (the unknown-user fake salt mitigation handles that), but the
|
||||
// endpoint is otherwise free CPU on the server. Limiting by IP is
|
||||
// enough; we don't have a username yet to limit by user.
|
||||
if !h.allowLoginByIP(c) {
|
||||
// Stall the response so a tight-loop attacker doesn't flood the
|
||||
// queue. Still return a well-formed salt to avoid making the
|
||||
// limit detectable from the client side.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
|
||||
salt, ok := h.auth.GetSalt(in.Username)
|
||||
// Do not leak which usernames exist: always return ok=true with a salt.
|
||||
// For unknown users hand back the empty salt (matches admin convention)
|
||||
// so the timing/shape of the response is uniform.
|
||||
if !ok {
|
||||
salt = ""
|
||||
}
|
||||
salt, _ := h.auth.GetSalt(in.Username)
|
||||
// GetSalt now returns a deterministic fake salt (16 hex chars) for
|
||||
// unknown users — same shape as a real salt — so an attacker can't
|
||||
// tell from this response alone whether the username exists.
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": "salt",
|
||||
"ok": true,
|
||||
@@ -109,6 +118,20 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
// Rate-limit BEFORE doing the hash work, so a flood doesn't pin CPU.
|
||||
// Two-dimensional throttle: per-IP catches scanners that try many
|
||||
// usernames; per-username catches scanners that rotate IPs against a
|
||||
// known account (admin). Either dimension tripping rejects the call
|
||||
// with a uniform "credentials" error so the limit is not detectable.
|
||||
if !h.allowLoginByIP(c) || !h.allowLoginByUsername(in.Username) {
|
||||
h.log.Warn("ws login throttled: user=%s addr=%s", in.Username, c.addr)
|
||||
// Burn the challenge so the attacker can't immediately replay.
|
||||
c.nonce = ""
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
|
||||
return
|
||||
}
|
||||
|
||||
// Bind the response to the challenge we issued at connect time so that
|
||||
// replays from a different connection can't reuse a captured response.
|
||||
if in.Nonce == "" || in.Nonce != c.nonce {
|
||||
@@ -120,9 +143,17 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
||||
if err != nil {
|
||||
// Burn the challenge on failure too — forces a new round on retry.
|
||||
c.nonce = ""
|
||||
// Fixed delay on failure: makes online brute force impractical
|
||||
// even within the rate-limit budget, and erases the timing
|
||||
// difference between "wrong password" and "wrong nonce".
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
|
||||
return
|
||||
}
|
||||
// Successful login: clear the per-IP/per-user budgets so a legitimate
|
||||
// user who fat-fingered a few times doesn't stay throttled.
|
||||
h.resetLoginThrottle(c, in.Username)
|
||||
|
||||
c.nonce = ""
|
||||
c.token = token
|
||||
c.role = role
|
||||
@@ -136,6 +167,31 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
||||
}))
|
||||
}
|
||||
|
||||
// allowLoginByIP / allowLoginByUsername return true when the call is
|
||||
// within budget; nil limiter always returns true (effectively disabled).
|
||||
func (h *wsHub) allowLoginByIP(c *wsClient) bool {
|
||||
if h.loginIPLimit == nil || c == nil || c.addr == "" {
|
||||
return true
|
||||
}
|
||||
return h.loginIPLimit.Allow(c.addr)
|
||||
}
|
||||
|
||||
func (h *wsHub) allowLoginByUsername(username string) bool {
|
||||
if h.loginUserLimit == nil || username == "" {
|
||||
return true
|
||||
}
|
||||
return h.loginUserLimit.Allow(username)
|
||||
}
|
||||
|
||||
func (h *wsHub) resetLoginThrottle(c *wsClient, username string) {
|
||||
if h.loginIPLimit != nil && c != nil && c.addr != "" {
|
||||
h.loginIPLimit.Reset(c.addr)
|
||||
}
|
||||
if h.loginUserLimit != nil && username != "" {
|
||||
h.loginUserLimit.Reset(username)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConnect kicks off a screen-sharing session for the browser. We send
|
||||
// COMMAND_SCREEN_SPY to the device's main TCP connection; the device then
|
||||
// opens a new sub-connection (TOKEN_BITMAPINFO) which the TCP side binds to
|
||||
|
||||
Reference in New Issue
Block a user