Skip to content

Streamlit uploads fail through port proxy because XSRF cookie value is percent-encoded #7927

Description

@maginator

Is there an existing issue for this?

  • I have searched the existing issues

OS/Web Information

  • Web Browser: Google Chrome

  • Local OS: macOS

  • Remote OS: Linux container running on OpenShift

  • Remote Architecture: amd64 / x64

  • Working code-server --version:

1.107.1
8c077bf605a76ebb600d23b71e21c8756a2aee2a
x64

Failing code-server --version:

1.127.0
1e6ed874e3138141a5636f6e0dbe8570aa6cd001
x64

Also reproduced with code-server --version:

1.131.0
a3fc2899bd0fcd388253c0e79ce33b8acd48c688
x64

Streamlit version: 1.60.0
Python versions tested: 3.12.12 and 3.12.13

code-server is started with:

/usr/bin/code-server \
  --bind-addr 0.0.0.0:8888 \
  --auth none \
  --disable-telemetry \
  --disable-update-check \
  --disable-workspace-trust \
  --disable-getting-started-override \
  /home/jovyan

The application is exposed through the code-server port proxy.

The proxy URL has the following structure:

https:///notebook///proxy/{{port}}/

The corresponding environment variable is:

VSCODE_PROXY_URI=https:///notebook///proxy/{{port}}/

Steps to Reproduce

Steps to Reproduce

  1. Create the following minimal Streamlit application as app.py:

    import streamlit as st
    
    st.title("Upload Test")
    
    uploaded_file = st.file_uploader(
        "Select CSV or Excel file",
        type=["csv", "xlsx", "xls"],
    )
    
    if uploaded_file is not None:
        st.success("Upload successful")
        st.write("Filename:", uploaded_file.name)
        st.write("Content-Type:", uploaded_file.type)
        st.write("Size:", uploaded_file.size)
  2. Install Streamlit 1.60.0.

  3. Start the application on port 8501:

    streamlit run app.py \
      --server.address=0.0.0.0 \
      --server.port=8501 \
      --server.headless=true
  4. Open the application through the code-server port proxy: https://<host>/notebook/<namespace>/<instance>/proxy/8501/

  5. Select any CSV or Excel file in the Streamlit file uploader.

  6. Observe the failed PUT request to an endpoint similar to:

    /notebook/<namespace>/<instance>/proxy/8501/_stcore/upload_file/0

  7. As a control test, access the same Streamlit process directly through a Kubernetes port-forward:

    kubectl port-forward pod/<pod-name> 8501:8501
  8. Open the application directly: http://localhost:8501

  9. Repeat the upload.

The upload works when Streamlit is accessed directly and only fails when the request passes through the code-server port proxy.

Expected

The code-server port proxy should forward the _streamlit_xsrf cookie value unchanged.

The backend should receive matching values:

X-Xsrftoken: 2|token-part|signature|timestamp
Cookie: _streamlit_xsrf=2|token-part|signature|timestamp

The Streamlit file upload should complete successfully while XSRF protection remains enabled.

Actual

The upload request fails with:

HTTP 403 Forbidden
XSRF token missing or invalid

With the working code-server version, the backend receives matching values:

X-Xsrftoken:
2|20cfe2b7|98e85396a82b2161c85917b07a434073|1785392786

Cookie:
_streamlit_xsrf=2|20cfe2b7|98e85396a82b2161c85917b07a434073|1785392786

With the failing versions, the X-Xsrftoken header is forwarded unchanged:

X-Xsrftoken:
2|d8d4d4ff|b32ba6c11e6ea28d32288cbee992ec90|1785396144

However, the cookie value received by the backend is percent-encoded:

Cookie:
_streamlit_xsrf=2%7Cd8d4d4ff%7Cb32ba6c11e6ea28d32288cbee992ec90%7C1785396144

%7C is the percent-encoded representation of |.

Streamlit therefore receives two different strings:

Header:
2|d8d4d4ff|b32ba6c11e6ea28d32288cbee992ec90|1785396144
Cookie:
2%7Cd8d4d4ff%7Cb32ba6c11e6ea28d32288cbee992ec90%7C1785396144

Because the XSRF cookie and request header no longer match, Streamlit rejects the request.

The problem also reproduces with code-server 1.131.0.

Logs

Screenshot/Video

No response

Does this bug reproduce in native VS Code?

This cannot be tested in native VS Code

Does this bug reproduce in VS Code web?

I did not test VS Code web

Does this bug reproduce in GitHub Codespaces?

I did not test GitHub Codespaces

Are you accessing code-server over a secure context?

  • I am using a secure context.

Notes

The following observations helped isolate the problem:

  • The same Streamlit 1.60.0 application works through code-server 1.107.1.
  • The issue reproduces with code-server 1.127.0.
  • The issue still reproduces with code-server 1.131.0.
  • The application works when accessed directly through a Kubernetes port-forward.
  • The problem is independent of the uploaded file type.
  • The problem occurs before CSV or Excel processing begins.
  • Setting an explicit Streamlit server.cookieSecret did not resolve the issue.
  • Deleting browser cookies and testing in a private browser session did not resolve the issue.
  • The relevant request headers such as Host, Origin, Referer, X-Forwarded-Host, and X-Forwarded-Proto were otherwise equivalent between working and failing environments.
  • No X-Forwarded-Prefix header was present in either environment.
  • A separate deployment of the same minimal Streamlit application behind an Istio VirtualService with a URI rewrite successfully uploads files.
  • This indicates that the issue is specific to the code-server port-proxy path rather than Streamlit or Istio in general.

Disabling Streamlit XSRF protection makes the upload work:

streamlit run app.py --server.enableXsrfProtection=false

This is only a workaround and is not suitable as a permanent solution because it disables a security mechanism.

Could you confirm whether percent-encoding cookie values in the port proxy is intentional?

The following transformation appears to occur:

| -> %7C

Applications such as Streamlit compare the cookie value directly with a corresponding request header, so modifying the cookie value causes the XSRF validation to fail.


Optional: Header echo server used for verification

The following HTTP echo server was used instead of Streamlit to inspect the request received after passing through the code-server proxy:

from http.server import BaseHTTPRequestHandler, HTTPServer


class Handler(BaseHTTPRequestHandler):
    def log_request_details(self) -> None:
        print("\n" + "=" * 80)
        print(f"{self.command} {self.path}")
        print("=" * 80)

        for name, value in self.headers.items():
            if name.lower() == "cookie":
                xsrf_cookie = next(
                    (
                        part.strip()
                        for part in value.split(";")
                        if part.strip().startswith("_streamlit_xsrf=")
                    ),
                    "<no _streamlit_xsrf cookie>",
                )
                value = xsrf_cookie

            print(f"{name}: {value}")

    def do_GET(self) -> None:
        self.log_request_details()

        body = b"""<!doctype html>
<html>
<head>
    <title>Proxy Header Test</title>
</head>
<body>
    <h1>Proxy Header Test</h1>
</body>
</html>
"""

        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_PUT(self) -> None:
        self.log_request_details()

        content_length = int(self.headers.get("Content-Length", "0"))
        if content_length:
            self.rfile.read(content_length)

        self.send_response(204)
        self.end_headers()


HTTPServer(("0.0.0.0", 8501), Handler).serve_forever()

After opening the echo server through the port proxy, the following request was sent from the browser console:

const cookieValue = document.cookie
  .split("; ")
  .find(cookie => cookie.startsWith("_streamlit_xsrf="))
  ?.split("=")
  .slice(1)
  .join("=");

fetch(
  window.location.pathname.replace(/\/test$/, "/header-test"),
  {
    method: "PUT",
    headers: {
      "Content-Type": "text/plain",
      "X-Xsrftoken": decodeURIComponent(cookieValue)
    },
    body: "upload-test"
  }
);

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriageThis issue needs to be triaged by a maintainer

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions