bff9bdf9f2
Sleep/hibernate handling
========================
Adds WM_POWERBROADCAST / PBT_APMSUSPEND handling alongside the existing
WTS_SESSION_LOCK detection. Closing a laptop lid often suspends the
system without firing a session lock, leaving the decrypted vault in
memory until resume — this fixes that.
Implementation note: WM_POWERBROADCAST is normally only delivered to
top-level windows, and Windows can silently skip hidden utility windows.
PowerRegisterSuspendResumeNotification (user32, Win 8+) forces delivery
to our specific HWND regardless. Loaded dynamically via GetProcAddress
so older Windows degrades gracefully (WTS lock still works).
The suspend handler reuses OnSystemLock — semantically the same event
from the user's perspective ("I'm leaving the machine"). Calls
lockVault() in JS via ExecuteJavaScript.
RateLimit fix (related: lockout feature from previous commit)
=============================================================
The UPSERT (INSERT ... ON CONFLICT DO UPDATE) in RecordFailedAccountAttempt
errored with "near ON: syntax error" — either the bundled SQLite version
or FireDAC's parameter preprocessor doesn't handle UPSERT correctly.
Replaced with portable UPDATE-then-INSERT (safe under our DB.Lock).
Also:
- datetime modifier ("+60 seconds") built in Delphi via Format() rather
than SQL-side concatenation ('+' || :sec || ' seconds'), which FireDAC
was mangling on some configs.
- GetAccountLockoutRemaining rewritten with julianday() (the SQLite
idiom for date arithmetic) instead of strftime('%s'). Cleaner, NULL-safe.
322 lines
9.8 KiB
ObjectPascal
322 lines
9.8 KiB
ObjectPascal
unit PM.RateLimit;
|
|
|
|
{
|
|
Two-layer brute-force protection:
|
|
|
|
1) Per-IP rate limit (login_attempts table, 15-minute window).
|
|
Useful for non-loopback deployments and as a defense-in-depth layer.
|
|
|
|
2) Per-username lockout (account_lockouts table, exponential backoff).
|
|
The real defense for a loopback-only setup where everyone shares
|
|
127.0.0.1. After 4 consecutive failed attempts for a given username,
|
|
the account is locked for an increasing duration (1m, 5m, 15m, 1h,
|
|
6h, 24h-capped). Counter resets on successful authentication.
|
|
|
|
IMPORTANT: failed attempts are recorded even when the username doesn't
|
|
exist in the users table. This prevents attackers from enumerating
|
|
existing accounts by observing which usernames trigger a lockout.
|
|
Trade-off: an attacker can DoS-lock arbitrary usernames, but they can
|
|
never bypass authentication this way.
|
|
}
|
|
|
|
interface
|
|
|
|
uses
|
|
System.SysUtils, System.JSON,
|
|
FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer,
|
|
PM.Database;
|
|
|
|
function GetClientIP(ARequest: TIdHTTPRequestInfo): string;
|
|
|
|
// ---- Per-IP rate limit (legacy) ----
|
|
function CheckRateLimit(const AIP: string): Integer;
|
|
procedure RecordAttempt(const AIP: string);
|
|
procedure ClearAttempts(const AIP: string);
|
|
|
|
// ---- Per-username account lockout ----
|
|
|
|
// Seconds remaining until the account is unlocked. 0 means not locked.
|
|
function GetAccountLockoutRemaining(const AUsername: string): Integer;
|
|
|
|
// Records a failed authentication attempt against AUsername (whether or not
|
|
// the user exists), applies exponential backoff, and returns the new
|
|
// failed_count. Caller should pass this back to the audit log.
|
|
function RecordFailedAccountAttempt(const AUsername, AIP: string): Integer;
|
|
|
|
// Resets the failed counter and clears any lockout for AUsername. Call on
|
|
// successful login / reauth.
|
|
procedure ClearAccountLockout(const AUsername: string);
|
|
|
|
// One-stop helper for handlers: if the account is currently locked, sends
|
|
// a 429 with a JSON body { error, retry_after } and returns True. Caller
|
|
// should Exit immediately. Returns False if not locked (caller proceeds).
|
|
function RejectIfAccountLocked(AResponse: TIdHTTPResponseInfo;
|
|
const AUsername: string): Boolean;
|
|
|
|
implementation
|
|
|
|
function GetClientIP(ARequest: TIdHTTPRequestInfo): string;
|
|
begin
|
|
// api.php trusts X-Forwarded-For (security flaw H1 in audit). Since this
|
|
// server is loopback-only and not behind a proxy, prefer the actual peer IP.
|
|
Result := ARequest.RemoteIP;
|
|
if Result = '' then Result := 'unknown';
|
|
end;
|
|
|
|
function CheckRateLimit(const AIP: string): Integer;
|
|
var
|
|
LQ: TFDQuery;
|
|
begin
|
|
Result := 0;
|
|
DB.Lock;
|
|
try
|
|
LQ := TFDQuery.Create(nil);
|
|
try
|
|
LQ.Connection := DB.Connection;
|
|
LQ.SQL.Text :=
|
|
'SELECT COUNT(*) AS cnt FROM login_attempts ' +
|
|
'WHERE ip = :ip ' +
|
|
'AND attempted_at > datetime(''now'', ''-15 minutes'')';
|
|
LQ.ParamByName('ip').AsString := AIP;
|
|
LQ.Open;
|
|
Result := LQ.FieldByName('cnt').AsInteger;
|
|
finally
|
|
LQ.Free;
|
|
end;
|
|
finally
|
|
DB.Unlock;
|
|
end;
|
|
end;
|
|
|
|
procedure RecordAttempt(const AIP: string);
|
|
var
|
|
LQ: TFDQuery;
|
|
begin
|
|
DB.Lock;
|
|
try
|
|
LQ := TFDQuery.Create(nil);
|
|
try
|
|
LQ.Connection := DB.Connection;
|
|
LQ.SQL.Text := 'INSERT INTO login_attempts (ip) VALUES (:ip)';
|
|
LQ.ParamByName('ip').AsString := AIP;
|
|
LQ.ExecSQL;
|
|
finally
|
|
LQ.Free;
|
|
end;
|
|
finally
|
|
DB.Unlock;
|
|
end;
|
|
end;
|
|
|
|
procedure ClearAttempts(const AIP: string);
|
|
var
|
|
LQ: TFDQuery;
|
|
begin
|
|
DB.Lock;
|
|
try
|
|
LQ := TFDQuery.Create(nil);
|
|
try
|
|
LQ.Connection := DB.Connection;
|
|
LQ.SQL.Text := 'DELETE FROM login_attempts WHERE ip = :ip';
|
|
LQ.ParamByName('ip').AsString := AIP;
|
|
LQ.ExecSQL;
|
|
finally
|
|
LQ.Free;
|
|
end;
|
|
finally
|
|
DB.Unlock;
|
|
end;
|
|
end;
|
|
|
|
// =============================================================================
|
|
// Per-username lockout with exponential backoff
|
|
// =============================================================================
|
|
|
|
// Exponential backoff schedule. Index = failed_count value AFTER this attempt.
|
|
// First 3 failures give no lockout — a real user typing their master pw
|
|
// wrong once or twice shouldn't be punished. From the 4th failure on, the
|
|
// duration ramps up sharply. Capped at 24 h regardless of further failures.
|
|
function ComputeBackoffSeconds(AFailedCount: Integer): Integer;
|
|
begin
|
|
case AFailedCount of
|
|
0..3: Result := 0; // grace window
|
|
4: Result := 60; // 1 minute
|
|
5: Result := 300; // 5 minutes
|
|
6: Result := 900; // 15 minutes
|
|
7: Result := 3600; // 1 hour
|
|
8: Result := 21600; // 6 hours
|
|
else
|
|
Result := 86400; // 24 hours — capped, regardless of count
|
|
end;
|
|
end;
|
|
|
|
function GetAccountLockoutRemaining(const AUsername: string): Integer;
|
|
var
|
|
LQ: TFDQuery;
|
|
begin
|
|
Result := 0;
|
|
if AUsername = '' then Exit;
|
|
|
|
DB.Lock;
|
|
try
|
|
LQ := TFDQuery.Create(nil);
|
|
try
|
|
LQ.Connection := DB.Connection;
|
|
// julianday() returns a real number of days since the Julian epoch
|
|
// — the standard SQLite idiom for date arithmetic. Multiplying by
|
|
// 86400 gives seconds. The WHERE clause filters out rows where the
|
|
// lockout is NULL or already expired, so an empty result set means
|
|
// "not locked" — no separate NULL handling needed.
|
|
LQ.SQL.Text :=
|
|
'SELECT CAST((julianday(locked_until) - julianday(''now'')) * 86400 ' +
|
|
' AS INTEGER) AS remaining ' +
|
|
'FROM account_lockouts ' +
|
|
'WHERE username = :u AND locked_until > datetime(''now'')';
|
|
LQ.ParamByName('u').AsString := AUsername;
|
|
LQ.Open;
|
|
if not LQ.IsEmpty then
|
|
Result := LQ.FieldByName('remaining').AsInteger;
|
|
finally
|
|
LQ.Free;
|
|
end;
|
|
finally
|
|
DB.Unlock;
|
|
end;
|
|
end;
|
|
|
|
function RecordFailedAccountAttempt(const AUsername, AIP: string): Integer;
|
|
var
|
|
LQ: TFDQuery;
|
|
LNewCount, LBackoff: Integer;
|
|
begin
|
|
Result := 0;
|
|
if AUsername = '' then Exit;
|
|
|
|
DB.Lock;
|
|
try
|
|
// Try UPDATE first; if no row exists, INSERT a fresh one. This avoids
|
|
// SQLite UPSERT (ON CONFLICT DO UPDATE), which requires SQLite 3.24+
|
|
// and which FireDAC's parameter preprocessor mangles on some versions.
|
|
// Safe under our outer DB.Lock — no race between the UPDATE and INSERT.
|
|
LQ := TFDQuery.Create(nil);
|
|
try
|
|
LQ.Connection := DB.Connection;
|
|
LQ.SQL.Text :=
|
|
'UPDATE account_lockouts SET ' +
|
|
' failed_count = failed_count + 1, ' +
|
|
' last_attempt_at = CURRENT_TIMESTAMP, ' +
|
|
' last_attempt_ip = :ip ' +
|
|
'WHERE username = :u';
|
|
LQ.ParamByName('u').AsString := AUsername;
|
|
LQ.ParamByName('ip').AsString := AIP;
|
|
LQ.ExecSQL;
|
|
|
|
if LQ.RowsAffected = 0 then
|
|
begin
|
|
LQ.SQL.Text :=
|
|
'INSERT INTO account_lockouts ' +
|
|
' (username, failed_count, last_attempt_at, last_attempt_ip) ' +
|
|
'VALUES (:u, 1, CURRENT_TIMESTAMP, :ip)';
|
|
LQ.ParamByName('u').AsString := AUsername;
|
|
LQ.ParamByName('ip').AsString := AIP;
|
|
LQ.ExecSQL;
|
|
end;
|
|
finally
|
|
LQ.Free;
|
|
end;
|
|
|
|
// Read the new failed_count to decide if the backoff threshold is crossed.
|
|
LQ := TFDQuery.Create(nil);
|
|
try
|
|
LQ.Connection := DB.Connection;
|
|
LQ.SQL.Text :=
|
|
'SELECT failed_count FROM account_lockouts WHERE username = :u';
|
|
LQ.ParamByName('u').AsString := AUsername;
|
|
LQ.Open;
|
|
if LQ.IsEmpty then Exit;
|
|
LNewCount := LQ.FieldByName('failed_count').AsInteger;
|
|
finally
|
|
LQ.Free;
|
|
end;
|
|
Result := LNewCount;
|
|
|
|
LBackoff := ComputeBackoffSeconds(LNewCount);
|
|
if LBackoff > 0 then
|
|
begin
|
|
LQ := TFDQuery.Create(nil);
|
|
try
|
|
LQ.Connection := DB.Connection;
|
|
// Build the datetime modifier in Delphi (e.g. "+60 seconds") and
|
|
// pass it as a single string parameter — avoids SQL-side string
|
|
// concatenation which FireDAC's preprocessor can choke on.
|
|
LQ.SQL.Text :=
|
|
'UPDATE account_lockouts SET ' +
|
|
' locked_until = datetime(''now'', :modifier) ' +
|
|
'WHERE username = :u';
|
|
LQ.ParamByName('modifier').AsString := Format('+%d seconds', [LBackoff]);
|
|
LQ.ParamByName('u').AsString := AUsername;
|
|
LQ.ExecSQL;
|
|
finally
|
|
LQ.Free;
|
|
end;
|
|
end;
|
|
finally
|
|
DB.Unlock;
|
|
end;
|
|
end;
|
|
|
|
procedure ClearAccountLockout(const AUsername: string);
|
|
var
|
|
LQ: TFDQuery;
|
|
begin
|
|
if AUsername = '' then Exit;
|
|
|
|
DB.Lock;
|
|
try
|
|
LQ := TFDQuery.Create(nil);
|
|
try
|
|
LQ.Connection := DB.Connection;
|
|
// Hard delete on success: keeps the table small, and there's no
|
|
// value in remembering past failures once the user has proven they
|
|
// know the password.
|
|
LQ.SQL.Text := 'DELETE FROM account_lockouts WHERE username = :u';
|
|
LQ.ParamByName('u').AsString := AUsername;
|
|
LQ.ExecSQL;
|
|
finally
|
|
LQ.Free;
|
|
end;
|
|
finally
|
|
DB.Unlock;
|
|
end;
|
|
end;
|
|
|
|
function RejectIfAccountLocked(AResponse: TIdHTTPResponseInfo;
|
|
const AUsername: string): Boolean;
|
|
var
|
|
LRemaining: Integer;
|
|
LBody: TJSONObject;
|
|
begin
|
|
LRemaining := GetAccountLockoutRemaining(AUsername);
|
|
Result := LRemaining > 0;
|
|
if not Result then Exit;
|
|
|
|
// 429 Too Many Requests with a structured body so the client can show
|
|
// a countdown timer ("Try again in X seconds") instead of a generic
|
|
// error toast. Standard Retry-After header included as a fallback for
|
|
// HTTP-aware tooling.
|
|
AResponse.ResponseNo := 429;
|
|
AResponse.ContentType := 'application/json; charset=utf-8';
|
|
AResponse.CustomHeaders.Values['Retry-After'] := IntToStr(LRemaining);
|
|
|
|
LBody := TJSONObject.Create;
|
|
try
|
|
LBody.AddPair('error', 'Account temporarily locked due to repeated failed attempts');
|
|
LBody.AddPair('retry_after', TJSONNumber.Create(LRemaining));
|
|
AResponse.ContentText := LBody.ToJSON;
|
|
finally
|
|
LBody.Free;
|
|
end;
|
|
end;
|
|
|
|
end.
|