unit PM.RateLimit; { Mirrors api.php checkRateLimit / recordAttempt / clearAttempts. 15-minute window. Caller decides the threshold (5 for register, 10 for login). } interface uses System.SysUtils, FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer, PM.Database; function GetClientIP(ARequest: TIdHTTPRequestInfo): string; function CheckRateLimit(const AIP: string): Integer; procedure RecordAttempt(const AIP: string); procedure ClearAttempts(const AIP: string); 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; end.