-
Notifications
You must be signed in to change notification settings - Fork 179
Add strict TOTP validation to block invalid authentication requests #578
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
f5a2d64
67c50e7
d9ac515
65f5699
a8c0e4b
fb1f0e8
50600fb
ea83763
2ff4cdf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,7 @@ | |
| import signal | ||
| import select | ||
| import sys | ||
| import unicodedata | ||
| from collections import deque | ||
| from struct import unpack | ||
|
|
||
|
|
@@ -88,6 +89,50 @@ | |
| warnings.warn(f"Cannot get the login user name: {str(e)}") | ||
|
|
||
|
|
||
| # TOTP validation utilities (client-side) | ||
| class TotpValidationResult(NamedTuple): | ||
| ok: bool | ||
| code: str | ||
| message: str | ||
|
|
||
|
|
||
| INVALID_TOTP_MSG = 'Invalid TOTP: Please enter a valid 6-digit numeric code.' | ||
|
|
||
|
|
||
| def validate_totp_code(raw_code: str) -> TotpValidationResult: | ||
| """Validate and normalize a user-supplied TOTP value. | ||
|
|
||
| Precedence: | ||
| 1) Trim & normalize input (normalize full-width digits; strip leading/trailing whitespace only) | ||
| 2) Empty check | ||
| 3) Length check (must be exactly 6) | ||
| 4) Numeric-only check (digits 0–9 only; do not remove internal separators) | ||
|
|
||
| Returns TotpValidationResult(ok, code, message). | ||
| - Success: `ok=True`, `code` is a 6-digit ASCII string, `message=''`. | ||
| - Failure: `ok=False`, `code=''`, `message` is always the generic INVALID_TOTP_MSG. | ||
| """ | ||
| try: | ||
| s = raw_code if raw_code is not None else '' | ||
| # Normalize Unicode (convert full-width digits etc. to ASCII) | ||
| s = unicodedata.normalize('NFKC', s) | ||
| # Strip leading/trailing whitespace | ||
| s = s.strip() | ||
| # Empty / length / numeric checks (do not remove internal separators) | ||
| if s == '': | ||
| return TotpValidationResult(False, '', INVALID_TOTP_MSG) | ||
| if len(s) != 6: | ||
| return TotpValidationResult(False, '', INVALID_TOTP_MSG) | ||
| if not s.isdigit(): | ||
| return TotpValidationResult(False, '', INVALID_TOTP_MSG) | ||
|
|
||
| # All good | ||
| return TotpValidationResult(True, s, '') | ||
| except Exception: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why the tests are not updated to test these new error message?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added the test case . |
||
| # Fallback generic error | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is no exception handling, it is just like function return. Handle the exception when we use try catch block
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added the exception . |
||
| return TotpValidationResult(False, '', INVALID_TOTP_MSG) | ||
|
|
||
|
|
||
| def connect(**kwargs: Any) -> Connection: | ||
| """Opens a new connection to a Vertica database.""" | ||
| return Connection(kwargs) | ||
|
|
@@ -313,6 +358,14 @@ def __init__(self, options: Optional[Dict[str, Any]] = None) -> None: | |
| if self.totp is not None: | ||
| if not isinstance(self.totp, str): | ||
| raise TypeError('The value of connection option "totp" should be a string') | ||
| # Validate using local validator | ||
| result = validate_totp_code(self.totp) | ||
| if not result.ok: | ||
| msg = INVALID_TOTP_MSG | ||
| self._logger.error(msg) | ||
| raise errors.ConnectionError(msg) | ||
| # normalized digits-only code | ||
| self.totp = result.code | ||
| self._logger.info('TOTP received in connection options') | ||
|
|
||
| # OAuth authentication setup | ||
|
|
@@ -974,13 +1027,11 @@ def send_startup(totp_value=None): | |
| short_msg = match.group(1).strip() if match else error_msg.strip() | ||
|
|
||
| if "Invalid TOTP" in short_msg: | ||
| print("Authentication failed: Invalid TOTP token.") | ||
| self._logger.error("Authentication failed: Invalid TOTP token.") | ||
| self._logger.error(INVALID_TOTP_MSG) | ||
| self.close_socket() | ||
| raise errors.ConnectionError("Authentication failed: Invalid TOTP token.") | ||
| raise errors.ConnectionError(INVALID_TOTP_MSG) | ||
|
|
||
| # Generic error fallback | ||
| print(f"Authentication failed: {short_msg}") | ||
| self._logger.error(short_msg) | ||
| raise errors.ConnectionError(f"Authentication failed: {short_msg}") | ||
| else: | ||
|
|
@@ -993,23 +1044,20 @@ def send_startup(totp_value=None): | |
|
|
||
| # ✅ If TOTP not provided initially, prompt only once | ||
| if not totp: | ||
| timeout_seconds = 30 # 5 minutes timeout | ||
| timeout_seconds = 300 # 5 minutes timeout | ||
| try: | ||
| print("Enter TOTP: ", end="", flush=True) | ||
| ready, _, _ = select.select([sys.stdin], [], [], timeout_seconds) | ||
| if ready: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we wait 5mins for the user to enter TOTP?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi Siva, if the user does not provide the TOTP within 5 minutes, the session should expire—meaning the connection between the driver and the server should be terminated. |
||
| totp_input = sys.stdin.readline().strip() | ||
|
|
||
| # ❌ Blank TOTP entered | ||
| if not totp_input: | ||
| self._logger.error("Invalid TOTP: Cannot be empty.") | ||
| raise errors.ConnectionError("Invalid TOTP: Cannot be empty.") | ||
|
|
||
| # ❌ Validate TOTP format (must be 6 digits) | ||
| if not totp_input.isdigit() or len(totp_input) != 6: | ||
| print("Invalid TOTP format. Please enter a 6-digit code.") | ||
| self._logger.error("Invalid TOTP format entered.") | ||
| raise errors.ConnectionError("Invalid TOTP format: Must be a 6-digit number.") | ||
| # Validate using local precedence-based validator | ||
| result = validate_totp_code(totp_input) | ||
| if not result.ok: | ||
| msg = INVALID_TOTP_MSG | ||
| self._logger.error(msg) | ||
| raise errors.ConnectionError(msg) | ||
| totp_input = result.code | ||
| # ✅ Valid TOTP — retry connection | ||
| totp = totp_input | ||
| self.close_socket() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
totp_is_valid is written as it is reserved for server side checks. but validate_totp_code function itself is called to do the checks before sending to the server. Something is not right, can you please verify?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch — validate_totp_code is purely client-side normalization and format checks. I removed the unused totp_is_valid parameter and updated all call sites. The server remains the authority on code correctness; client failures now return the single generic message.