Skip to content

WebSocket JWT authentication silently fails due to unstrimmed comma-separated subprotocol values #614

Description

@omaiko-intellecteu

We ran into this while paging through /v2/state/active-contracts over WebSocket, as recommended in the docs (https://docs.canton.network/global-synchronizer/reference/api-configuration#websockets) for result sets too large for the non-streaming endpoint.

What happens

Connecting a WebSocket client to /v2/state/active-contracts with subprotocols daml.ws.auth and jwt.token.<TOKEN> (the documented auth mechanism for this endpoint), e.g.:

const ws = new WebSocket(
  `wss://<host>/v2/state/active-contracts`,
  ['daml.ws.auth', `jwt.token.${token}`]
);

or via wscat:

wscat -c "wss://<host>/v2/state/active-contracts" -s "daml.ws.auth" -s "jwt.token.<TOKEN>"

The handshake succeeds fine (HTTP/1.1 101 Switching Protocols, Sec-WebSocket-Protocol: daml.ws.auth correctly echoed back). But as soon as we send the request body, the first response frame is:

{"code":"NA","cause":"A security-sensitive error has been received","correlationId":"...","traceId":"...","context":{},"resources":[],"errorCategory":-1,"grpcCodeValue":16,"retryInfo":null,"definiteAnswer":null}

grpcCodeValue: 16 is UNAUTHENTICATED — even though the exact same JWT works fine via the standard Authorization: Bearer header on non-WS endpoints.

Why

The JWT extraction for WebSocket connections lives in Endpoints.scala (community/ledger/ledger-json-api/.../http/json/v2/Endpoints.scala):

auth
  .apiKey(header[Option[String]]("Sec-WebSocket-Protocol"))
  .map { bearer =>
    val tokenPrefix = "jwt.token." // TODO (i21030) test this
    bearer
      .map(_.split(",").toSeq)
      .getOrElse(Seq.empty)
      .filter(_.startsWith(tokenPrefix))
      .map(_.substring(tokenPrefix.length))
      .headOption
      .map(Jwt.apply)
  }(_.map(_.token))

It splits Sec-WebSocket-Protocol on , but never trims the resulting strings. RFC 6455 clients (browsers, ws, wscat) send this header as a comma-space separated list, so "daml.ws.auth, jwt.token.<TOKEN>".split(",") gives ["daml.ws.auth", " jwt.token.<TOKEN>"] — note the leading space on the second element. .startsWith("jwt.token.") then fails, the filter drops it, and .headOption returns None. The JWT is silently dropped, the request goes out unauthenticated, and the ledger API rejects it.

The pre-existing // TODO (i21030) test this comment suggests this path never actually had a passing end-to-end test with a real client, which is presumably how this slipped through.

Fix

Opened #613 with the fix (trims each element after splitting) and regression tests.

Originally raised against canton-network/splice#6776

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions