-
Notifications
You must be signed in to change notification settings - Fork 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
callback addon: support intercepting requests #105
Changes from 1 commit
de656f0
26941ae
005bb91
7b50a78
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 |
---|---|---|
|
@@ -10,7 +10,7 @@ | |
from urllib.error import HTTPError, URLError | ||
from datetime import datetime | ||
|
||
# Callback will intercept a response and send a POST request to the provided callback_url, with | ||
# Callback will intercept a request and/or response and send a POST request to the provided url, with | ||
# the following JSON object. Supports filters: https://docs.mitmproxy.org/stable/concepts-filters/ | ||
# { | ||
# method: "GET|PUT|...", | ||
|
@@ -20,10 +20,25 @@ | |
# response_body: { some json object }, | ||
# response_code: 200, | ||
# } | ||
# Currently this is a read-only callback. The response cannot be modified, but side-effects can be | ||
# taken. For example, tests may wish to terminate a client prior to the delivery of a response but | ||
# after the server has processed the request, or the test may wish to use the response as a | ||
# synchronisation point for a Waiter. | ||
# The response to this request can control what gets returned to the client. The response object: | ||
# { | ||
# "respond_status_code": 200, | ||
# "respond_body": { "some": "json_object" } | ||
# } | ||
# If {} is sent back, the response is not modified. Likewise, if `respond_body` is set but | ||
# `respond_status_code` is not, only the response body is modified, not the status code, and vice versa. | ||
# | ||
# To use this addon, configure it with these fields: | ||
# - callback_request_url: the URL to send outbound requests to. This allows callbacks to intercept | ||
# requests BEFORE they reach the server. The request/response struct in this | ||
# callback is the same as `callback_response_url`, except `response_body` | ||
# and `response_code` will be missing as the request hasn't been processed | ||
# yet. To block the request from reaching the server, the callback needs to | ||
# provide both `respond_status_code` and `respond_body`. | ||
# - callback_response_url: the URL to send inbound responses to. This allows callbacks to modify | ||
# response content. | ||
# - filter: the mitmproxy filter to apply. If unset, ALL requests are eligible to go to the callback | ||
# server. | ||
class Callback: | ||
def __init__(self): | ||
self.reset() | ||
|
@@ -32,38 +47,64 @@ def __init__(self): | |
|
||
def reset(self): | ||
self.config = { | ||
"callback_url": "", | ||
"callback_request_url": "", | ||
"callback_response_url": "", | ||
Comment on lines
+50
to
+51
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. given that the whole thing is a Callback, the 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. I prefix this on purpose because there are a lot of request/response keys flying around (the ones you register with the addon, the ones passed in as req body to the callback server, the res body from the callback server) and I've tried to use |
||
"filter": None, | ||
} | ||
|
||
def load(self, loader): | ||
loader.add_option( | ||
name="callback", | ||
typespec=dict, | ||
default={"callback_url": "", "filter": None}, | ||
default={ | ||
"callback_request_url": "", | ||
"callback_response_url": "", | ||
Comment on lines
+60
to
+61
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. seems like the options are, well, optional (we use 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. They are optional but in absence of having |
||
"filter": None, | ||
}, | ||
help="Change the callback url, with an optional filter", | ||
) | ||
|
||
def configure(self, updates): | ||
if "callback" not in updates: | ||
self.reset() | ||
return | ||
if ctx.options.callback is None or ctx.options.callback["callback_url"] == "": | ||
if ctx.options.callback is None: | ||
self.reset() | ||
return | ||
self.config = ctx.options.callback | ||
new_filter = self.config.get('filter', None) | ||
print(f"callback will hit {self.config['callback_url']} filter={new_filter}") | ||
print(f"callback req_url={self.config.get('callback_request_url')} res_url={self.config.get('callback_response_url')} filter={new_filter}") | ||
if new_filter: | ||
self.filter = flowfilter.parse(new_filter) | ||
else: | ||
self.filter = self.matchall | ||
|
||
async def request(self, flow): | ||
# always ignore the controller | ||
if flow.request.pretty_host == MITM_DOMAIN_NAME: | ||
return | ||
if self.config.get("callback_request_url", "") == "": | ||
return # ignore requests if we aren't told a url | ||
if not flowfilter.match(self.filter, flow): | ||
return # ignore requests which don't match the filter | ||
try: # e.g GET requests have no req body | ||
req_body = flow.request.json() | ||
except: | ||
req_body = None | ||
print(f'{datetime.now().strftime("%H:%M:%S.%f")} hitting request callback for {flow.request.url}') | ||
callback_body = { | ||
"method": flow.request.method, | ||
"access_token": flow.request.headers.get("Authorization", "").removeprefix("Bearer "), | ||
"url": flow.request.url, | ||
"request_body": req_body, | ||
} | ||
await self.send_callback(flow, self.config["callback_request_url"], callback_body) | ||
|
||
async def response(self, flow): | ||
# always ignore the controller | ||
if flow.request.pretty_host == MITM_DOMAIN_NAME: | ||
return | ||
if self.config["callback_url"] == "": | ||
if self.config.get("callback_response_url","") == "": | ||
return # ignore responses if we aren't told a url | ||
if flowfilter.match(self.filter, flow): | ||
try: # e.g GET requests have no req body | ||
|
@@ -74,7 +115,7 @@ async def response(self, flow): | |
res_body = flow.response.json() | ||
except: | ||
res_body = None | ||
print(f'{datetime.now().strftime("%H:%M:%S.%f")} hitting callback for {flow.request.url}') | ||
print(f'{datetime.now().strftime("%H:%M:%S.%f")} hitting response callback for {flow.request.url}') | ||
callback_body = { | ||
"method": flow.request.method, | ||
"access_token": flow.request.headers.get("Authorization", "").removeprefix("Bearer "), | ||
|
@@ -83,25 +124,27 @@ async def response(self, flow): | |
"request_body": req_body, | ||
"response_body": res_body, | ||
} | ||
try: | ||
# use asyncio so we don't block other unrelated requests from being processed | ||
async with aiohttp.request( | ||
method="POST",url=self.config["callback_url"], timeout=aiohttp.ClientTimeout(total=10), | ||
headers={"Content-Type": "application/json"}, | ||
json=callback_body) as response: | ||
print(f'{datetime.now().strftime("%H:%M:%S.%f")} callback for {flow.request.url} returned HTTP {response.status}') | ||
test_response_body = await response.json() | ||
# if the response includes some keys then we are modifying the response on a per-key basis. | ||
if len(test_response_body) > 0: | ||
respond_status_code = test_response_body.get("respond_status_code", flow.response.status_code) | ||
respond_body = test_response_body.get("respond_body", res_body) | ||
flow.response = Response.make( | ||
respond_status_code, json.dumps(respond_body), | ||
headers={ | ||
"MITM-Proxy": "yes", # so we don't reprocess this | ||
"Content-Type": "application/json", | ||
}) | ||
await self.send_callback(flow, self.config["callback_response_url"], callback_body) | ||
|
||
except Exception as error: | ||
print(f"ERR: callback for {flow.request.url} returned {error}") | ||
print(f"ERR: callback, provided request body was {callback_body}") | ||
async def send_callback(self, flow, url: str, body: dict): | ||
try: | ||
# use asyncio so we don't block other unrelated requests from being processed | ||
async with aiohttp.request( | ||
method="POST",url=url, timeout=aiohttp.ClientTimeout(total=10), | ||
headers={"Content-Type": "application/json"}, | ||
json=body) as response: | ||
kegsay marked this conversation as resolved.
Show resolved
Hide resolved
|
||
print(f'{datetime.now().strftime("%H:%M:%S.%f")} callback for {flow.request.url} returned HTTP {response.status}') | ||
test_response_body = await response.json() | ||
# if the response includes some keys then we are modifying the response on a per-key basis. | ||
if len(test_response_body) > 0: | ||
respond_status_code = test_response_body.get("respond_status_code", flow.response.status_code) | ||
respond_body = test_response_body.get("respond_body", body["response_body"]) | ||
flow.response = Response.make( | ||
respond_status_code, json.dumps(respond_body), | ||
headers={ | ||
"MITM-Proxy": "yes", # so we don't reprocess this | ||
"Content-Type": "application/json", | ||
}) | ||
except Exception as error: | ||
print(f"ERR: callback for {flow.request.url} returned {error}") | ||
print(f"ERR: callback, provided request body was {body}") |
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.
This only applies to
callback_response_url
, right?I think it might be clearer to document the request and response for the two URLs completely separately, even if there's a bit of duplication.
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.
Yes.
Sounds good, I'm going to document this in the README where it's more easily accessible. But that will be in the next PR.