Streamlit uploads fail through port proxy because XSRF cookie value is percent-encoded · Issue #7927 · coder/code-server · GitHub
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"
  }
);

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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