How to make _xsrf cookie httponly false

Hello,

I need to make httponly flag for _xsrf false because some extensions like nbgrader need to work with it in the javascript (i think) it causes some issues in nbgrader extension.

I deployed Z2JH and set in the config:

hub:
    extraConfig:
      xsrfCookie: |
        import json
        cookie_options = {"httponly": False}
        c.Spawner.environment.update(
            {"JUPYTERHUB_COOKIE_OPTIONS": "{%s}" % json.dumps(cookie_options)}
        )
        c.JupyterHub.tornado_settings["cookie_options"] = cookie_options

and here is my ingress.annotations

nginx.ingress.kubernetes.io/configuration-snippet: |
        proxy_cookie_flags ~ Secure;

where I specify only secure flag

when I check jupyterhub config using

kubectl exec -it hub-684bd585b4-gzhql -- jupyterhub -f /usr/local/etc/jupyterhub/jupyterhub_config.py --show-config

I see these lines

JupyterHub
     .tornado_settings = {'cookie_options': {'httponly': False}, 'slow_spawn_timeout': 0}

which seems that the changes are reflected in the configuration.

However, _xsrf still has both secure and httponly flags in the browser.

Do you know how to make _xsrf cookie set to false?

best

Which version of JupyterHub are you using? In our deployment, they both are set to false for _xsrf cookies and we are not using any special cookie options in our deployment. We are on v5.2.1

I’m using jupyterhub 4.0.2

kubectl exec  hub-84c9d86b8d-d8w9n -- jupyterhub --version
4.0.2

Hmm, with a quick test using JupyterHub 4.0.2 locally, I see both are set to false as well. In your screenshot, I see that you are looking at the _xsrf token on a service (as path is /service..). Are you sure that it is not your service handler that is managing the cookie flags?

1 Like

Thank you for the response. I am using nbgrader as service. How can i test that my service is setting httponly to true?
How about my nginx ingress controller?

Ahh, I missed your nginx ingress. Probably, the configuration is not being applied on the ingress controller. Can you check your final nginx.conf in the pod? Something like kubectl exec <pod> -n <ns> -- cat /etc/nginx/nginx.conf should give you the current config.

1 Like

Thanks. Here is nginx config from the nginx controller pod.
kubectl exec ingress-nginx-controller-7f5bfdb96d-4wf67 -n ingress-nginx -- cat /etc/nginx/nginx.conf

I added the secure flag to cookies but not httponly! but the xsrf cookie is httponly in the browser.

I think this part is related to the jupyterhub


        ## start server jupyterhub<redacted>
        server {
                server_name jupyterhub<redacted> ;

                listen 80  ;
                listen [::]:80  ;
                listen 443  ssl http2 ;
                listen [::]:443  ssl http2 ;

                set $proxy_upstream_name "-";

                ssl_certificate_by_lua_block {
                        certificate.call()
                }

                location / {

                        set $namespace      "default";
                        set $ingress_name   "jupyterhub";
                        set $service_name   "proxy-public";
                        set $service_port   "http";
                        set $location_path  "/";
                        set $global_rate_limit_exceeding n;

                        rewrite_by_lua_block {
                                lua_ingress.rewrite({
                                        force_ssl_redirect = true,
                                        ssl_redirect = true,
                                        force_no_ssl_redirect = false,
                                        preserve_trailing_slash = false,
                                        use_port_in_redirects = false,
                                        global_throttle = { namespace = "", limit = 0, window_size = 0, key = { }, ignored_cidrs = { } },
                                })
                                balancer.rewrite()
                                plugins.run()
                        }

                        # be careful with `access_by_lua_block` and `satisfy any` directives as satisfy any
                        # will always succeed when there's `access_by_lua_block` that does not have any lua code doing `ngx.exit(ngx.DECLINED)`
                        # other authentication method such as basic auth or external auth useless - all requests will be allowed.
                        #access_by_lua_block {
                        #}

                        header_filter_by_lua_block {
                                lua_ingress.header()
                                plugins.run()
                        }

                        body_filter_by_lua_block {
                                plugins.run()
                        }

                        log_by_lua_block {
                                balancer.log()

                                monitor.call()

                                plugins.run()
                        }

                        port_in_redirect off;

                        set $balancer_ewma_score -1;
                        set $proxy_upstream_name "default-proxy-public-http";
                        set $proxy_host          $proxy_upstream_name;
                        set $pass_access_scheme  $scheme;

                        set $pass_server_port    $server_port;

                        set $best_http_host      $http_host;
                        set $pass_port           $pass_server_port;

                        set $proxy_alternative_upstream_name "";

                        client_max_body_size                    1m;

                        proxy_set_header Host                   $best_http_host;

                        # Pass the extracted client certificate to the backend

                        # Allow websocket connections
                        proxy_set_header                        Upgrade           $http_upgrade;

                        proxy_set_header                        Connection        $connection_upgrade;

                        proxy_set_header X-Request-ID           $req_id;
                        proxy_set_header X-Real-IP              $remote_addr;

                        proxy_set_header X-Forwarded-For        $remote_addr;

                        proxy_set_header X-Forwarded-Host       $best_http_host;
                        proxy_set_header X-Forwarded-Port       $pass_port;
                        proxy_set_header X-Forwarded-Proto      $pass_access_scheme;
                        proxy_set_header X-Forwarded-Scheme     $pass_access_scheme;

                        proxy_set_header X-Scheme               $pass_access_scheme;

                        # Pass the original X-Forwarded-For
                        proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;

                        # mitigate HTTPoxy Vulnerability
                        # https://www.nginx.com/blog/mitigating-the-httpoxy-vulnerability-with-nginx/
                        proxy_set_header Proxy                  "";

                        # Custom headers to proxied server

                        proxy_connect_timeout                   5s;
                        proxy_send_timeout                      60s;
                        proxy_read_timeout                      60s;

                        proxy_buffering                         off;
                        proxy_buffer_size                       4k;
                        proxy_buffers                           4 4k;

                        proxy_max_temp_file_size                1024m;

                        proxy_request_buffering                 on;
                        proxy_http_version                      1.1;

                        proxy_cookie_domain                     off;
                        proxy_cookie_path                       off;

                        # In case of errors try the next upstream server before returning an error
                        proxy_next_upstream                     error timeout;
                        proxy_next_upstream_timeout             0;
                        proxy_next_upstream_tries               3;

                        proxy_cookie_flags ~ Secure;
                        # proxy_cookie_flags ~ Secure HttpOnly;
                        # proxy_cookie_flags ~ Secure HttpOnly SameSite=Strict;
                        more_set_headers "x-jupyterhub-version: X";
                        # add_header Strict-Transport-Security "max-age=31557600; includeSubDomains; preload" always;
                        add_header X-Content-Type-Options nosniff always;
                        add_header X-Frame-Options "SAMEORIGIN" always;
                        add_header X-XSS-Protection "1; mode=block" always;
                        add_header X-Robots-Tag "noindex, nofollow" always;
                        add_header X-Download-Options noopen always;
                        add_header X-Permitted-Cross-Domain-Policies none always;
                        add_header Referrer-Policy same-origin always;
                        add_header Permissions-Policy "microphone=(), geolocation=(), camera=()" always;

                        proxy_pass http://upstream_balancer;

                        proxy_redirect                          off;

                }

        }
        ## end server jupyterhub<redacted>

Ok, in that case my guess is that it is nbgrader who is setting that cookie flag. I dont have any experience with nbgrader but I assume you are running it as JupyterHub service. So, I would suggest you to set the environment variable JUPYTERHUB_COOKIE_OPTIONS in the service definition of nbgrader. Using example provided in nbgrader docs it would look as :slight_smile:

import json

cookie_options = {"httponly": False}

c.JupyterHub.services = [
    {
        'name': 'course101',
        'url': 'http://127.0.0.1:9999',
        'command': [
            'jupyterhub-singleuser',
            '--group=formgrade-course101',
            '--debug',
        ],
        'environment': {
            'JUPYTERHUB_COOKIE_OPTIONS': json.dumps(cookie_options),
        },
        'user': 'grader-course101',
        'cwd': '/home/grader-course101'
    }
]

You can try this and see if it helps!

1 Like

Thanks. How can make sure that the nbgrader is setting the httponly on xsrf?
I’m asking this because when I do a get request to /hub/home (which is probably unrelated to service), I get httponly xsrf cookie in the response:

curl.exe -v -b 'jupyterhub-hub-login="2|1:0|10:1746519801|20:jupyterhub-hub-login|44:N2VjNzM1NmRiMjI0NDAwYWIxOTMzZjkyNzNjMThlY2M=|c66791d55ab59d5c1b9ab1421e060276275bb139cbdb7b1ae8aa595d7...."' https://my-domain/hub/home

result:

set-cookie: _xsrf=2|dd866c52|3e36c93af4246f85d9de35196ec30814|1746606...; expires=Fri, 06 Jun 2025 08:35:05 GMT; Path=/hub/; Secure; HttpOnly

do you know what’s causing this?

I tried this, but it didn’t work.

Well, I am looking at the source code. For example, for 4.0.2, here is the place Hub sets the _xsrf cookie options and apart from path, it does not set anything else. Eventually, downstream it is tornado that sets the _xsrf cookie with the options passed by JupyterHub. So, I am not sure who else might be fiddling with cookie options if not ingress controller!!

I agree. it’s very strange. It was working just fine but then it stopped working (i didn’t change any script). could an update or change in the server have caused that?
Here is the complete ingress part:

ingress:   
    annotations:
      nginx.ingress.kubernetes.io/proxy-hide-headers: "X-Powered-By,Server"
      nginx.ingress.kubernetes.io/ssl-redirect: "true"
      nginx.ingress.kubernetes.io/force-ssl-redirect: "true"SameSite=strict"
      nginx.ingress.kubernetes.io/configuration-snippet: |
        proxy_cookie_flags ~ Secure;
        more_set_headers "x-jupyterhub-version: X";
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-XSS-Protection "1; mode=block" always;
        add_header X-Robots-Tag "noindex, nofollow" always;
        add_header X-Download-Options noopen always;
        add_header X-Permitted-Cross-Domain-Policies none always;
        add_header Referrer-Policy same-origin always;
        add_header Permissions-Policy "microphone=(), geolocation=(), camera=()" always;

    enabled: true
    ingressClassName: nginx
    hosts:
      - my-domain
    tls:
      - hosts:
        - my-domain
        secretName: my-tls-secret

is there any way to debug that? for example send a request directly to jupyterhub without involving ingress and checking the cookies?

You mentioned that all the cookies of JupyterHub (_xsrf, hub-login, single-server, oauth,…) have httponly set, right?!

for example send a request directly to jupyterhub without involving ingress and checking the cookies?

Depends on the network policy of the JupyterHub deployment. If you can access JupyterHub service via CluserIP, yes, that would be a good test by port forwarding the cluster IP of JupyterHub service and see how cookies are set.

1 Like

I did this test:

$kubectl get ingress
NAME         CLASS   HOSTS                ADDRESS       PORTS     AGE
jupyterhub   nginx   my-domain   ip_1   80, 443   460d

$curl -c cookies.txt -v -L https://my-domain/hub/login
$cat cookies.txt
#HttpOnly_my-domain    FALSE   /hub/   TRUE    0       _xsrf   2|9387343a|0c46eae81a33c063f8dc288fca446142|1746622...
$ kubectl get service
NAME           TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
kubernetes     ClusterIP   some_ip      <none>        443/TCP    462d
course-svc     ClusterIP   None           <none>        <none>     460d
proxy-api      ClusterIP   some_ip    <none>        8001/TCP   460d
hub            ClusterIP   some_ip    <none>        8081/TCP   460d
proxy-public   ClusterIP   ip_2   <none>        80/TCP     460d

$ curl -c cookies.txt -v -L  ip_2:80/hub/login
ip_2    FALSE   /hub/   FALSE   0       _xsrf   2|25be6881|a15a3026ba5dafbb631cadebed8299f0|174662...

does it confirm that nginx is adding httponly? but how to stop it from adding that?

Well, that seems like it is indeed nginx that is adding the cookie flag. Very strange!!

Maybe you can try removing proxy_cookie_flags ~ Secure; from the configuration snippet altogether to see that makes any difference.

1 Like

i did it already but nothing changed. It seems that regardless of the changes I make in the ingress.annotations the cookies always have secure and httponly flags.
For example, I made changes like proxy_cookie_flags _xsrf "", proxy_cookie_flags ~ Secure SameSite=Strict in the ingress.annotations for testing but cookies don’t change (see screenshot please).

Sorry, I am out of ideas. Maybe try bumping (or downgrading) the version of nginx ingress controller?!

It was my guess that httponly flag has caused this. But now I doubt.
I get this error in browser console

jquery.min.js:2 
 POST https://my-domain/servicesmy-course/formgrader/api/submission/assignment/user/autograde 403 (Forbidden)

the request header infact contains the xsrf cookie

cookie
service-my-course=2|1:0|10:1746631546|18:service-my-course|40:YnFCOWRmWGt4M25VU1kzWlN6....hPZDFBTm1J|f539275c37d24b224f1....28f953c570219df1a53601b1b175aaf458c8b254a0f; _xsrf=2|797ac438|4c9a1c409284c231f844feedbbafa38d|1746631...; jupyterhub-session-id=445dd87b36894b029e60175bd04...

but other headers like csrf_token is missing:

sec-fetch-dest
empty
sec-fetch-mode
cors
sec-fetch-site
same-origin
user-agent
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36
x-csrftoken
undefined

can this be causing the issue?

In the source code

autograde: function () {
        this.clear();
        this.$student_name.text("Please wait...");
        var student = this.model.get("student");
        var assignment = this.model.get("name");
        console.log(base_url + "/formgrader/api/submission/" + assignment + "/" + student + "/autograde");
        $.post(base_url + "/formgrader/api/submission/" + assignment + "/" + student + "/autograde")
           .done(_.bind(this.autograde_success, this))
            .fail(_.bind(this.autograde_failure, this));
    },

I added csrftoken manually:

var csrfToken = '2|797ac438|4c9a1c409284c231f844feedbbafa38d|1746...';  
        $.post({
            url: base_url + "/formgrader/api/submission/" + assignment + "/" + student + "/autograde",
            headers: {
                'x-csrftoken': csrfToken  // Add the CSRF token to the request header
            }
        })
        .done(_.bind(this.autograde_success, this))  // Success callback
        .fail(_.bind(this.autograde_failure, this)); // Failure callback

and it worked!

but why is csrf token missing in the first place?