diff --git a/easebuzz/api.py b/easebuzz/api.py index 30c012a..67b6823 100644 --- a/easebuzz/api.py +++ b/easebuzz/api.py @@ -1,16 +1,19 @@ import frappe import requests + @frappe.whitelist(allow_guest=True) def webhook_handler(**kwargs): try: data = frappe.parse_json(kwargs) controller = frappe.get_last_doc("Easebuzz Settings") if data.get("udf3") == "webform": - controller.handle_response_web_form(data) - else: - controller.handle_response(data) - url = "https://fees.walnutedu.in/index.php/payment/easebuzz_webhook_callback" - r = requests.post(url, json = data) + return controller.handle_response_web_form(data) + controller.handle_response(data) + url = "https://fees.walnutedu.in/index.php/payment/easebuzz_webhook_callback" + requests.post(url, json=data, timeout=(5, 30)) + return {"message": "ok"} except Exception as e: - frappe.logger("easebuzz").exception(e) \ No newline at end of file + frappe.local.response["http_status_code"] = 400 + frappe.logger("easebuzz").exception(e) + return {"message": "invalid callback"} diff --git a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py index 59d133b..782593c 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py @@ -2,8 +2,11 @@ # For license information, please see license.txt import json + import frappe +from frappe import _ from frappe.model.document import Document +from easebuzz.easebuzz.utils import payment as easebuzz_payment from easebuzz.easebuzz.utils.easebuzz_payment_gateway import Easebuzz from frappe.utils import call_hook_method from frappe.utils.data import cint, flt @@ -169,19 +172,36 @@ def get_settings(self, data): return settings + def validate_gateway_response(self, data): + salt = self.get_password(fieldname="salt", raise_exception=False) + response = easebuzz_payment.easebuzzResponse(data, salt) + if response.get("status") != 1: + frappe.throw(_("Invalid Easebuzz callback")) + return response.get("data", {}) + + def _reject_replay(self, doc, transaction_id): + existing_transaction_id = getattr(doc, "transaction_id", None) + if existing_transaction_id and existing_transaction_id != transaction_id: + frappe.throw(_("Conflicting transaction callback received")) + if existing_transaction_id == transaction_id: + return True + return False + def handle_response(self, data): + data = self.validate_gateway_response(data) payment_request_doctype = data.get("udf1") payment_request_docname = data.get("udf2") status = data.get("status") transaction_id = data.get("txnid") if status == "success": if frappe.db.exists(payment_request_doctype, payment_request_docname): - frappe.msgprint("Payment Request exists") payment_request = frappe.get_doc( payment_request_doctype, payment_request_docname, ignore_permissions=True, ) + if self._reject_replay(payment_request, transaction_id): + return {"message": "Duplicate callback ignored"} frappe.db.set_value( payment_request_doctype, payment_request_docname, @@ -190,10 +210,10 @@ def handle_response(self, data): ) payment_request.on_payment_authorized(status="Completed") return {"message": "Payment Successful"} - else: - frappe.msgprint("Payment Request does not exist, Invalid Request") + frappe.throw(_("Payment Request does not exist, Invalid Request")) def handle_response_web_form(self, data): + data = self.validate_gateway_response(data) doctype = data.get("udf1") docname = data.get("udf2") docname = docname.replace("@", "(").replace("#", ")") @@ -201,11 +221,12 @@ def handle_response_web_form(self, data): transaction_id = data.get("txnid") if status == "success": if frappe.db.exists(doctype, docname): - frappe.db.set_value(doctype, docname, "transaction_id", transaction_id) doc = frappe.get_doc(doctype, docname, ignore_permissions=True) + if self._reject_replay(doc, transaction_id): + return {"message": "Duplicate callback ignored"} + frappe.db.set_value(doctype, docname, "transaction_id", transaction_id) return doc.validate_payment(data) - else: - frappe.log_error(f"{doctype} {docname} does not exist") + frappe.throw(_("{0} {1} does not exist").format(doctype, docname)) def initiateRefund(self, data): amounts = float(data.get("amount")) @@ -223,8 +244,10 @@ def initiateRefund(self, data): return response -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() def get_merchant_key(): + if frappe.session.user == "Guest": + frappe.throw(_("Authentication required"), frappe.PermissionError) controller = frappe.get_doc("Easebuzz Settings") return controller.merchant_key diff --git a/easebuzz/easebuzz/utils/http.py b/easebuzz/easebuzz/utils/http.py new file mode 100644 index 0000000..7466793 --- /dev/null +++ b/easebuzz/easebuzz/utils/http.py @@ -0,0 +1,34 @@ +import time + +import requests +from requests import RequestException + +DEFAULT_CONNECT_TIMEOUT = 5 +DEFAULT_READ_TIMEOUT = 30 +DEFAULT_MAX_ATTEMPTS = 3 +DEFAULT_BACKOFF_SECONDS = 1 +DEFAULT_STATUS_RETRY_CODES = {408, 429, 500, 502, 503, 504} + + +def post_with_retries(url, data, timeout=None, max_attempts=DEFAULT_MAX_ATTEMPTS, backoff_seconds=DEFAULT_BACKOFF_SECONDS, retry_status_codes=None): + timeout = timeout or (DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT) + retry_status_codes = retry_status_codes or DEFAULT_STATUS_RETRY_CODES + last_error = None + + for attempt in range(1, max_attempts + 1): + try: + response = requests.post(url, data, timeout=timeout) + if response.status_code not in retry_status_codes or attempt == max_attempts: + return response + last_error = requests.HTTPError(f"Retryable HTTP status: {response.status_code}", response=response) + except (requests.Timeout, requests.ConnectionError) as exc: + last_error = exc + if attempt == max_attempts: + raise + except RequestException: + raise + + time.sleep(backoff_seconds * (2 ** (attempt - 1))) + + if last_error: + raise last_error diff --git a/easebuzz/easebuzz/utils/payment.py b/easebuzz/easebuzz/utils/payment.py index 95b59ac..e8370af 100644 --- a/easebuzz/easebuzz/utils/payment.py +++ b/easebuzz/easebuzz/utils/payment.py @@ -1,10 +1,11 @@ from hashlib import sha512 -import requests import json import re import traceback +from .http import post_with_retries + # import webbrowser ''' @@ -538,7 +539,7 @@ def _pay(params_array, salt_key, url): params_array['hash'] = hash_key # requests call for initiate pay link - request_result = requests.post(url + 'payment/initiateLink', params_array) + request_result = post_with_retries(url + 'payment/initiateLink', params_array) result = json.loads(request_result.content) # print(params_array) if result['status'] == 1: diff --git a/easebuzz/easebuzz/utils/payout.py b/easebuzz/easebuzz/utils/payout.py index 73721a4..e7132a5 100644 --- a/easebuzz/easebuzz/utils/payout.py +++ b/easebuzz/easebuzz/utils/payout.py @@ -1,10 +1,11 @@ from hashlib import sha512 -import requests import json import re import traceback +from .http import post_with_retries + ''' * * get_payout_details_by_date method payout amount and dispay the payout response. @@ -342,7 +343,7 @@ def _payoutPayment(params_array, salt_key, url): params_array['hash'] = hash_key # requests call for retrive all payout - request_result = requests.post(url + 'payout/v1/retrieve', params_array) + request_result = post_with_retries(url + 'payout/v1/retrieve', params_array) result = json.loads(request_result.content) return result diff --git a/easebuzz/easebuzz/utils/refund.py b/easebuzz/easebuzz/utils/refund.py index 8ede35a..a9126df 100644 --- a/easebuzz/easebuzz/utils/refund.py +++ b/easebuzz/easebuzz/utils/refund.py @@ -1,10 +1,11 @@ from hashlib import sha512 -import requests import json import re import traceback +from .http import post_with_retries + ''' * * initiate_refund method refund amount and call dispay the refund page. @@ -387,7 +388,7 @@ def _refundPayment(params_array, salt_key, url): params_array['hash'] = hash_key # requests call for retrive transaction - request_result = requests.post(url + 'transaction/v1/refund', params_array) + request_result = post_with_retries(url + 'transaction/v1/refund', params_array) return json.loads(request_result.content) diff --git a/easebuzz/easebuzz/utils/transaction.py b/easebuzz/easebuzz/utils/transaction.py index 4c02362..ab9b409 100644 --- a/easebuzz/easebuzz/utils/transaction.py +++ b/easebuzz/easebuzz/utils/transaction.py @@ -1,10 +1,11 @@ from hashlib import sha512 -import requests import json import re import traceback +from .http import post_with_retries + ''' * * get_transaction_details method use for transaction @@ -376,7 +377,7 @@ def _getTransaction(params_array, salt_key, url): params_array['hash'] = hash_key # requests call for retrive transaction - request_result = requests.post(url + 'transaction/v1/retrieve', params_array) + request_result = post_with_retries(url + 'transaction/v1/retrieve', params_array) return json.loads(request_result.content) diff --git a/easebuzz/easebuzz/utils/transaction_date.py b/easebuzz/easebuzz/utils/transaction_date.py index a95767b..16fc419 100644 --- a/easebuzz/easebuzz/utils/transaction_date.py +++ b/easebuzz/easebuzz/utils/transaction_date.py @@ -1,10 +1,11 @@ from hashlib import sha512 -import requests import json import re import traceback +from .http import post_with_retries + ''' * * get_transactions_by_date method use for transaction based on date @@ -296,7 +297,7 @@ def _getDateTransaction(params_array, salt_key, url): params_array['hash'] = hash_key # requests call for retrive transaction based on date - request_result = requests.post(url + 'transaction/v1/retrieve/date', params_array) + request_result = post_with_retries(url + 'transaction/v1/retrieve/date', params_array) temp_result = json.loads(request_result.content) return temp_result diff --git a/easebuzz/www/easebuzz/success.py b/easebuzz/www/easebuzz/success.py index 5e766b1..df476dc 100644 --- a/easebuzz/www/easebuzz/success.py +++ b/easebuzz/www/easebuzz/success.py @@ -3,14 +3,9 @@ def get_context(context): try: - if frappe.session.user == "Guest": - frappe.set_user("Administrator") - data = frappe.form_dict - handle_data(data) - frappe.set_user("Guest") - else: - handle_data(data) + handle_data(frappe.form_dict) except Exception as e: + frappe.local.response["http_status_code"] = 400 frappe.logger("easebuzz").exception(e) diff --git a/tests_security_smoke.py b/tests_security_smoke.py new file mode 100644 index 0000000..9ce2496 --- /dev/null +++ b/tests_security_smoke.py @@ -0,0 +1,47 @@ +from hashlib import sha512 +from unittest.mock import patch + +import requests + +from easebuzz.easebuzz.utils import payment +from easebuzz.easebuzz.utils.http import post_with_retries + + +def build_callback_payload(salt="secret"): + data = { + "status": "success", + "txnid": "txn-1", + "amount": "100.00", + "productinfo": "Order", + "firstname": "Test", + "email": "test@example.com", + "phone": "9999999999", + "udf1": "Payment Request", + "udf2": "PR-0001", + "udf3": "", + "udf4": "", + "udf5": "", + "key": "merchant", + "surl": "https://example.com/success", + "furl": "https://example.com/failure", + } + seq = "udf10|udf9|udf8|udf7|udf6|udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|txnid|key".split("|") + reverse = salt + "|" + data["status"] + for field in seq: + reverse += "|" + str(data.get(field, "")) + data["hash"] = sha512(reverse.encode("utf-8")).hexdigest().lower() + return data + + +payload = build_callback_payload() +assert payment.easebuzzResponse(payload, "secret")["status"] == 1 + +with patch("easebuzz.easebuzz.utils.http.requests.post") as mocked: + mocked.side_effect = [ + requests.Timeout(), + type("Resp", (), {"status_code": 200, "content": b"{}"})(), + ] + response = post_with_retries("https://example.com", {"a": 1}, max_attempts=2, backoff_seconds=0) + assert response.status_code == 200 + +print("security smoke checks passed")