Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HttpOnly cookie is not getting set on the handshake request in Jest #3812

Closed
marnixhoh opened this issue Feb 21, 2021 · 18 comments
Closed

HttpOnly cookie is not getting set on the handshake request in Jest #3812

marnixhoh opened this issue Feb 21, 2021 · 18 comments
Labels
enhancement New feature or request

Comments

@marnixhoh
Copy link

Describe the bug
I am using the React socket.io-client package to connect to a socket.io websocket. The authentication of the entire application is based on httpOnly cookies (i.e. these cookies can not be accessed through clientside Javascript).

When running the app in developement, everything works as expected: the socket client sets the httpOnly cookie on the handshake request and the server authenticates this.

But when running the Jest test suite, the httpOnly cookie no longer gets set on the handshake.

Note that when making http requests (using fetch) in Jest, the httpOnly cookie DOES get set as expected. So for whatever reason, the socket.io-client is not setting the httpOnly cookie on the handshake request in Jest...

Any help or suggestions would be greatly appreciated! Thank you

Socket.IO server version: ^3.1.0
Socket.IO client version: ^3.1.1

@marnixhoh marnixhoh added the bug Something isn't working label Feb 21, 2021
@darrachequesne
Copy link
Member

Hi! Could you please provide a reproducible test case? There's an example with jest here. Thanks!

@marnixhoh
Copy link
Author

Yes. I just created one. Note, that it is required to use create-react-app's (CRA) test configuration. Because of this, the server is run separately. To prevent CORS issues, the following npm command must be used to run the test file:

"test": "react-scripts test --testURL=http://localhost:3000/api --runInBand"

To run the test do the following:

  1. start the server using:
    node ./server.js
  2. start the test suite using:
    npm run test test.test.js

Note that the test file contains both a case illustrating how the httpOnly cookie IS set using fetch. And a test case showing that this is not the case with the socket.io handshake. As I said in my original question, this DOES work when running it outside of the jest environment.

Thank you so so so much for your help!!

If you have any questions, please ask and I'll get back to you asap. :)

server.test.js

const { createServer } = require("http")
const { Server } = require("socket.io")
const express = require('express')
const cookieParser = require('cookie-parser')

const cookieConfig = {
    httpOnly: true,
    maxAge: 9999999,
    path: '/',
    secure: false,
    signed: true,
    sameSite: 'Lax',
}

// create express app
const expressApp = express()
expressApp.use(cookieParser('my-secret'))

// routes
expressApp.post('/', (req, res) => {
    res.send({ token: req.signedCookies.Authorization })
})

expressApp.get('/cookie', (req, res) => {
    res.cookie('Authorization', 'my-token', cookieConfig)
    res.send()
})

// set up socket.io server
httpServer = createServer(expressApp)
io = new Server(httpServer)

// set up socket.io middleware
const wrap = middleware => (socket, next) => middleware(socket.request, {}, next)
io.use(wrap(cookieParser(process.env.COOKIE_SECRET)))

io.on('connection', (socket) => {
    socket.emit('token', { token: socket.request.signedCookies.Authorization })
})

const port = 3000
httpServer.listen(port, () => {
    console.log('Server is up!')
})

test.test.js

const Client = require("socket.io-client")

describe("my awesome project", () => {
    const address = 'http://localhost:3000'

    let clientSocket
    beforeAll(() => {
        clientSocket = new Client(address, { autoConnect: false })
    })

    afterAll(() => {
        clientSocket.close()
    })

    // this test passes:
    test('Normal fetch call should set cookie', async () => {
        // get httpOnly cookie
        await fetch(address + '/cookie', {
            method: 'GET', credentials: 'include'
        })

        // send httpOnly cookie to server and get it back
        const response = await fetch(address, {
            method: 'POST', credentials: 'include'
        })
        const text = await response.json()
        expect(text).toEqual({ token: 'my-token' })
    })

    // this test fails:
    test('Socket.io handshake should send httpOnly cookie to server', async () => {
        // get httpOnly cookie
        await fetch(address + '/cookie', {
            method: 'GET', credentials: 'include'
        })

        let payload
        await new Promise((resolve) => {
            clientSocket.connect()
            clientSocket.on('connect', () => {
                console.log('success')
            })

            clientSocket.on('token', (arg) => {
                payload = arg
                resolve()
            })
        })
        expect(payload).toEqual({ token: 'my-token' })
    })
})

@darrachequesne
Copy link
Member

I see you are using credentials: 'include' with fetch requests. I think you need to set withCredentials: true on the Socket.IO client too.

Documentation: withCredentials

@marnixhoh
Copy link
Author

@darrachequesne Thank you for suggesting that. Unfortunately, I have already tried that and it didn't make a difference...

@peey
Copy link

peey commented Apr 2, 2021

I believe this happens because we don't get access to a response object in the middleware adapter

const wrap = middleware => (socket, next) => middleware(socket.request, {}, next)

The withCredentials only configures CORS to allow cookies to be sent by the client to the server. If these cookies have already been set by some HTTP route then these cookies are sent.

However if the cookies haven't been set by an HTTP route, then socket.io is invoked without a cookie. The middleware does its job - it starts a new session and sets a cookie. But this is ignored by socket.io and the changes in response headers made by middleware are not propagated to response of the handshake request.

Currently, I see no way of customizing socket.io handshake behaviour (e.g. adding custom headers to be sent in the response), so I see this as a bug. (This used to be possible with some hacks in older versions of socket.io)

@marnixhoh
Copy link
Author

@peey thank you for your comment. Just to emphasize, the problem I described is specific to running tests in Jest. Everything works fine and as expected in development (and production)

@Jake-Prentice
Copy link

has this been sorted yet, I can't get mine to work :(

@marnixhoh
Copy link
Author

To my knowledge, it still hasn't... I guess most people don't actually test frontends with a real socket server (/API). However, I actually do prefer to write my client-side tests this way. But for all my socket related things, I had to mock those responses as a way to work around this issue - unfortunately...

@darrachequesne
Copy link
Member

OK, so I guess that's because the tests are run in a Node.js process, and not in a browser (e2e testing). In that case, I think you'll need to manually attach the cookie (since the browser doesn't).

It seems you can't retrieve the value of the "set-cookie" header in the fetch response, I was able to workaround it with:

Client:

test('Socket.io handshake should send httpOnly cookie to server', async () => {
    // get httpOnly cookie
    await fetch(address + '/cookie', {
        method: 'GET', credentials: 'include'
    })
    
+   const signedValue = 's:' + require('cookie-signature').sign('my-token', 'my-secret');
+   clientSocket.io.opts.extraHeaders = {
+     cookie: `Authorization=${signedValue}`
+   }
+
    let payload
    await new Promise((resolve) => {
        clientSocket.connect()
        clientSocket.on('connect', () => {
            console.log('success')
        })

        clientSocket.on('token', (arg) => {
            payload = arg
            resolve()
        })
    })
    expect(payload).toEqual({ token: 'my-token' })
})

@Venryx
Copy link

Venryx commented Jul 19, 2021

As an alternative to retrieving the http-only cookie into frontend code, you can use a "connection id" handshake to have the server "associate" an http request's user-data cookie with the websocket connection: vuejs/apollo#144 (comment)

Summary (see post above for more details): client http request with http-only cookie -> server, generates connection-id -> client receives connection-id and sends back to server, through websocket -> server associates the websocket connection with the user-data in the http-only cookie

@buzzy
Copy link

buzzy commented Sep 5, 2021

As an alternative to retrieving the http-only cookie into frontend code, you can use a "connection id" handshake to have the server "associate" an http request's user-data cookie with the websocket connection: vuejs/vue-apollo#144 (comment)

Summary (see post above for more details): client http request with http-only cookie -> server, generates connection-id -> client receives connection-id and sends back to server, through websocket -> server associates the websocket connection with the user-data in the http-only cookie

Doesn't that defeat the whole purpose of having the session cookie httponly in the first place? That solution just creates yet another "session hash" as connection-id and use that as a replacement to the session cookie. The whole idea with httponly is that Javascript does NOT have access to a session hash. If you are ok with that security issue, then why not just remove the httponly on your main session cookie instead?

@Venryx
Copy link

Venryx commented Sep 5, 2021

If you are ok with that security issue, then why not just remove the httponly on your main session cookie instead?

It depends on what you're storing in the http-only cookie.

In my case, the cookie is storing the JWT token for a third-party service. For this, the "connection-id" approach is safer than making the cookie accessible in Javascript, because:

  1. Stealing the connection-id will not allow the stealer to make arbitrary commands against the third-party backend. They are forced to operate through your app-server.
  2. It is more difficult to "utilize" the stolen connection-id: The server (in my case anyway) rejects >1 attempts to "apply" a given connection-id to a requester's websocket connection. This means that if the main user "applied" it first, no one else can use it. If the hacker "applied" it first (for example, if they intercept the message and don't pass it on to the user), then at least the user will get a warning about it, and it'll get logged on the app-server for review.
  3. If you discover that a hacker has stolen connection-ids, you can fix everything by just restarting the app-server (since the connection-ids were only valid when they were stored in-memory on the app-server's association mapping). Contrast this to the source JWTs, which are more work to invalidate + recreate.

That said, I agree it's still not as secure as using http-only cookies all the way; it is safer than exposing the source JWTs to client-side Javascript though.

@darrachequesne
Copy link
Member

This was added in the documentation here: https://socket.io/how-to/deal-with-cookies#nodejs-client-and-cookies

Please reopen if needed.

@darrachequesne darrachequesne added documentation Improvements or additions to documentation and removed bug Something isn't working labels Apr 5, 2022
@satejbidvai
Copy link

Describe the bug I am using the React socket.io-client package to connect to a socket.io websocket. The authentication of the entire application is based on httpOnly cookies (i.e. these cookies can not be accessed through clientside Javascript).

When running the app in developement, everything works as expected: the socket client sets the httpOnly cookie on the handshake request and the server authenticates this.

But when running the Jest test suite, the httpOnly cookie no longer gets set on the handshake.

Note that when making http requests (using fetch) in Jest, the httpOnly cookie DOES get set as expected. So for whatever reason, the socket.io-client is not setting the httpOnly cookie on the handshake request in Jest...

Any help or suggestions would be greatly appreciated! Thank you

Socket.IO server version: ^3.1.0 Socket.IO client version: ^3.1.1

Hey @marnixhoh, was working on a project and I am unable to pass my httpOnly cookies to my backend. I checked using DevTools and request headers does not contain any cookie. I have also set withCredentials: true.

I see you had worked on something similar that worked, it would be great if we can connect. I am stuck on this issue since many days.

You can check out my StackOverflow Ques for more reference.

@marnixhoh
Copy link
Author

@Electron-2002 What I ended up doing, was actually just mocking the server response for all socket requests... Since then I haven't looked at this issue. So I am afraid that I won't be able to help you here...

If you do happen to find a solution, then please let me know :)

Good luck!

@darrachequesne darrachequesne added enhancement New feature or request and removed documentation Improvements or additions to documentation labels Oct 14, 2022
@darrachequesne
Copy link
Member

I think we need to add a way to store cookies for the Node.js client. Let's do this 👍

@driedger
Copy link

driedger commented Jun 8, 2023

I think we need to add a way to store cookies for the Node.js client. Let's do this 👍

@darrachequesne Has there been any progress on storing the cookies for a node.js client?

I am having issues with a node.js socket.io-client connecting to a Traefik proxy using cookies for a sticky session. Connecting with a browser to the same entrypoint sets the cookie correctly, resulting in a polling connection successfully upgrading to a websocket. However, when the node.js client performs the same action with the same options (withCredentials: true), it fails to connect unless I limit the transports to websocket only.

darrachequesne added a commit to socketio/engine.io-client that referenced this issue Jun 13, 2023
When setting the `withCredentials` option to `true`, the Node.js client
will now include the cookies in the HTTP requests, making it easier to
use it with cookie-based sticky sessions.

Related: socketio/socket.io#3812
@darrachequesne
Copy link
Member

@driedger cookie management in Node.js was added in socketio/engine.io-client@5fc88a6, included in version 4.7.0:

import { io } from "socket.io-client";

const socket = io("https://example.com", {
  withCredentials: true
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

8 participants