-
Notifications
You must be signed in to change notification settings - Fork 0
fix: add security hardening for production stability #5
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
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 |
|---|---|---|
|
|
@@ -28,14 +28,20 @@ | |
| __license__ = "Apache License, Version 2.0" | ||
| """ The license for the module """ | ||
|
|
||
| import os | ||
| import json | ||
|
|
||
| import netius | ||
| import netius.clients | ||
|
|
||
| import pushi | ||
|
|
||
| from . import handler | ||
|
|
||
| # webhook configuration | ||
| WEBHOOK_TIMEOUT = int(os.environ.get("PUSHI_WEBHOOK_TIMEOUT", "10")) | ||
| WEBHOOK_MAX_CONCURRENT = int(os.environ.get("PUSHI_WEBHOOK_MAX_CONCURRENT", "100")) | ||
|
|
||
|
|
||
| class WebHandler(handler.Handler): | ||
| """ | ||
|
|
@@ -51,6 +57,7 @@ class WebHandler(handler.Handler): | |
| def __init__(self, owner): | ||
| handler.Handler.__init__(self, owner, name="web") | ||
| self.subs = {} | ||
| self._active_requests = 0 | ||
|
|
||
| def send(self, app_id, event, json_d, invalid={}): | ||
| # retrieves the reference to the app structure associated with the | ||
|
|
@@ -96,17 +103,6 @@ def send(self, app_id, event, json_d, invalid={}): | |
| data = json.dumps(json_d) | ||
| headers = {"content-type": "application/json"} | ||
|
|
||
| # creates the on message function that is going to be used at the end of | ||
| # the request to be able to close the protocol, this is a clojure and so | ||
| # current local variables will be exposed to the method | ||
| def on_message(protocol, parser, message): | ||
| protocol.close() | ||
|
|
||
| # creates the on close function that will be responsible for the stopping | ||
| # of the loop as defined by the web implementation | ||
| def on_finish(protocol): | ||
| netius.compat_loop(loop).stop() | ||
|
|
||
| # iterates over the complete set of URLs that are going to | ||
| # be notified about the message, each of them is going to | ||
| # received an HTTP post request with the data | ||
|
|
@@ -117,23 +113,91 @@ def on_finish(protocol): | |
| if url in invalid: | ||
| continue | ||
|
|
||
| # prints a debug message about the web message that | ||
| # is going to be sent (includes URL) | ||
| self.logger.debug("Sending POST request to '%s'" % url) | ||
| # check if we have reached the maximum concurrent requests | ||
| if self._active_requests >= WEBHOOK_MAX_CONCURRENT: | ||
| self.logger.warning( | ||
| "Maximum concurrent webhook requests reached, skipping '%s'" % url | ||
| ) | ||
| continue | ||
|
|
||
| # adds the current URL to the list of invalid items for | ||
| # the current message sending stream (do this before sending | ||
| # to prevent duplicate sends in case of rapid events) | ||
| invalid[url] = True | ||
|
|
||
| # send the webhook using netius async callback pattern | ||
| self._send_webhook(url, headers, data) | ||
|
|
||
| def _send_webhook(self, url, headers, data): | ||
| """ | ||
| Sends a webhook POST request using netius async event loop with timeout. | ||
| Uses protocol.delay() for timeout handling instead of threading. | ||
| """ | ||
| self._active_requests += 1 | ||
| self.logger.debug("Sending POST request to '%s'" % url) | ||
|
|
||
| # creates the HTTP protocol to be used in the POST request and | ||
| # sets the headers and the data then registers for the message | ||
| # event so that the loop and protocol may be closed | ||
| # track completion state for timeout handling | ||
| completed = [False] | ||
|
|
||
| try: | ||
| # creates the HTTP protocol to be used in the POST request | ||
| loop, protocol = netius.clients.HTTPClient.post_s( | ||
| url, headers=headers, data=data | ||
| ) | ||
| protocol.bind("message", on_message) | ||
| protocol.bind("finish", on_finish) | ||
| loop.run_forever() | ||
| except Exception as exception: | ||
| self.logger.warning( | ||
| "Error creating HTTP request to '%s': %s" % (url, str(exception)) | ||
| ) | ||
| self._active_requests -= 1 | ||
| return | ||
|
|
||
| # adds the current URL to the list of invalid items for | ||
| # the current message sending stream | ||
| invalid[url] = True | ||
| def on_message(protocol, parser, message): | ||
| protocol.close() | ||
|
|
||
| def on_finish(protocol): | ||
| completed[0] = True | ||
| self._active_requests -= 1 | ||
| try: | ||
| netius.compat_loop(loop).stop() | ||
| except Exception: | ||
| pass | ||
|
|
||
| def on_error(protocol, error): | ||
| self.logger.warning("Webhook error for '%s': %s" % (url, str(error))) | ||
|
|
||
| def on_timeout(): | ||
| if not completed[0]: | ||
| self.logger.warning( | ||
| "Webhook request to '%s' timed out after %d seconds" | ||
| % (url, WEBHOOK_TIMEOUT) | ||
| ) | ||
| try: | ||
| protocol.close() | ||
| netius.compat_loop(loop).stop() | ||
| except Exception: | ||
| pass | ||
| # decrement only if on_finish hasn't run | ||
| if not completed[0]: | ||
| completed[0] = True | ||
| self._active_requests -= 1 | ||
|
|
||
| # bind event handlers | ||
| protocol.bind("message", on_message) | ||
| protocol.bind("finish", on_finish) | ||
| protocol.bind("error", on_error) | ||
|
|
||
| # schedule timeout using netius event loop delay | ||
| protocol.delay(on_timeout, timeout=WEBHOOK_TIMEOUT) | ||
|
|
||
| try: | ||
| loop.run_forever() | ||
| except Exception as exception: | ||
| self.logger.warning( | ||
| "Error sending webhook to '%s': %s" % (url, str(exception)) | ||
| ) | ||
| if not completed[0]: | ||
| completed[0] = True | ||
| self._active_requests -= 1 | ||
|
Comment on lines
+192
to
+200
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. 🧩 Analysis chain🏁 Script executed: # First, let's see the actual code in web.py, especially around lines 131-200 and the send() method
head -250 src/pushi/base/web.py | tail -120Repository: hivesolutions/pushi Length of output: 4159 🏁 Script executed: # Let's also look at the imports and class definition to understand the context better
head -50 src/pushi/base/web.pyRepository: hivesolutions/pushi Length of output: 1567 🏁 Script executed: # Find the send() method to see how it calls _send_webhook
rg -n "def send\(" src/pushi/base/web.py -A 15Repository: hivesolutions/pushi Length of output: 891 🏁 Script executed: # Look for how the event loop is created and managed
rg -n "loop\s*=" src/pushi/base/web.py -B 2 -A 2Repository: hivesolutions/pushi Length of output: 45 🏁 Script executed: # Get the full send() method to see how _send_webhook is called
sed -n '62,150p' src/pushi/base/web.pyRepository: hivesolutions/pushi Length of output: 4109 🏁 Script executed: # Check if there's any threading involved in calling _send_webhook
rg -n "threading\|Thread\|_send_webhook" src/pushi/base/web.py -B 2 -A 2Repository: hivesolutions/pushi Length of output: 45 🏁 Script executed: # Check how netius is used - does it have its own event loop?
rg -n "netius" src/pushi/base/web.pyRepository: hivesolutions/pushi Length of output: 503 🌐 Web query:
💡 Result: netius provides a synchronous "helper" API (methods named *_s, e.g. get_s / post_s) that runs requests via netius's internal event loop and supports both synchronous (blocking) and asynchronous (callback) usage. See the project docs and PyPI page for details. [1][2] Quick notes:
Sources: 🌐 Web query:
💡 Result: I can help — can you paste the code or show where you saw "compat_loop" (file, library version, or a link)? I couldn't find an authoritative reference for "netius compat_loop" and need the exact context to give an accurate explanation. 🏁 Script executed: # Check if netius imports any threading or if post_s spawns threads internally
grep -n "import" src/pushi/base/web.py | head -20Repository: hivesolutions/pushi Length of output: 178 🏁 Script executed: # Look for any handler/callback thread spawning in the code
rg -n "thread\|Thread\|executor\|spawn" src/pushi/base/web.pyRepository: hivesolutions/pushi Length of output: 45 🏁 Script executed: # Check git history to understand the intent - was this intentionally made blocking?
git log --oneline src/pushi/base/web.py | head -5Repository: hivesolutions/pushi Length of output: 124 🏁 Script executed: # Let's look at netius module info if available
python3 << 'EOF'
try:
import netius
import inspect
# Try to understand post_s signature and behavior
print("netius version:", getattr(netius, "__version__", "unknown"))
print("\nHTTPClient.post_s signature:")
print(inspect.signature(netius.clients.HTTPClient.post_s))
except Exception as e:
print("Error:", e)
EOFRepository: hivesolutions/pushi Length of output: 149
Each call to To achieve true concurrency, consider:
🧰 Tools🪛 Ruff (0.14.10)194-194: Do not catch blind exception: (BLE001) 🤖 Prompt for AI Agents |
||
|
|
||
| def load(self): | ||
| subs = pushi.Web.find() | ||
|
|
||
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.
Race condition in concurrency limit check.
The check at lines 118-124 reads
_active_requestsunder lock, but by the time_send_webhook_asyncspawns a thread and increments the counter (lines 141-142), other threads may have already passed the same check. This can allow more concurrent requests thanWEBHOOK_MAX_CONCURRENT.Consider moving the increment and check into a single atomic operation:
🔎 Proposed fix
Then in
_send_webhook_async, remove the increment indo_sendsince it's already done:🤖 Prompt for AI Agents