Skip to content

Commit 540551a

Browse files
elleMathieu Fenniak
authored andcommitted
federation: protect against SSRF attacks (#11795)
Adds an extra check to ensure the `keyId` and `actorId` included in signed requests and actor records point back to the originating host. This check prevents server-side request forgery (SSRF) attacks where a carefully crafted request could be used to trick a federation server into making requests to arbitrary hosts and ports. Further refactors can make these checks more robust, but would better fit after other existing refactor PRs are merged. Related: https://codeberg.org/forgejo/forgejo/issues/11779 ### Tests for Go changes - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11795 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
1 parent 2210125 commit 540551a

17 files changed

Lines changed: 296 additions & 46 deletions

modules/activitypub/client.go

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import (
1111
"crypto/rsa"
1212
"crypto/x509"
1313
"encoding/pem"
14+
"errors"
1415
"fmt"
1516
"io"
1617
"net/http"
18+
"net/url"
1719
"strings"
1820
"time"
1921

2022
user_model "forgejo.org/models/user"
23+
"forgejo.org/modules/hostmatcher"
2124
"forgejo.org/modules/log"
2225
"forgejo.org/modules/proxy"
2326
"forgejo.org/modules/setting"
@@ -92,9 +95,60 @@ func NewClientFactoryWithTimeout(timeout time.Duration) (c *ClientFactory, err e
9295
return c, err
9396
}
9497

98+
// SetHostMatcher sets the HTTP dialer that ensures the `to` host matches the set federation host.
99+
//
100+
// This prevents specially crafted key IDs or other IRIs from triggering a SSRF
101+
// against hosts that do not match the host of the originating request.
102+
//
103+
// If no host is set, an error will be returned unless `setting.Federation.InsecureAllowInvalidHosts` is set to `true`.
104+
func (cf *ClientFactory) setHostMatcher(hosts []*url.URL) error {
105+
if cf == nil {
106+
return errors.New("nil client factory")
107+
}
108+
109+
hostsNil := len(hosts) == 0
110+
for _, host := range hosts {
111+
hostsNil = hostsNil || host == nil
112+
}
113+
114+
if hostsNil && !setting.Federation.InsecureAllowInvalidHosts {
115+
return errors.New("nil client host(s)")
116+
}
117+
118+
var hostMatchAllow, hostMatchBlock string
119+
if setting.Federation.InsecureAllowInvalidHosts {
120+
hostMatchAllow = fmt.Sprintf("%s, %s", hostmatcher.MatchBuiltinPrivate, hostmatcher.MatchBuiltinLoopback)
121+
for _, host := range hosts {
122+
if host != nil {
123+
hostMatchAllow = fmt.Sprintf("%s, %s", hostMatchAllow, host.Host)
124+
}
125+
}
126+
} else {
127+
for i, host := range hosts {
128+
if i == 0 {
129+
hostMatchAllow = host.Host
130+
} else {
131+
hostMatchAllow = fmt.Sprintf("%s, %s", hostMatchAllow, host.Host)
132+
}
133+
}
134+
hostMatchBlock = fmt.Sprintf("%s, %s", hostmatcher.MatchBuiltinPrivate, hostmatcher.MatchBuiltinLoopback)
135+
}
136+
137+
allowMatcher := hostmatcher.ParseHostMatchList("", hostMatchAllow)
138+
blockMatcher := hostmatcher.ParseHostMatchList("", hostMatchBlock)
139+
dialCtx := hostmatcher.NewDialContext("activitypub", allowMatcher, blockMatcher, nil)
140+
141+
cf.client.Transport = &http.Transport{
142+
Proxy: proxy.Proxy(),
143+
DialContext: dialCtx,
144+
}
145+
146+
return nil
147+
}
148+
95149
type APClientFactory interface {
96-
WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error)
97-
WithKeysDirect(ctx context.Context, privateKey, pubID string) (APClient, error)
150+
WithKeys(ctx context.Context, user *user_model.User, pubID string, hosts []*url.URL) (APClient, error)
151+
WithKeysDirect(ctx context.Context, privateKey, pubID string, hosts []*url.URL) (APClient, error)
98152
}
99153

100154
// Client struct
@@ -109,13 +163,17 @@ type Client struct {
109163
}
110164

111165
// NewRequest function
112-
func (cf *ClientFactory) WithKeysDirect(ctx context.Context, privateKey, pubID string) (APClient, error) {
166+
func (cf *ClientFactory) WithKeysDirect(ctx context.Context, privateKey, pubID string, hosts []*url.URL) (APClient, error) {
113167
privPem, _ := pem.Decode([]byte(privateKey))
114168
privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes)
115169
if err != nil {
116170
return nil, err
117171
}
118172

173+
if err = cf.setHostMatcher(hosts); err != nil {
174+
return nil, fmt.Errorf("client: invalid host for HostMatcher: %w", err)
175+
}
176+
119177
c := Client{
120178
client: cf.client,
121179
algs: cf.algs,
@@ -128,15 +186,15 @@ func (cf *ClientFactory) WithKeysDirect(ctx context.Context, privateKey, pubID s
128186
return &c, nil
129187
}
130188

131-
func (cf *ClientFactory) WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error) {
189+
func (cf *ClientFactory) WithKeys(ctx context.Context, user *user_model.User, pubID string, hosts []*url.URL) (APClient, error) {
132190
priv, err := GetPrivateKey(ctx, user)
133191
if err != nil {
134192
return nil, err
135193
}
136-
return cf.WithKeysDirect(ctx, priv, pubID)
194+
return cf.WithKeysDirect(ctx, priv, pubID, hosts)
137195
}
138196

139-
// NewRequest function
197+
// NewRequest function creates a new signed request to an external federation host.
140198
func (c *Client) newRequest(method string, b []byte, to string) (req *http.Request, err error) {
141199
buf := bytes.NewBuffer(b)
142200
req, err = http.NewRequest(method, to, buf)

modules/activitypub/client_test.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"net/http"
1111
"net/http/httptest"
12+
"net/url"
1213
"testing"
1314
"time"
1415

@@ -18,6 +19,7 @@ import (
1819
"forgejo.org/modules/activitypub"
1920
"forgejo.org/modules/log"
2021
"forgejo.org/modules/setting"
22+
"forgejo.org/modules/test"
2123

2224
"github.com/stretchr/testify/assert"
2325
"github.com/stretchr/testify/require"
@@ -63,20 +65,46 @@ Set up a user called "me" for all tests
6365
*/
6466

6567
func TestClientCtx(t *testing.T) {
68+
defer test.MockVariableValue(&setting.Federation.InsecureAllowInvalidHosts, true)()
6669
require.NoError(t, unittest.PrepareTestDatabase())
6770
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
6871
pubID := "myGpgId"
6972
cf, err := activitypub.NewClientFactory()
7073
log.Debug("ClientFactory: %v\nError: %v", cf, err)
7174
require.NoError(t, err)
7275

73-
c, err := cf.WithKeys(db.DefaultContext, user, pubID)
76+
c, err := cf.WithKeys(db.DefaultContext, user, pubID, nil)
7477

7578
log.Debug("Client: %v\nError: %v", c, err)
7679
require.NoError(t, err)
7780
_ = activitypub.NewContext(db.DefaultContext, cf)
7881
}
7982

83+
func TestClientNilHostsCtx(t *testing.T) {
84+
defer test.MockVariableValue(&setting.Federation.InsecureAllowInvalidHosts, false)()
85+
require.NoError(t, unittest.PrepareTestDatabase())
86+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
87+
pubID := "myGpgId"
88+
cf, err := activitypub.NewClientFactory()
89+
log.Debug("ClientFactory: %v\nError: %v", cf, err)
90+
require.NoError(t, err)
91+
92+
_, err = cf.WithKeys(db.DefaultContext, user, pubID, nil)
93+
require.Error(t, err)
94+
95+
_, err = cf.WithKeys(db.DefaultContext, user, pubID, nil)
96+
require.Error(t, err)
97+
98+
_, err = cf.WithKeys(db.DefaultContext, user, pubID, []*url.URL{nil})
99+
require.Error(t, err)
100+
101+
testURL, err := url.Parse("https://example.dev")
102+
require.NoError(t, err)
103+
104+
_, err = cf.WithKeys(db.DefaultContext, user, pubID, []*url.URL{testURL, nil})
105+
require.Error(t, err)
106+
}
107+
80108
/* TODO: bring this test to work or delete
81109
func TestActivityPubSignedGet(t *testing.T) {
82110
require.NoError(t, unittest.PrepareTestDatabase())
@@ -109,13 +137,10 @@ func TestActivityPubSignedGet(t *testing.T) {
109137
*/
110138

111139
func TestActivityPubSignedPost(t *testing.T) {
140+
defer test.MockVariableValue(&setting.Federation.InsecureAllowInvalidHosts, true)()
112141
require.NoError(t, unittest.PrepareTestDatabase())
113142
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
114143
pubID := "https://example.com/pubID"
115-
cf, err := activitypub.NewClientFactory()
116-
require.NoError(t, err)
117-
c, err := cf.WithKeys(db.DefaultContext, user, pubID)
118-
require.NoError(t, err)
119144

120145
expected := "BODY"
121146
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -129,6 +154,13 @@ func TestActivityPubSignedPost(t *testing.T) {
129154
}))
130155
defer srv.Close()
131156

157+
cf, err := activitypub.NewClientFactory()
158+
require.NoError(t, err)
159+
srvURL, err := url.Parse(srv.URL)
160+
require.NoError(t, err)
161+
c, err := cf.WithKeys(db.DefaultContext, user, pubID, []*url.URL{srvURL})
162+
require.NoError(t, err)
163+
132164
r, err := c.Post([]byte(expected), srv.URL)
133165
require.NoError(t, err)
134166
defer r.Body.Close()

modules/setting/federation.go

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,25 @@ import (
1212
// Federation settings
1313
var (
1414
Federation = struct {
15-
Enabled bool
16-
ShareUserStatistics bool
17-
MaxSize int64
18-
SignatureAlgorithms []string
19-
DigestAlgorithm string
20-
GetHeaders []string
21-
PostHeaders []string
22-
SignatureEnforced bool
15+
Enabled bool
16+
ShareUserStatistics bool
17+
MaxSize int64
18+
SignatureAlgorithms []string
19+
DigestAlgorithm string
20+
GetHeaders []string
21+
PostHeaders []string
22+
SignatureEnforced bool
23+
InsecureAllowInvalidHosts bool
2324
}{
24-
Enabled: false,
25-
ShareUserStatistics: true,
26-
MaxSize: 4,
27-
SignatureAlgorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
28-
DigestAlgorithm: "SHA-256",
29-
GetHeaders: []string{"(request-target)", "Date", "Host"},
30-
PostHeaders: []string{"(request-target)", "Date", "Host", "Digest"},
31-
SignatureEnforced: true,
25+
Enabled: false,
26+
ShareUserStatistics: true,
27+
MaxSize: 4,
28+
SignatureAlgorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
29+
DigestAlgorithm: "SHA-256",
30+
GetHeaders: []string{"(request-target)", "Date", "Host"},
31+
PostHeaders: []string{"(request-target)", "Date", "Host", "Digest"},
32+
SignatureEnforced: true,
33+
InsecureAllowInvalidHosts: false,
3234
}
3335
)
3436

services/federation/delivery_queue.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package federation
66
import (
77
"fmt"
88
"io"
9+
"net/url"
910

1011
"forgejo.org/models/user"
1112
"forgejo.org/modules/activitypub"
@@ -54,7 +55,13 @@ func deliverToInbox(item deliveryQueueItem) error {
5455
if err != nil {
5556
return err
5657
}
57-
apclient, err := clientFactory.WithKeys(ctx, item.Doer, item.Doer.APActorID()+"#main-key")
58+
59+
inboxURL, err := url.Parse(item.InboxURL)
60+
if err != nil {
61+
return fmt.Errorf("invalid delivery item inbox URL: %w", err)
62+
}
63+
64+
apclient, err := clientFactory.WithKeys(ctx, item.Doer, item.Doer.APActorID()+"#main-key", []*url.URL{inboxURL})
5865
if err != nil {
5966
return err
6067
}

services/federation/federation_service.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,12 @@ func createFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forge
129129
return nil, err
130130
}
131131

132-
client, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID())
132+
uri, err := url.Parse(actorID.AsWellKnownNodeInfoURI())
133+
if err != nil {
134+
return nil, fmt.Errorf("invalid actor URI: %w", err)
135+
}
136+
137+
client, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID(), []*url.URL{uri})
133138
if err != nil {
134139
return nil, err
135140
}
@@ -175,7 +180,8 @@ func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHost *
175180
return nil, nil, err
176181
}
177182

178-
apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID())
183+
hostURL := federationHost.AsURL()
184+
apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID(), []*url.URL{&hostURL})
179185
if err != nil {
180186
return nil, nil, err
181187
}

services/federation/repository_inbox_like.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"fmt"
99
"net/http"
10+
"net/url"
1011
"time"
1112

1213
"forgejo.org/models/forgefed"
@@ -112,9 +113,15 @@ func SendLikeActivities(ctx context.Context, doer user.User, repoID int64) error
112113
}
113114

114115
likeActivityList := make([]fm.ForgeLike, 0)
116+
var hosts []*url.URL
115117
for _, followingRepo := range followingRepos {
116118
log.Trace("Found following repo: %#v", followingRepo)
117119
target := followingRepo.URI
120+
hostURL, err := url.Parse(target)
121+
if err != nil {
122+
return fmt.Errorf("invalid repository URL: %w", err)
123+
}
124+
hosts = append(hosts, hostURL)
118125
likeActivity, err := fm.NewForgeLike(doer.APActorID(), target, time.Now())
119126
if err != nil {
120127
return err
@@ -126,7 +133,7 @@ func SendLikeActivities(ctx context.Context, doer user.User, repoID int64) error
126133
if err != nil {
127134
return err
128135
}
129-
apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorID()+"#main-key")
136+
apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorID()+"#main-key", hosts)
130137
if err != nil {
131138
return err
132139
}

services/federation/signature_service.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"errors"
1212
"fmt"
1313
"net/url"
14+
"strconv"
1415

1516
"forgejo.org/models/forgefed"
1617
"forgejo.org/models/user"
@@ -57,8 +58,25 @@ func FindOrCreateActorKey(ctx context.Context, keyID string) (pubKey any, err er
5758
return pubKey, nil
5859
}
5960

61+
port := uint16(443)
62+
if keyURL.Port() != "" {
63+
port64, err := strconv.ParseUint(keyURL.Port(), 10, 16)
64+
if err != nil {
65+
return nil, err
66+
}
67+
port = uint16(port64)
68+
}
69+
70+
hostURL := *keyURL
71+
// if the `Actor` is not an `Application`, check for a `FederationHost` record associated with the key ID
72+
// for user `Actor`s, we should already have a `FederationHost` record in the database, so we ensure that the user `Actor` matches the respective `FederationHost` URL
73+
// otherwise, we just match against the supplied key ID URL
74+
if federationHost, err := forgefed.FindFederationHostByFqdnAndPort(ctx, keyURL.Hostname(), port); err == nil {
75+
hostURL = federationHost.AsURL()
76+
}
77+
6078
// Fetch missing key
61-
pubKey, pubKeyBytes, actor, err := fetchKeyFromAp(ctx, *keyURL)
79+
pubKey, pubKeyBytes, actor, err := fetchKeyFromAp(ctx, *keyURL, hostURL)
6280
if err != nil {
6381
return nil, err
6482
}
@@ -136,7 +154,7 @@ func updateFederationHostKey(ctx context.Context, federationHost *forgefed.Feder
136154
return nil
137155
}
138156

139-
func fetchKeyFromAp(ctx context.Context, keyURL url.URL) (pubKey any, pubKeyBytes []byte, apPerson *ap.Actor, err error) {
157+
func fetchKeyFromAp(ctx context.Context, keyURL, hostURL url.URL) (pubKey any, pubKeyBytes []byte, apPerson *ap.Actor, err error) {
140158
log.Trace("keyURL %v", keyURL)
141159
actionsUser := user.NewAPServerActor()
142160

@@ -145,7 +163,7 @@ func fetchKeyFromAp(ctx context.Context, keyURL url.URL) (pubKey any, pubKeyByte
145163
return nil, nil, nil, err
146164
}
147165

148-
apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID())
166+
apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID(), []*url.URL{&hostURL})
149167
if err != nil {
150168
return nil, nil, nil, err
151169
}

0 commit comments

Comments
 (0)