Skip to content

Commit

Permalink
Add option to enable proxy header
Browse files Browse the repository at this point in the history
Add a new option that configure the list of trusted address to parse the proxy headers from.
The parsing is handled by `uvicorn` which currently only support `x-forwarded-{for,proto}` headers.

Closes #8626, closes #8427

Co-authored-by: Marcos Medrano <[email protected]>
  • Loading branch information
FirelightFlagboy and mmmarcos committed Oct 10, 2024
1 parent 28a78bd commit 0c0d9e3
Show file tree
Hide file tree
Showing 9 changed files with 51 additions and 81 deletions.
46 changes: 18 additions & 28 deletions docs/HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,24 @@

# Hosting Server

- [Hosting Server](#hosting-server)
- [Requirements](#requirements)
- [Hosting](#hosting)
- [Installation](#installation)
- [Run](#run)
- [Settings](#settings)
- [Host](#host)
- [Port](#port)
- [Database URL](#database-url)
- [Database connections](#database-connections)
- [Blockstore URL](#blockstore-url)
- [Administration token](#administration-token)
- [SSL](#ssl)
- [Logs](#logs)
- [Email](#email)
- [Webhooks](#webhooks)
- [SSE Keepalive](#sse-keepalive)
- [Sentry](#sentry)
- [Debug](#debug)
- [Requirements](#requirements)
- [Hosting](#hosting)
- [Installation](#installation)
- [Run](#run)
- [Settings](#settings)
- [Host](#host)
- [Port](#port)
- [Database URL](#database-url)
- [Database connections](#database-connections)
- [Blockstore URL](#blockstore-url)
- [Administration token](#administration-token)
- [SSL](#ssl)
- [Logs](#logs)
- [Email](#email)
- [Webhooks](#webhooks)
- [SSE Keepalive](#sse-keepalive)
- [Sentry](#sentry)
- [Debug](#debug)

## Requirements

Expand Down Expand Up @@ -168,15 +167,6 @@ SSL key file. This setting enables serving Parsec over SSL.
SSL certificate file. This setting enables serving Parsec over SSL.
- ``--forward-proto-enforce-https``
- Environ: ``PARSEC_FORWARD_PROTO_ENFORCE_HTTPS``
Enforce HTTPS by redirecting incoming request that do not comply with the provided header.
This is useful when running Parsec behind a forward proxy handing the SSL layer.
You should *only* use this setting if you control your proxy or have some other
guarantee that it sets/strips this header appropriately.
Typical value for this setting should be `X-Forwarded-Proto:https`.
### Logs
- ``--log-level <level>, -l <level>``
Expand Down
3 changes: 0 additions & 3 deletions docs/hosting/deployment/parsec.env
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ PARSEC_SSL_KEYFILE=/run/secrets/parsec-pem-key
# The SSL certificate file.
PARSEC_SSL_CERTFILE=/run/secrets/parsec-pem-crt

# Enforce HTTPS by redirecting HTTP request.
PARSEC_FORWARD_PROTO_ENFORCE_HTTPS=X-Forwarded-Proto:https

# The granularity of Error log outputs.
PARSEC_LOG_LEVEL=WARNING

Expand Down
1 change: 1 addition & 0 deletions newsfragments/8626.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add server option ``--proxy-trusted-addresses`` to enable parsing of proxy headers from trusted addresses. By default, the server will trust the proxy headers from localhost.
2 changes: 0 additions & 2 deletions server/packaging/server/template-prod.env.list
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ PARSEC_DB_MAX_CONNECTIONS=7
# PARSEC_SSL_KEYFILE
# The SSL certificate file.
# PARSEC_SSL_CERTFILE
# Enforce HTTPS by redirecting HTTP request.
PARSEC_FORWARD_PROTO_ENFORCE_HTTPS=true

# The granularity of Error log outputs.
PARSEC_LOG_LEVEL=WARNING
Expand Down
24 changes: 9 additions & 15 deletions server/parsec/asgi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,25 +60,11 @@ async def page_not_found(scope: Scope, receive: Receive, send: Send) -> None:
app: AsgiApp = app_factory()


# TODO: implement forward_proto_enforce_https
# # Do https redirection if incoming request doesn't follow forward proto rules
# if backend.config.forward_proto_enforce_https:
# header_key, header_expected_value = backend.config.forward_proto_enforce_https

# @app.before_request
# def redirect_unsecure() -> ResponseReturnValue | None:
# header_value = request.headers.get(header_key)
# # If redirection header match and protocol match, then no need for a redirection.
# if header_value is not None and header_value != header_expected_value:
# if request.url.startswith("http://"):
# return quart_redirect(request.url.replace("http://", "https://", 1), code=301)
# return None


async def serve_parsec_asgi_app(
app: AsgiApp,
host: str,
port: int,
proxy_trusted_addresses: list[str],
ssl_certfile: Path | None = None,
ssl_keyfile: Path | None = None,
workers: int | None = None,
Expand All @@ -93,6 +79,7 @@ async def serve_parsec_asgi_app(
v_major, _ = parsec_version.split(".", 1)
# ex: parsec/3
server_header = f"parsec/{v_major}"

# Note: Uvicorn comes with default values for incoming data size to
# avoid DoS abuse, so just trust them on that ;-)
config = uvicorn.Config(
Expand All @@ -106,6 +93,13 @@ async def serve_parsec_asgi_app(
ssl_keyfile=ssl_keyfile, # type: ignore
ssl_certfile=ssl_certfile,
workers=workers,
# Enable/Disable X-Forwarded-Proto, X-Forwarded-For to populate remote address info.
# When enabled, is restricted to only trusting connecting IPs in forwarded-allow-ips.
# See: https://www.uvicorn.org/settings/#http
# Currently uvicorn only supports X-Forwarded-* headers (https://github.com/encode/uvicorn/issues/2237)
proxy_headers=(proxy_trusted_addresses != []),
# Comma separated list of IP Addresses, IP Networks, or literals (e.g. UNIX Socket path) to trust with proxy headers
forwarded_allow_ips=proxy_trusted_addresses,
# TODO: configure access log format:
# Timestamp is added by the log processor configured in `parsec.logging`,
# here we configure peer address + req line + rep status + rep body size + time
Expand Down
39 changes: 11 additions & 28 deletions server/parsec/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,6 @@
DEFAULT_EMAIL_SENDER = "[email protected]"


def _parse_forward_proto_enforce_https_check_param(
raw_param: str | None,
) -> tuple[str, str] | None:
if raw_param is None:
return None
try:
key, value = raw_param.split(":")
except ValueError:
raise click.BadParameter("Invalid format, should be `<header-name>:<header-value>`")
# HTTP header key is case-insensitive unlike the header value
return (key.lower(), value)


def _parse_organization_initial_tos_url(raw_param: str | None) -> dict[TosLocale, TosUrl] | None:
if raw_param is None:
return None
Expand Down Expand Up @@ -292,20 +279,15 @@ def handle_parse_result(
help="Sender address used in sent emails",
)
@click.option(
"--forward-proto-enforce-https",
type=str,
show_default=True,
default=None,
callback=lambda ctx, param, value: _parse_forward_proto_enforce_https_check_param(value),
envvar="PARSEC_FORWARD_PROTO_ENFORCE_HTTPS",
"--proxy-trusted-addresses",
default=["localhost", "127.0.0.1", "::1"],
envvar="PARSEC_PROXY_TRUSTED_ADDRESSES",
callback=lambda ctx, param, value: [item.strip() for item in str(value).split(",")],
show_envvar=True,
help=(
"Enforce HTTPS by redirecting incoming request that do not comply with the provided header."
" This is useful when running Parsec behind a forward proxy handing the SSL layer."
" You should *only* use this setting if you control your proxy or have some other"
" guarantee that it sets/strips this header appropriately."
" Typical value for this setting should be `X-Forwarded-Proto:https`."
),
help="""\b
Comma-separated list of IP Addresses, IP Networks or literals to trust with proxy headers.
Set this value to allow the server to use the forwarded headers from those clients.
""",
)
@click.option(
"--ssl-keyfile",
Expand Down Expand Up @@ -373,7 +355,7 @@ def run_cmd(
email_use_ssl: bool,
email_use_tls: bool,
email_sender: str | None,
forward_proto_enforce_https: tuple[str, str] | None,
proxy_trusted_addresses: list[str],
ssl_keyfile: Path | None,
ssl_certfile: Path | None,
log_level: LogLevel,
Expand Down Expand Up @@ -418,7 +400,7 @@ def run_cmd(
sse_keepalive=sse_keepalive,
blockstore_config=blockstore,
email_config=email_config,
forward_proto_enforce_https=forward_proto_enforce_https,
proxy_trusted_addresses=proxy_trusted_addresses,
server_addr=server_addr,
debug=debug,
organization_bootstrap_webhook_url=organization_bootstrap_webhook,
Expand Down Expand Up @@ -505,6 +487,7 @@ async def _run_backend(
port=port,
ssl_certfile=ssl_certfile,
ssl_keyfile=ssl_keyfile,
proxy_trusted_addresses=app_config.proxy_trusted_addresses,
)
return

Expand Down
6 changes: 4 additions & 2 deletions server/parsec/cli/testbed.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ async def testbed_backend_factory(
debug=True,
db_config=db_config,
sse_keepalive=30,
forward_proto_enforce_https=None,
proxy_trusted_addresses=[],
server_addr=server_addr,
email_config=MockedEmailConfig("[email protected]", tmpdir),
blockstore_config=blockstore_config,
Expand Down Expand Up @@ -343,7 +343,9 @@ async def _watch_and_stop_after_process(pid: int, cancel_scope: anyio.CancelScop

app.state.testbed = testbed
app.state.backend = testbed.backend
await serve_parsec_asgi_app(host=host, port=port, app=app)
await serve_parsec_asgi_app(
host=host, port=port, app=app, proxy_trusted_addresses=[]
)

click.echo("bye ;-)")

Expand Down
2 changes: 1 addition & 1 deletion server/parsec/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ class BackendConfig:
blockstore_config: BaseBlockStoreConfig

email_config: SmtpEmailConfig | MockedEmailConfig
forward_proto_enforce_https: tuple[str, str] | None
proxy_trusted_addresses: list[str]
server_addr: ParsecAddr | None

debug: bool
Expand Down
9 changes: 7 additions & 2 deletions server/tests/common/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
from parsec.backend import Backend, backend_factory
from parsec.cli.testbed import TestbedBackend, TestbedTemplate
from parsec.components.memory.organization import MemoryOrganization, OrganizationID
from parsec.config import BackendConfig, BaseBlockStoreConfig, BaseDatabaseConfig, MockedEmailConfig
from parsec.config import (
BackendConfig,
BaseBlockStoreConfig,
BaseDatabaseConfig,
MockedEmailConfig,
)
from tests.common.postgresql import reset_postgresql_testbed

SERVER_DOMAIN = "parsec.invalid"
Expand All @@ -33,7 +38,7 @@ def backend_config(
debug=True,
db_config=db_config,
sse_keepalive=30,
forward_proto_enforce_https=None,
proxy_trusted_addresses=[],
server_addr=ParsecAddr(hostname=SERVER_DOMAIN, port=None, use_ssl=True),
email_config=MockedEmailConfig("[email protected]", tmpdir),
blockstore_config=blockstore_config,
Expand Down

0 comments on commit 0c0d9e3

Please sign in to comment.