getting this problem from firefox or chrome Blocked loading mixed active content using fastapi with jinja2, traefik letsencrypt

Viewed 668

ok first i think i know about this issue where my css file is using something http instead of https in the href but that is generated by something like this in my index.html. Note that i am using traefik for reverse proxy and having letsencrypt to handle https cert

i am following this guide https://testdriven.io/blog/fastapi-docker-traefik/#traefik

while i change the codes inside for fastapi for my own but the rest such as traefik and docker etc is same

<link href="{{ url_for('static', path='/css/style.css') }}" rel="stylesheet">

as for my main.py

app.mount(
    "/static",
    StaticFiles(directory=settings.ROOT_DIR / "static"),
    name="static",
)

so currently, after the rendering, i found out that my css file is loading from http instead of https. Is there any way to fix this to make it https instead of http?

also for my file structure is something like this

app folder
 |main.py
html folder
 |index.html
static folder
 |css folder
  |style.css

my traefik configuration traefik.toml

# traefik.prod.toml

[entryPoints]
  [entryPoints.web]
    address = ":80"
  [entryPoints.web.http]
    [entryPoints.web.http.redirections]
      [entryPoints.web.http.redirections.entryPoint]
        to = "websecure"
        scheme = "https"

  [entryPoints.websecure]
    address = ":443"

[accessLog]

[api]
dashboard = true

[providers]
  [providers.docker]
    exposedByDefault = false

[certificatesResolvers.letsencrypt.acme]
  email = "testtest@gmail.com"
  storage = "/certificates/acme.json"
  [certificatesResolvers.letsencrypt.acme.httpChallenge]
    entryPoint = "web"

and my compose

# docker-compose.prod.yml

version: '3.8'

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.prod
    expose:
      - 80
    environment:
      - DATABASE_URL=$DATABASE_URL
    depends_on:
      - db
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.fastapi.rule=Host(`test.domain.tk`)"
      - "traefik.http.routers.fastapi.tls=true"
      - "traefik.http.routers.fastapi.tls.certresolver=letsencrypt"
  db:
    image: postgres:13-alpine
    volumes:
      - postgres_data_prod:/var/lib/postgresql/data/
    expose:
      - 5432
    environment:
      - POSTGRES_USER=$POSTGRES_USER
      - POSTGRES_PASSWORD=$POSTGRES_PASSWORD
      - POSTGRES_DB=$POSTGRES_DB
  traefik:
    build:
      context: .
      dockerfile: Dockerfile.traefik
    ports:
      - 80:80
      - 443:443
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./traefik-public-certificates:/certificates"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=Host(`dashboard.domain.tk`)"
      - "traefik.http.routers.dashboard.tls=true"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=auth"
      - "traefik.http.middlewares.auth.basicauth.users=$TRAEFIK_DASHBOARD_USER:$TRAEFIK_DASHBOARD_PASSWORD"

volumes:
  postgres_data_prod:
  traefik-public-certificates:

my docker file

# Dockerfile.prod

FROM tiangolo/uvicorn-gunicorn:python3.8-slim

RUN apt-get update && apt-get install -y netcat

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY . .

and i have a prestart script

# prestart.sh

echo "Waiting for postgres connection"

while ! nc -z db 5432; do
    sleep 0.1
done

echo "PostgreSQL started"

exec "$@"
1 Answers

Note: To keep things universal, I'm intentionally not focusing on any Proxy Software.

The problem is that url_for (from Starlette) produces the scheme part based on the current context.

There is - in my opinion - 2 possible context:

  1. Your client speaks directly with your Uvicorn/Gunicorn instance.

  2. Your client speaks indirectly with your Uvicorn/Gunicorn instance - through a proxy.


1st Context: Client <=> Uvicorn/Gunicorn

In the first context, if you provide the SSL/TLS certificate directly to Uvicorn/Gunicorn, it will overwrite the scheme to https. Otherwise, it will be set to http.

That's what we can read in the source code (source):

class HttpToolsProtocol(asyncio.Protocol):
    ...

    # Protocol interface
    def connection_made(self, transport):
        self.connections.add(self)

        self.transport = transport
        self.flow = FlowControl(transport)
        self.server = get_local_addr(transport)
        self.client = get_remote_addr(transport)
        self.scheme = "https" if is_ssl(transport) else "http"

        if self.logger.level <= TRACE_LOG_LEVEL:
            prefix = "%s:%d - " % tuple(self.client) if self.client else ""
            self.logger.log(TRACE_LOG_LEVEL, "%sHTTP connection made", prefix)

    ...

The significant part is:

self.scheme = "https" if is_ssl(transport) else "http"

Where is_ssl is defined like this (source):

def is_ssl(transport: asyncio.Transport) -> bool:
    return bool(transport.get_extra_info("sslcontext"))

Here it's clear, if you provide a sslcontext (through the --ssl-keyfile and --ssl-certfile arguments), all your subsequential contexts will be prefixed with the https scheme. Otherwise, it will be the default http scheme.


2nd Context: Client <=> Proxy <=> Unicorn/Gunicorn

When running behind a proxy, your Uvicorn/Gunicorn instance is normally running in an HTTP context. Meaning that you don't give your SSL/TLS certificates explicitly.

This is where things get tricky.

Indeed, you can't set the SSL/TLS certificate explicitly but you still serve HTTPS requests through your proxy. On the other hand, url_for (and others) will generate URIs based on the current context - which is in this case the HTTP context.

But fortunately, there is a solution to that problem.

Uvicorn: The solution

When using uvicorn, there are 2 arguments that should be used if you know that you are behind a controlled proxy.

The 2 arguments are:

  • --proxy-headers: Enable/Disable X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Port to populate remote address info.

  • --forwarded-allow-ips TEXT: Comma separated list of IPs to trust with proxy headers. Defaults to the $FORWARDED_ALLOW_IPS environment variable if available, or 127.0.0.1.

Indeed, when using the --proxy-headers argument, uvicorn will follow the X-Forwarded-Proto header to set the scheme.

On the other hand, because security matters, uvicorn has a safety check that says something like:

If your IP is trusted, we will comply with the X-Forwarded-* headers.

The list of trusted IPs can be set through the --forwarded-allow-ips argument - or its environment variable counterpart.

As example, you can use --forwarded-allow-ips="10.0.3.2,10.1.3.2" to trust 10.0.3.2 and 10.1.3.2 OR use --forwarded-allow-ips="*" to trust any source (Not recommended).

That's what we can read in the source code (source):

class ProxyHeadersMiddleware:
    def __init__(
        self, app: ASGI3Application, trusted_hosts: Union[List[str], str] = "127.0.0.1"
    ) -> None:
        self.app = app
        if isinstance(trusted_hosts, str):
            self.trusted_hosts = {item.strip() for item in trusted_hosts.split(",")}
        else:
            self.trusted_hosts = set(trusted_hosts)
        self.always_trust = "*" in self.trusted_hosts

    async def __call__(
        self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
    ) -> None:
        if scope["type"] in ("http", "websocket"):
            scope = cast(Union[HTTPScope, WebSocketScope], scope)
            client_addr: Optional[Tuple[str, int]] = scope.get("client")
            client_host = client_addr[0] if client_addr else None

            if self.always_trust or client_host in self.trusted_hosts:
                headers = dict(scope["headers"])

                if b"x-forwarded-proto" in headers:
                    # Determine if the incoming request was http or https based on
                    # the X-Forwarded-Proto header.
                    x_forwarded_proto = headers[b"x-forwarded-proto"].decode("latin1")
                    scope["scheme"] = x_forwarded_proto.strip()  # type: ignore[index]

                if b"x-forwarded-for" in headers:
                    # Determine the client address from the last trusted IP in the
                    # X-Forwarded-For header. We've lost the connecting client's port
                    # information by now, so only include the host.
                    x_forwarded_for = headers[b"x-forwarded-for"].decode("latin1")
                    x_forwarded_for_hosts = [
                        item.strip() for item in x_forwarded_for.split(",")
                    ]
                    host = self.get_trusted_client_host(x_forwarded_for_hosts)
                    port = 0
                    scope["client"] = (host, port)  # type: ignore[index]

        return await self.app(scope, receive, send)

    ...

The significant parts are:

  1. Multiple comma-separated trusted hosts OR * to trust any source.
if isinstance(trusted_hosts, str):
    self.trusted_hosts = {item.strip() for item in trusted_hosts.split(",")}
else:
    self.trusted_hosts = set(trusted_hosts)
self.always_trust = "*" in self.trusted_hosts
  1. Proxy Host trusted? => Overwrite scheme.
if self.always_trust or client_host in self.trusted_hosts:
  1. Overwrite scheme based on the X-Forwarded-Proto header.
if b"x-forwarded-proto" in headers:
    # Determine if the incoming request was http or https based on
    # the X-Forwarded-Proto header.
    x_forwarded_proto = headers[b"x-forwarded-proto"].decode("latin1")
    scope["scheme"] = x_forwarded_proto.strip()  # type: ignore[index]

Here it's clear, if you inform uvicorn that it is behind a proxy, it will follow the protocol/scheme set behind the X-Forwarded-Proto header. Otherwise, it will be the default http scheme.

Sources:

Gunicorn: The solution

Gunicorn being a server on top of Uvicorn - in this example, you only have to provide the following arguments - or their environment variable/configuration counterpart-s.

  • --forwarded-allow-ips STRING | $FORWARDED_ALLOW_IPS | forwarded_allow_ips (same as in uvicorn)

Sources:

Proxy: The implementation

If you want to focus yourself only on the scheme, you should ensure that your proxy is sending the X-Forwarded-Proto: https header to your Uvicorn/Guvicorn application.

Related