Skip to content

Commit

Permalink
WIP: Have the sdk always shell out to the proxy
Browse files Browse the repository at this point in the history
  • Loading branch information
legoktm committed Jan 11, 2024
1 parent 4106eef commit 1a025c9
Show file tree
Hide file tree
Showing 2 changed files with 54 additions and 75 deletions.
5 changes: 4 additions & 1 deletion client/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ echo ""

cleanup

gpg --allow-secret-key-import --import tests/files/securedrop.gpg.asc &
gpg --allow-secret-key-import --import tests/files/securedrop.gpg.asc

echo "Building proxy..."
(cd ../proxy && cargo build)

# create the database and config for local testing
poetry run python create_dev_data.py "$SDC_HOME" &
Expand Down
124 changes: 50 additions & 74 deletions client/securedrop_client/sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@
import http
import json
import os
import subprocess
from datetime import datetime # noqa: F401
from subprocess import PIPE, Popen, TimeoutExpired
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urljoin

import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout, TooManyRedirects

from .sdlocalobjects import (
AuthError,
Expand Down Expand Up @@ -109,7 +106,7 @@ def __init__(
self.first_name = None # type: Optional[str]
self.last_name = None # type: Optional[str]
self.req_headers = dict() # type: Dict[str, str]
self.proxy = proxy # type: bool
self.development_mode = not proxy # type: bool
self.default_request_timeout = default_request_timeout or DEFAULT_REQUEST_TIMEOUT
self.default_download_timeout = default_download_timeout or DEFAULT_DOWNLOAD_TIMEOUT

Expand All @@ -122,101 +119,80 @@ def __init__(
except Exception:
pass # We already have a default name

def _send_json_request(
self,
method: str,
path_query: str,
body: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[int] = None,
) -> Tuple[Any, int, Dict[str, str]]:
if self.proxy: # We are using the Qubes securedrop-proxy
func = self._send_rpc_json_request
else: # We are not using the Qubes securedrop-proxy
func = self._send_http_json_request

return func(method, path_query, body, headers, timeout)
def _rpc_target(self):
if not self.development_mode:
return ["/usr/lib/qubes/qrexec-client-vm", self.proxy_vm_name, "securedrop.Proxy"]
# Development mode, find the target directory and look for a debug securedrop-proxy
# binary. We assume that `cargo build` has already been run. We don't use `cargo run`
# because it adds its own output that would interfere with ours.
target_directory = json.loads(
subprocess.check_output(["cargo", "metadata", "--format-version", "1"], text=True)
)["target_directory"]
return f"{target_directory}/debug/securedrop-proxy"

def _send_http_json_request(
self,
method: str,
path_query: str,
body: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[int] = None,
) -> Tuple[Any, int, Dict[str, str]]:
url = urljoin(self.server, path_query)
kwargs = {"headers": headers} # type: Dict[str, Any]

if timeout:
kwargs["timeout"] = timeout

if method == "POST":
kwargs["data"] = body

try:
result = requests.request(method, url, **kwargs)
except (ConnectTimeout, ReadTimeout):
raise RequestTimeoutError
except (TooManyRedirects, ConnectionError):
raise ServerConnectionError

if result.status_code == http.HTTPStatus.FORBIDDEN:
raise AuthError("forbidden")

# Because when we download a file there is no JSON in the body
if path_query.endswith("/download"):
return result, result.status_code, dict(result.headers)

return result.json(), result.status_code, dict(result.headers)

def _send_rpc_json_request(
def _send_json_request(
self,
method: str,
path_query: str,
body: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[int] = None,
) -> Tuple[Any, int, Dict[str, str]]:
data = {"method": method, "path_query": path_query} # type: Dict[str, Any]
data: Dict[str, Any] = {"method": method, "path_query": path_query, "stream": False}

if method == "POST":
if method == "POST" and body:
data["body"] = body

if headers is not None and headers:
if headers:
data["headers"] = headers

if timeout:
data["timeout"] = timeout

data_str = json.dumps(data)

json_result = json_query(self.proxy_vm_name, data_str, timeout)
if not json_result:
raise BaseError("No response from proxy")

try:
result = json.loads(json_result)
except json.decoder.JSONDecodeError:
raise BaseError("Error in parsing JSON")
env = {}
if self.development_mode:
env['SD_PROXY_ORIGIN'] = self.server
response = subprocess.run(
self._rpc_target(), capture_output=True, text=True, timeout=timeout, input=data_str, env=env
)
except subprocess.TimeoutExpired as err:
raise RequestTimeoutError from err

data = json.loads(result["body"])
# error handling
if response.returncode != 0:
try:
error = json.loads(response.stderr)
except json.decoder.JSONDecodeError as err:
raise BaseError("Unable to parse stderr JSON") from err
raise BaseError("Internal proxy error: " + error.get("error", "unknown error"))

try:
result = json.loads(response.stdout)
except json.decoder.JSONDecodeError as err:
raise BaseError("Unable to parse stdout JSON") from err

if "error" in data and result["status"] == http.HTTPStatus.GATEWAY_TIMEOUT:
if result["status"] == http.HTTPStatus.GATEWAY_TIMEOUT:
raise RequestTimeoutError
elif "error" in data and result["status"] == http.HTTPStatus.BAD_GATEWAY:
elif result["status"] == http.HTTPStatus.BAD_GATEWAY:
raise ServerConnectionError
elif "error" in data and result["status"] == http.HTTPStatus.FORBIDDEN:
raise AuthError(data["error"])
elif "error" in data and result["status"] == http.HTTPStatus.BAD_REQUEST:
raise ReplyError(data["error"])
elif "error" in data and result["status"] != http.HTTPStatus.NOT_FOUND:
elif result["status"] == http.HTTPStatus.FORBIDDEN:
raise AuthError("FIXME")
elif result["status"] == http.HTTPStatus.BAD_REQUEST:
raise ReplyError("FIXME")
elif (
str(result["status"]).startswith(("4", "5"))
and result["status"] != http.HTTPStatus.NOT_FOUND
):
# We exclude 404 since if we encounter a 404, it means that an
# item is missing. In that case we return to the caller to
# handle that with an appropriate message. However, if the error
# is not a 404, then we raise.
raise BaseError(data["error"])
raise BaseError("FIXME")

data = json.loads(result["body"])
return data, result["status"], result["headers"]

def authenticate(self, totp: Optional[str] = None) -> bool:
Expand Down Expand Up @@ -610,7 +586,7 @@ def download_submission(
# Get the headers
headers = headers

if not self.proxy:
if not self.development_mode:
# This is where we will save our downloaded file
filepath = os.path.join(path, submission.filename)
with open(filepath, "wb") as fobj:
Expand Down Expand Up @@ -834,7 +810,7 @@ def download_reply(self, reply: Reply, path: str = "") -> Tuple[str, str]:
# Get the headers
headers = headers

if not self.proxy:
if not self.development_mode:
# This is where we will save our downloaded file
filepath = os.path.join(
path, headers["Content-Disposition"].split("attachment; filename=")[1]
Expand Down

0 comments on commit 1a025c9

Please sign in to comment.