Skip to content

Commit

Permalink
Merge pull request from GHSA-3p3g-vpw6-4w66
Browse files Browse the repository at this point in the history
BREAKING CHANGE: This patch requires a new SQL Table which needs to be created using `hydra migrate sql`. No other breaking changes have been introduced by this patch.

This patch introduces a blacklist for JTIs which prevents a potential replay of `private_key_jwt` JWTs when performing client authorization.

## GHSA-3p3g-vpw6-4w66

### Impact

When using client authentication method "private_key_jwt" [1], OpenId specification says the following about assertion `jti`:

> A unique identifier for the token, which can be used to prevent reuse of the token. These tokens MUST only be used once, unless conditions for reuse were negotiated between the parties

Hydra does not seem to check the uniqueness of this `jti` value. Here is me sending the same token request twice, hence with the same `jti` assertion, and getting two access tokens:

```
$ curl --insecure --location --request POST 'https://localhost/_/oauth2/token' \
   --header 'Content-Type: application/x-www-form-urlencoded' \
   --data-urlencode 'grant_type=client_credentials' \
   --data-urlencode 'client_id=c001d00d-5ecc-beef-ca4e-b00b1e54a111' \
   --data-urlencode 'scope=application openid' \
   --data-urlencode 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
   --data-urlencode 'client_assertion=eyJhb [...] jTw'
{"access_token":"zeG0NoqOtlACl8q5J6A-TIsNegQRRUzqLZaYrQtoBZQ.VR6iUcJQYp3u_j7pwvL7YtPqGhtyQe5OhnBE2KCp5pM","expires_in":3599,"scope":"application openid","token_type":"bearer"}⏎            ~$ curl --insecure --location --request POST 'https://localhost/_/oauth2/token' \
   --header 'Content-Type: application/x-www-form-urlencoded' \
   --data-urlencode 'grant_type=client_credentials' \
   --data-urlencode 'client_id=c001d00d-5ecc-beef-ca4e-b00b1e54a111' \
   --data-urlencode 'scope=application openid' \
   --data-urlencode 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
   --data-urlencode 'client_assertion=eyJhb [...] jTw'
{"access_token":"wOYtgCLxLXlELORrwZlmeiqqMQ4kRzV-STU2_Sollas.mwlQGCZWXN7G2IoegUe1P0Vw5iGoKrkOzOaplhMSjm4","expires_in":3599,"scope":"application openid","token_type":"bearer"}
```

### Severity

We rate the severity as medium because the following reasons make it hard to replay tokens without the patch:�

- TLS protects against MITM which makes it difficult to intercept valid tokens for replay attacks
- The expiry time of the JWT gives only a short window of opportunity where it could be replayed

### Patches

This will be patched with v1.4.0+oryOS.17

### Workarounds

Two workarounds have been identified:

- Do not allow clients to use `private_key_jwt`
- Use short expiry times for the JWTs

### References

https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication

### Upstream

This issue will be resolved in the upstream repository https://github.com/ory/fosite
  • Loading branch information
zepatrik authored Apr 2, 2020
1 parent 179dd5a commit 700d17d
Show file tree
Hide file tree
Showing 17 changed files with 464 additions and 47 deletions.
2 changes: 1 addition & 1 deletion client/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ type Manager interface {
}

type Storage interface {
fosite.Storage
GetClient(ctx context.Context, id string) (fosite.Client, error)

CreateClient(ctx context.Context, c *Client) error

Expand Down
7 changes: 4 additions & 3 deletions driver/configuration/provider_viper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import (
"strings"
"testing"

"github.com/ory/hydra/x"
"github.com/ory/viper"
"github.com/ory/x/logrusx"
"github.com/rs/cors"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/ory/hydra/x"
"github.com/ory/viper"
"github.com/ory/x/logrusx"
)

func setupEnv(env map[string]string) func(t *testing.T) (func(), func()) {
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/go-swagger/go-swagger v0.22.1-0.20200306221957-4aad3a5f78b8
github.com/gobuffalo/packr v1.24.0
github.com/gobwas/glob v0.2.3
github.com/golang/mock v1.3.1
github.com/golang/mock v1.4.3
github.com/google/uuid v1.1.1
github.com/gorilla/sessions v1.1.4-0.20181208214519-12bd4761fc66
github.com/gtank/cryptopasta v0.0.0-20170601214702-1f550f6f2f69
Expand All @@ -26,7 +26,7 @@ require (
github.com/oleiade/reflections v1.0.0
github.com/olekukonko/tablewriter v0.0.1
github.com/opentracing/opentracing-go v1.1.1-0.20190913142402-a7454ce5950e
github.com/ory/fosite v0.30.6
github.com/ory/fosite v0.31.0
github.com/ory/go-acc v0.2.1
github.com/ory/graceful v0.1.1
github.com/ory/herodot v0.7.0
Expand All @@ -53,7 +53,7 @@ require (
github.com/uber/jaeger-client-go v2.22.1+incompatible
github.com/urfave/negroni v1.0.0
go.opentelemetry.io/otel v0.2.1
golang.org/x/crypto v0.0.0-20200320181102-891825fb96df
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59
golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/tools v0.0.0-20200313205530-4303120df7d8
Expand Down
9 changes: 9 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,8 @@ github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
Expand Down Expand Up @@ -737,6 +739,8 @@ github.com/ory/dockertest/v3 v3.5.4/go.mod h1:J8ZUbNB2FOhm1cFZW9xBpDsODqsSWcyYgt
github.com/ory/fosite v0.29.0/go.mod h1:0atSZmXO7CAcs6NPMI/Qtot8tmZYj04Nddoold4S2h0=
github.com/ory/fosite v0.30.6 h1:t1EQHkGv3gVODC9oBvoEi3nIyZ4kvC/ayCsvyswDvis=
github.com/ory/fosite v0.30.6/go.mod h1:Lq9qQ9Sl6mcea2Tt8J7PU+wUeFYPZ+vg7N3zPVKGbN8=
github.com/ory/fosite v0.31.0 h1:NZ0FA4ywPEYrCGLNVBAz2dq8vTacLDbbO4Iiy68WCKQ=
github.com/ory/fosite v0.31.0/go.mod h1:lSSqjo8Kr/U1P3kJWxsNGHmq7TnH/7pS1ijvQRT7G+g=
github.com/ory/go-acc v0.0.0-20181118080137-ddc355013f90 h1:Bpk3eqc3rbJT2mE+uS9ETzmi2cEL4RuIKz2iUeteh04=
github.com/ory/go-acc v0.0.0-20181118080137-ddc355013f90/go.mod h1:sxnvPCxChFuSmTJGj8FdMupeq1BezCiEpDjTUXQ4hf4=
github.com/ory/go-acc v0.2.1 h1:Pwcmwd/cSnwJsYN76+w3HU7oXeWFTkwj/KUj1qGDrVw=
Expand Down Expand Up @@ -1025,6 +1029,8 @@ golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d h1:1ZiEyfaQIg3Qh0EoqpwAak
golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200320181102-891825fb96df h1:lDWgvUvNnaTnNBc/dwOty86cFeKoKWbwy2wQj0gIxbU=
golang.org/x/crypto v0.0.0-20200320181102-891825fb96df/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
Expand Down Expand Up @@ -1153,6 +1159,7 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepx
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
Expand Down Expand Up @@ -1324,5 +1331,7 @@ mvdan.cc/unparam v0.0.0-20190720180237-d51796306d8f/go.mod h1:4G1h5nDURzA3bwVMZI
mvdan.cc/unparam v0.0.0-20190917161559-b83a221c10a2/go.mod h1:rCqoQrfAmpTX/h2APczwM7UymU/uvaOluiVPIYCSY/k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0=
sourcegraph.com/sqs/pbtypes v1.0.0/go.mod h1:3AciMUv4qUuRHRHhOG4TZOB+72GdPVz5k+c648qsFS4=
116 changes: 116 additions & 0 deletions oauth2/fosite_store_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ package oauth2

import (
"context"
"crypto/sha256"
"fmt"
"net/url"
"testing"
"time"

"github.com/ory/hydra/x"

"github.com/ory/fosite/storage"
"github.com/ory/x/sqlxx"

Expand All @@ -44,6 +47,33 @@ import (
"github.com/ory/hydra/consent"
)

func signatureFromJTI(jti string) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(jti)))
}

type blacklistedJTI struct {
JTI string
Signature string `db:"signature"`
Expiry time.Time `db:"expires_at"`
}

func newBlacklistedJTI(jti string, exp time.Time) *blacklistedJTI {
return &blacklistedJTI{
JTI: jti,
Signature: signatureFromJTI(jti),
// because the database timestamp types are not as accurate as time.Time we truncate to seconds (which should always work)
Expiry: exp.UTC().Truncate(time.Second),
}
}

type assertionJWTReader interface {
x.FositeStorer

getClientAssertionJWT(ctx context.Context, jti string) (*blacklistedJTI, error)

setClientAssertionJWT(context.Context, *blacklistedJTI) error
}

var defaultRequest = fosite.Request{
ID: "blank",
RequestedAt: time.Now().UTC().Round(time.Second),
Expand Down Expand Up @@ -135,6 +165,8 @@ func TestHelperRunner(t *testing.T, store InternalRegistry, k string) {
t.Run(fmt.Sprintf("case=testHelperRevokeRefreshToken/db=%s", k), testHelperRevokeRefreshToken(store))
t.Run(fmt.Sprintf("case=testHelperCreateGetDeletePKCERequestSession/db=%s", k), testHelperCreateGetDeletePKCERequestSession(store))
t.Run(fmt.Sprintf("case=testHelperFlushTokens/db=%s", k), testHelperFlushTokens(store, time.Hour))
t.Run(fmt.Sprintf("case=testFositeStoreSetClientAssertionJWT/db=%s", k), testFositeStoreSetClientAssertionJWT(store))
t.Run(fmt.Sprintf("case=testFositeStoreClientAssertionJWTValid/db=%s", k), testFositeStoreClientAssertionJWTValid(store))
}

func testHelperUniqueConstraints(m InternalRegistry, storageType string) func(t *testing.T) {
Expand Down Expand Up @@ -531,6 +563,90 @@ func testFositeSqlStoreTransactionRollbackOpenIdConnectSession(m InternalRegistr
}
}

func testFositeStoreSetClientAssertionJWT(m InternalRegistry) func(*testing.T) {
return func(t *testing.T) {
t.Run("case=basic setting works", func(t *testing.T) {
store, ok := m.OAuth2Storage().(assertionJWTReader)
require.True(t, ok)
jti := newBlacklistedJTI("basic jti", time.Now().Add(time.Minute))

require.NoError(t, store.SetClientAssertionJWT(context.Background(), jti.JTI, jti.Expiry))

cmp, err := store.getClientAssertionJWT(context.Background(), jti.JTI)
require.NoError(t, err)
assert.Equal(t, jti, cmp)
})

t.Run("case=errors when the JTI is blacklisted", func(t *testing.T) {
store, ok := m.OAuth2Storage().(assertionJWTReader)
require.True(t, ok)
jti := newBlacklistedJTI("already set jti", time.Now().Add(time.Minute))
require.NoError(t, store.setClientAssertionJWT(context.Background(), jti))

assert.True(t, errors.Is(store.SetClientAssertionJWT(context.Background(), jti.JTI, jti.Expiry), fosite.ErrJTIKnown))
})

t.Run("case=deletes expired JTIs", func(t *testing.T) {
store, ok := m.OAuth2Storage().(assertionJWTReader)
require.True(t, ok)
expiredJTI := newBlacklistedJTI("expired jti", time.Now().Add(-time.Minute))
require.NoError(t, store.setClientAssertionJWT(context.Background(), expiredJTI))
newJTI := newBlacklistedJTI("some new jti", time.Now().Add(time.Minute))

require.NoError(t, store.SetClientAssertionJWT(context.Background(), newJTI.JTI, newJTI.Expiry))

_, err := store.getClientAssertionJWT(context.Background(), expiredJTI.JTI)
assert.True(t, errors.Is(err, sqlcon.ErrNoRows))
cmp, err := store.getClientAssertionJWT(context.Background(), newJTI.JTI)
assert.Equal(t, newJTI, cmp)
})

t.Run("case=inserts same JTI if expired", func(t *testing.T) {
store, ok := m.OAuth2Storage().(assertionJWTReader)
require.True(t, ok)
jti := newBlacklistedJTI("going to be reused jti", time.Now().Add(-time.Minute))
require.NoError(t, store.setClientAssertionJWT(context.Background(), jti))

jti.Expiry = jti.Expiry.Add(2 * time.Minute)
assert.NoError(t, store.SetClientAssertionJWT(context.Background(), jti.JTI, jti.Expiry))
cmp, err := store.getClientAssertionJWT(context.Background(), jti.JTI)
assert.NoError(t, err)
assert.Equal(t, jti, cmp)
})
}
}

func testFositeStoreClientAssertionJWTValid(m InternalRegistry) func(*testing.T) {
return func(t *testing.T) {
t.Run("case=returns valid on unknown JTI", func(t *testing.T) {
store, ok := m.OAuth2Storage().(assertionJWTReader)
require.True(t, ok)

assert.NoError(t, store.ClientAssertionJWTValid(context.Background(), "unknown jti"))
})

t.Run("case=returns invalid on known JTI", func(t *testing.T) {
store, ok := m.OAuth2Storage().(assertionJWTReader)
require.True(t, ok)
jti := newBlacklistedJTI("known jti", time.Now().Add(time.Minute))

require.NoError(t, store.setClientAssertionJWT(context.Background(), jti))

assert.True(t, errors.Is(store.ClientAssertionJWTValid(context.Background(), jti.JTI), fosite.ErrJTIKnown))
})

t.Run("case=returns valid on expired JTI", func(t *testing.T) {
store, ok := m.OAuth2Storage().(assertionJWTReader)
require.True(t, ok)
jti := newBlacklistedJTI("expired jti", time.Now().Add(-time.Minute))

require.NoError(t, store.setClientAssertionJWT(context.Background(), jti))

assert.NoError(t, store.ClientAssertionJWTValid(context.Background(), jti.JTI))
})
}
}

func doTestCommit(m InternalRegistry, t *testing.T,
createFn func(context.Context, string, fosite.Requester) error,
getFn func(context.Context, string, fosite.Session) (fosite.Requester, error),
Expand Down
69 changes: 59 additions & 10 deletions oauth2/fosite_store_memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ import (
)

type FositeMemoryStore struct {
AuthorizeCodes map[string]authorizeCode
IDSessions map[string]fosite.Requester
AccessTokens map[string]fosite.Requester
RefreshTokens map[string]fosite.Requester
PKCES map[string]fosite.Requester
AuthorizeCodes map[string]authorizeCode
IDSessions map[string]fosite.Requester
AccessTokens map[string]fosite.Requester
RefreshTokens map[string]fosite.Requester
PKCES map[string]fosite.Requester
BlacklistedJTIs map[string]time.Time

c Configuration
r InternalRegistry
Expand All @@ -53,11 +54,12 @@ func NewFositeMemoryStore(
c Configuration,
) *FositeMemoryStore {
return &FositeMemoryStore{
AuthorizeCodes: make(map[string]authorizeCode),
IDSessions: make(map[string]fosite.Requester),
AccessTokens: make(map[string]fosite.Requester),
PKCES: make(map[string]fosite.Requester),
RefreshTokens: make(map[string]fosite.Requester),
AuthorizeCodes: make(map[string]authorizeCode),
IDSessions: make(map[string]fosite.Requester),
AccessTokens: make(map[string]fosite.Requester),
PKCES: make(map[string]fosite.Requester),
RefreshTokens: make(map[string]fosite.Requester),
BlacklistedJTIs: make(map[string]time.Time),

c: c,
r: r,
Expand All @@ -73,6 +75,53 @@ func (s *FositeMemoryStore) GetClient(ctx context.Context, id string) (fosite.Cl
return s.r.ClientManager().GetClient(ctx, id)
}

func (s *FositeMemoryStore) ClientAssertionJWTValid(_ context.Context, jti string) error {
s.RLock()
defer s.RUnlock()
if exp, exists := s.BlacklistedJTIs[jti]; exists && exp.After(time.Now()) {
return errors.WithStack(fosite.ErrJTIKnown)
}

return nil
}

func (s *FositeMemoryStore) SetClientAssertionJWT(_ context.Context, jti string, exp time.Time) error {
s.Lock()
defer s.Unlock()

for j, e := range s.BlacklistedJTIs {
if e.Before(time.Now()) {
delete(s.BlacklistedJTIs, j)
}
}

if _, exists := s.BlacklistedJTIs[jti]; exists {
return errors.WithStack(fosite.ErrJTIKnown)
}

s.BlacklistedJTIs[jti] = exp
return nil
}

func (s *FositeMemoryStore) getClientAssertionJWT(_ context.Context, jti string) (*blacklistedJTI, error) {
s.RLock()
defer s.RUnlock()

if exp, exists := s.BlacklistedJTIs[jti]; exists {
return newBlacklistedJTI(jti, exp), nil
}

return nil, errors.WithStack(sqlcon.ErrNoRows)
}

func (s *FositeMemoryStore) setClientAssertionJWT(_ context.Context, jti *blacklistedJTI) error {
s.Lock()
defer s.Unlock()

s.BlacklistedJTIs[jti.JTI] = jti.Expiry
return nil
}

func (s *FositeMemoryStore) Authenticate(ctx context.Context, id string, secret []byte) (*client.Client, error) {
return s.r.ClientManager().Authenticate(ctx, id, secret)
}
Expand Down
Loading

0 comments on commit 700d17d

Please sign in to comment.