From c6cf2762b3186aecb91cf5dff6d4a7d8ab59f5b2 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Pinho Date: Fri, 18 Jul 2025 14:26:43 +1200 Subject: [PATCH 1/2] fix: HS256 JWK signature verification --- .../channels/auth/jwt_verification.ex | 12 ++- mix.exs | 2 +- .../channels/auth/jwt_verification_test.exs | 86 ++++++++++++++++++- 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/lib/realtime_web/channels/auth/jwt_verification.ex b/lib/realtime_web/channels/auth/jwt_verification.ex index b61e78d57..66084fe65 100644 --- a/lib/realtime_web/channels/auth/jwt_verification.ex +++ b/lib/realtime_web/channels/auth/jwt_verification.ex @@ -107,13 +107,17 @@ defmodule RealtimeWeb.JwtVerification do "keys" => keys }) when is_binary(kid) and alg in @hs_algorithms do - jwk = Enum.find(keys, fn jwk -> jwk["kty"] == "oct" and jwk["kid"] == kid end) + jwk = Enum.find(keys, fn jwk -> jwk["kty"] == "oct" and jwk["kid"] == kid and is_binary(jwk["k"]) end) - case jwk do + if jwk do + case Base.url_decode64(jwk["k"], padding: false) do + {:ok, secret} -> {:ok, Joken.Signer.create(alg, secret)} + _ -> {:error, :error_generating_signer} + end + else # If there's no JWK, and HS* is being used, instead of erroring, try # the jwt_secret instead. - nil -> {:ok, Joken.Signer.create(alg, jwt_secret)} - _ -> {:ok, Joken.Signer.create(alg, jwk)} + {:ok, Joken.Signer.create(alg, jwt_secret)} end end diff --git a/mix.exs b/mix.exs index 24284f858..8c98b3702 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.40.11", + version: "2.40.12", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime_web/channels/auth/jwt_verification_test.exs b/test/realtime_web/channels/auth/jwt_verification_test.exs index 8307a157c..6b8c59ed1 100644 --- a/test/realtime_web/channels/auth/jwt_verification_test.exs +++ b/test/realtime_web/channels/auth/jwt_verification_test.exs @@ -11,12 +11,12 @@ defmodule RealtimeWeb.JwtVerificationTest do setup_all do Application.put_env(:realtime, :jwt_secret, @jwt_secret) Application.put_env(:realtime, :jwt_claim_validators, %{}) - on_exit(fn -> Application.put_env(:realtime, :jwt_claim_validators, %{}) end) :ok end setup do start_supervised(Mock) + on_exit(fn -> Application.put_env(:realtime, :jwt_claim_validators, %{}) end) :ok end @@ -218,7 +218,26 @@ defmodule RealtimeWeb.JwtVerificationTest do "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0xIn0.eyJpYXQiOjE3MTIwNDc1NjUsInJvbGUiOiJhdXRoZW50aWNhdGVkIiwic3ViIjoidXNlci1pZCIsImV4cCI6MTcxMjA1MTE2NX0.zUeoZrWK1efAc4q9y978_9qkhdXktdjf5H8O9Rw0SHcPaXW8OBcuNR2huRrgORvqFx6_sHn6nCJaWkZGzO-f8wskMD7Z4INq2JUypr6nASie3Qu2lLyeY3WTInaXNAKH-oqlfTLRskbz8zkIxOj2bBJiN9ceQLkJU-c92ndiuiG5D1jyQrGsvRdFem_cemp0yOoEaC0XWdjeV6C_UD-34GIyv3o8H4HZg1GcCiyNnAfDmLAcTOQPmqkwsRDQb-pm5O3HwpQt9WHOB6i1vzf-nmIGyCRA7STPdALK16-aiAyT4SJRxM5WN3iK8yitH7g4JETb9WocBbwIM_zfNnUI5w" # Check that the signature is valid even though time may be off. - assert {:error, :signature_error} != JwtVerification.verify(token, @jwt_secret, jwks) + assert JwtVerification.verify(token, @jwt_secret, jwks) != {:error, :signature_error} + end + + test "using RS256 JWK but wrong signature" do + jwks = %{ + "keys" => [ + %{ + "kty" => "RSA", + "n" => + "6r1mKwCalvJ0NyThyQkBr5huFILwwhXcxtsdlw-WybNz4avzODQwLFkA-b2fnnfdFgualV2NdcvoJSo1bzVGCWWqwWKWdTQKFjtcjAIC4FnhOv5ynNF9Ub-11ORDd1aiq_4XKNA4GaS1HqBekVDAAvJYy99Jz0CkLx4NU_VrS0U9sOQzUAhy2MwZCx2kZ3SWKEMjjEIkbvIb22IdRTyuFsAndKGpyzhw-MalnU5P2hOig-QApNBc0WJtTHTAa4PLQ6v_5jNc5PzCwP8jGK9SlrSF-GOnx9BVBX9t-AIDp-BviKbtY7y-pku6-f7HSiS2T3iAJkHXPm9E_NwwhWzMJQ", + "e" => "AQAB", + "kid" => "key-id-1" + } + ] + } + + token = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0xIn0.eyJpYXQiOjE3MTIwNDc1NjUsInJvbGUiOiJhdXRoZW50aWNhdGVkIiwic3ViIjoidXNlci1pZCIsImV4cCI6MTcxMjA1MTE2NX0.zUeoZrWK1efAc4q9y978_9qkhdXktdjf5H8O9Rw0SHcPaXW8OBcuNR2huRrgORvqFx6_sHn6nCJaWkZGzO-f8wskMD7Z4INq2JUypr6nASie3Qu2lLyeY3WTInaXNAKH-oqlfTLRskbz8zkIxOj2bBJiN9ceQLkJU-c92ndiuiG5D1jyQrGsvRdFem_cemp0yOoEaC0XWdjeV6C_UD-34GIyv3o8H4HZg1GcCiyNnAfDmLAcTOQPmqkwsRDQb-pm5O3HwpQt9WHOB6i1vzf-nmIGyCRA7STPdALK16-aiAyT4SJRxM5WN3iK8yitH7g4JETb9WocBbwIM_zfnnUI5w" + + assert JwtVerification.verify(token, @jwt_secret, jwks) == {:error, :signature_error} end test "using ES256 JWK" do @@ -239,7 +258,68 @@ defmodule RealtimeWeb.JwtVerificationTest do "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImtleS1pZC0xIn0.eyJpYXQiOjE3MTIwNDk2NTcsInJvbGUiOiJhdXRoZW50aWNhdGVkIiwic3ViIjoidXNlci1pZCIsImV4cCI6MTcxMjA1MzI1N30.IIQBuEiSnZacGMqiqsrLAeRGOjIaB4F3x1gnLN5zvhFryJ-6tdgu96lFv5HUF13IL2UfHWad0OuvoDt4DEHRxw" # Check that the signature is valid even though time may be off. - assert {:error, :signature_error} != JwtVerification.verify(token, @jwt_secret, jwks) + assert JwtVerification.verify(token, @jwt_secret, jwks) != {:error, :signature_error} + end + + test "using ES256 JWK with wrong signature" do + jwks = %{ + "keys" => [ + %{ + "kty" => "EC", + "x" => "iX_niXPSL2nW-9IyCELzyceAtuE3B98pWML5tQGACD4", + "y" => "kT02DoLhXx6gtpkbrN8XwQ2wtzE6cDBaqlWgVXIeqV0", + "crv" => "P-256", + "d" => "FBVYnsYA2C3FTggEwV8kCRMo4FLl220_cWY2RdXyb_8", + "kid" => "key-id-1" + } + ] + } + + token = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImtleS1pZC0xIn0.eyJpYXQiOjE3MTIwNDk2NTcsInJvbGUiOiJhdXRoZW50aWNhdGVkIiwic3ViIjoidXNlci1pZCIsImV4cCI6MTcxMjA1MzI1N30.IIQBuEiSnZacGMqiqsrLAeRGOjIaB4F3x1gnLN5zvhFryJ-6tdgu96lFv5HUF13IL2UfHWad0OuvoDt4DEHrxw" + + assert JwtVerification.verify(token, @jwt_secret, jwks) == {:error, :signature_error} + end + + test "using HS256 JWK" do + jwks = %{ + "keys" => [ + %{ + "alg" => "HS256", + "k" => + "WWpiUEVXK2I4dVM1djkzMS9TWTNmb2RtcUtiZVh3NnBHS0JaS1JDMGpaODdhVHpaZ3N0Ly9yMG0wU1M4Z1U4OFE0aGdwclBMMzVRRU5ya253TWxhUlE9PQ", + "key_ops" => ["verify"], + "kid" => "4FcGwlBxkBV1bSZw", + "kty" => "oct" + } + ] + } + + token = + "eyJhbGciOiJIUzI1NiIsImtpZCI6IjRGY0d3bEJ4a0JWMWJTWnciLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2hqbmRnYWdpZGlwY3RxdXFxeXloLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJmZjA0NjVlMy1lZjk3LTRkYjItOWE1Zi0zZDI4Y2YxODE0MmYiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzUyODA4NjE4LCJpYXQiOjE3NTI4MDUwMTgsImVtYWlsIjoiY2hhdEBlZHVhcmRvLmd1cmdlbC5tZSIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnsiZW1haWwiOiJjaGF0QGVkdWFyZG8uZ3VyZ2VsLm1lIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInBob25lX3ZlcmlmaWVkIjpmYWxzZSwic3ViIjoiZmYwNDY1ZTMtZWY5Ny00ZGIyLTlhNWYtM2QyOGNmMTgxNDJmIn0sInJvbGUiOiJhdXRoZW50aWNhdGVkIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoicGFzc3dvcmQiLCJ0aW1lc3RhbXAiOjE3NTI4MDUwMTh9XSwic2Vzc2lvbl9pZCI6IjA2MDJkYWM0LWMwMjctNGIwNi1hZDM5LTMzN2ViMTZlODdlNSIsImlzX2Fub255bW91cyI6ZmFsc2V9.SnGzRjLfHPtT64kXYEQVBLKizCl76LqEPILyAPxoDwk" + + # Check that the signature is valid even though time may be off. + assert JwtVerification.verify(token, @jwt_secret, jwks) != {:error, :signature_error} + end + + test "using HS256 JWK with wrong signature" do + jwks = %{ + "keys" => [ + %{ + "alg" => "HS256", + "k" => + "WWpiUEVXK2I4dVM1djkzMS9TWTNmb2RtcUtiZVh3NnBHS0JaS1JDMGpaODdhVHpaZ3N0Ly9yMG0wU1M4Z1U4OFE0aGdwclBMMzVRRU5ya253TWxhUlE9PQ", + "key_ops" => ["verify"], + "kid" => "4FcGwlBxkBV1bSZw", + "kty" => "oct" + } + ] + } + + token = + "eyJhbGciOiJIUzI1NiIsImtpZCI6IjRGY0d3bEJ4a0JWMWJTWnciLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2hqbmRnYWdpZGlwY3RxdXFxeXloLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiJmZjA0NjVlMy1lZjk3LTRkYjItOWE1Zi0zZDI4Y2YxODE0MmYiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzUyODA4NjE4LCJpYXQiOjE3NTI4MDUwMTgsImVtYWlsIjoiY2hhdEBlZHVhcmRvLmd1cmdlbC5tZSIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnsiZW1haWwiOiJjaGF0QGVkdWFyZG8uZ3VyZ2VsLm1lIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInBob25lX3ZlcmlmaWVkIjpmYWxzZSwic3ViIjoiZmYwNDY1ZTMtZWY5Ny00ZGIyLTlhNWYtM2QyOGNmMTgxNDJmIn0sInJvbGUiOiJhdXRoZW50aWNhdGVkIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoicGFzc3dvcmQiLCJ0aW1lc3RhbXAiOjE3NTI4MDUwMTh9XSwic2Vzc2lvbl9pZCI6IjA2MDJkYWM0LWMwMjctNGIwNi1hZDM5LTMzN2ViMTZlODdlNSIsImlzX2Fub255bW91cyI6ZmFsc2V9.SnGzRjLfHPtT64kXYEQVBLKizCl76LqEPILyApxoDwk" + + assert JwtVerification.verify(token, @jwt_secret, jwks) == {:error, :signature_error} end test "returns error when no matching JWK is found for RSA algorithm" do From 5578f00456b5133940a7c028c9a89fb4abf4e6f5 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Pinho Date: Fri, 18 Jul 2025 18:43:25 +1200 Subject: [PATCH 2/2] chore: add manual_prod_build workflow --- .github/workflows/manual_prod_build.yml | 129 ++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/workflows/manual_prod_build.yml diff --git a/.github/workflows/manual_prod_build.yml b/.github/workflows/manual_prod_build.yml new file mode 100644 index 000000000..f5014dd24 --- /dev/null +++ b/.github/workflows/manual_prod_build.yml @@ -0,0 +1,129 @@ +name: Manual Build Production +on: + workflow_dispatch: + inputs: + branch: + description: "Branch to run the workflow" + required: true + docker_tag: + description: "Tag to be used by the docker image on push" + required: true +jobs: + docker_x86_release: + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + arch: amd64 + outputs: + image_digest: ${{ steps.build.outputs.digest }} + steps: + - id: meta + uses: docker/metadata-action@v4 + with: + images: | + supabase/realtime + tags: | + type=raw,value=v${{ github.event.inputs.docker_tag }}_${{ env.arch }} + + - uses: docker/setup-buildx-action@v2 + + - uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - id: build + uses: docker/build-push-action@v3 + with: + push: true + tags: ${{ steps.meta.outputs.tags }} + platforms: linux/${{ env.arch }} + cache-from: type=gha + cache-to: type=gha,mode=max + + docker_arm_release: + runs-on: arm-runner + timeout-minutes: 120 + env: + arch: arm64 + outputs: + image_digest: ${{ steps.build.outputs.digest }} + steps: + - uses: actions/checkout@v3 + + - id: meta + uses: docker/metadata-action@v4 + with: + images: | + supabase/realtime + tags: | + type=raw,value=v${{ github.event.inputs.docker_tag }}_${{ env.arch }} + + - uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - uses: docker/setup-buildx-action@v2 + with: + driver: docker + driver-opts: | + image=moby/buildkit:master + network=host + + - id: build + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + platforms: linux/${{ env.arch }} + no-cache: true + + merge_manifest: + needs: [docker_x86_release, docker_arm_release] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - uses: docker/setup-buildx-action@v2 + + - uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Merge multi-arch manifests for custom output + run: | + docker buildx imagetools create -t supabase/realtime:v${{ github.event.inputs.docker_tag }} \ + supabase/realtime@${{ needs.docker_x86_release.outputs.image_digest }} \ + supabase/realtime@${{ needs.docker_arm_release.outputs.image_digest }} + + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + role-to-assume: ${{ secrets.PROD_AWS_ROLE }} + aws-region: us-east-1 + + - name: Login to ECR + uses: docker/login-action@v2 + with: + registry: public.ecr.aws + + - name: Login to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Mirror to ECR + uses: akhilerm/tag-push-action@v2.0.0 + with: + src: docker.io/supabase/realtime:v${{ github.event.inputs.docker_tag }} + dst: | + public.ecr.aws/supabase/realtime:v${{ github.event.inputs.docker_tag }} + ghcr.io/supabase/realtime:v${{ github.event.inputs.docker_tag }} +