Skip to content

Commit 77b0275

Browse files
famfoGusted
authored andcommitted
feat(activitiypub): enable HTTP signatures on all ActivityPub endpoints (go-gitea#7035)
- Set the right keyID and use the right signing keys for outgoing requests. - Verify the HTTP signature of all incoming requests, except for the server actor. - Caches keys of incoming requests for users and servers actors. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7035 Reviewed-by: Gusted <[email protected]> Co-authored-by: famfo <[email protected]> Co-committed-by: famfo <[email protected]>
1 parent ba5b157 commit 77b0275

22 files changed

+685
-126
lines changed

models/forgefed/federationhost.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package forgefed
55

66
import (
7+
"database/sql"
78
"fmt"
89
"strings"
910
"time"
@@ -15,12 +16,14 @@ import (
1516
// FederationHost data type
1617
// swagger:model
1718
type FederationHost struct {
18-
ID int64 `xorm:"pk autoincr"`
19-
HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
20-
NodeInfo NodeInfo `xorm:"extends NOT NULL"`
21-
LatestActivity time.Time `xorm:"NOT NULL"`
22-
Created timeutil.TimeStamp `xorm:"created"`
23-
Updated timeutil.TimeStamp `xorm:"updated"`
19+
ID int64 `xorm:"pk autoincr"`
20+
HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
21+
NodeInfo NodeInfo `xorm:"extends NOT NULL"`
22+
LatestActivity time.Time `xorm:"NOT NULL"`
23+
Created timeutil.TimeStamp `xorm:"created"`
24+
Updated timeutil.TimeStamp `xorm:"updated"`
25+
KeyID sql.NullString `xorm:"key_id UNIQUE"`
26+
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
2427
}
2528

2629
// Factory function for FederationHost. Created struct is asserted to be valid.

models/forgefed/federationhost_repository.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ func GetFederationHost(ctx context.Context, ID int64) (*FederationHost, error) {
3030
return host, nil
3131
}
3232

33-
func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) {
33+
func findFederationHostFromDB(ctx context.Context, searchKey, searchValue string) (*FederationHost, error) {
3434
host := new(FederationHost)
35-
has, err := db.GetEngine(ctx).Where("host_fqdn=?", strings.ToLower(fqdn)).Get(host)
35+
has, err := db.GetEngine(ctx).Where(searchKey, searchValue).Get(host)
3636
if err != nil {
3737
return nil, err
3838
} else if !has {
@@ -44,6 +44,14 @@ func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost
4444
return host, nil
4545
}
4646

47+
func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) {
48+
return findFederationHostFromDB(ctx, "host_fqdn=?", strings.ToLower(fqdn))
49+
}
50+
51+
func FindFederationHostByKeyID(ctx context.Context, keyID string) (*FederationHost, error) {
52+
return findFederationHostFromDB(ctx, "key_id=?", keyID)
53+
}
54+
4755
func CreateFederationHost(ctx context.Context, host *FederationHost) error {
4856
if res, err := validation.IsValid(host); !res {
4957
return err

models/forgejo_migrations/migrate.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ var migrations = []*Migration{
9494
NewMigration("Add `created_unix` column to `user_redirect` table", AddCreatedUnixToRedirect),
9595
// v27 -> v28
9696
NewMigration("Add pronoun privacy settings to user", AddHidePronounsOptionToUser),
97+
// v28 -> v29
98+
NewMigration("Add public key information to `FederatedUser` and `FederationHost`", AddPublicKeyInformationForFederation),
9799
}
98100

99101
// GetCurrentDBVersion returns the current Forgejo database version.

models/forgejo_migrations/v29.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright 2025 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package forgejo_migrations //nolint:revive
5+
6+
import (
7+
"database/sql"
8+
9+
"xorm.io/xorm"
10+
)
11+
12+
func AddPublicKeyInformationForFederation(x *xorm.Engine) error {
13+
type FederationHost struct {
14+
KeyID sql.NullString `xorm:"key_id UNIQUE"`
15+
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
16+
}
17+
18+
err := x.Sync(&FederationHost{})
19+
if err != nil {
20+
return err
21+
}
22+
23+
type FederatedUser struct {
24+
KeyID sql.NullString `xorm:"key_id UNIQUE"`
25+
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
26+
}
27+
28+
return x.Sync(&FederatedUser{})
29+
}

models/user/activitypub.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright 2025 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package user
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net/url"
10+
11+
"forgejo.org/models/db"
12+
"forgejo.org/modules/setting"
13+
"forgejo.org/modules/validation"
14+
)
15+
16+
// APActorID returns the IRI to the api endpoint of the user
17+
func (u *User) APActorID() string {
18+
if u.IsAPServerActor() {
19+
return fmt.Sprintf("%sapi/v1/activitypub/actor", setting.AppURL)
20+
}
21+
22+
return fmt.Sprintf("%sapi/v1/activitypub/user-id/%s", setting.AppURL, url.PathEscape(fmt.Sprintf("%d", u.ID)))
23+
}
24+
25+
// APActorKeyID returns the ID of the user's public key
26+
func (u *User) APActorKeyID() string {
27+
return u.APActorID() + "#main-key"
28+
}
29+
30+
func GetUserByFederatedURI(ctx context.Context, federatedURI string) (*User, error) {
31+
user := new(User)
32+
has, err := db.GetEngine(ctx).Where("normalized_federated_uri=?", federatedURI).Get(user)
33+
if err != nil {
34+
return nil, err
35+
} else if !has {
36+
return nil, nil
37+
}
38+
39+
if res, err := validation.IsValid(*user); !res {
40+
return nil, err
41+
}
42+
43+
return user, nil
44+
}

models/user/federated_user.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@
44
package user
55

66
import (
7+
"context"
8+
"database/sql"
9+
10+
"forgejo.org/models/db"
711
"forgejo.org/modules/validation"
812
)
913

1014
type FederatedUser struct {
11-
ID int64 `xorm:"pk autoincr"`
12-
UserID int64 `xorm:"NOT NULL"`
13-
ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
14-
FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
15+
ID int64 `xorm:"pk autoincr"`
16+
UserID int64 `xorm:"NOT NULL"`
17+
ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
18+
FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
19+
KeyID sql.NullString `xorm:"key_id UNIQUE"`
20+
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
1521
}
1622

1723
func NewFederatedUser(userID int64, externalID string, federationHostID int64) (FederatedUser, error) {
@@ -26,6 +32,30 @@ func NewFederatedUser(userID int64, externalID string, federationHostID int64) (
2632
return result, nil
2733
}
2834

35+
func getFederatedUserFromDB(ctx context.Context, searchKey, searchValue any) (*FederatedUser, error) {
36+
federatedUser := new(FederatedUser)
37+
has, err := db.GetEngine(ctx).Where(searchKey, searchValue).Get(federatedUser)
38+
if err != nil {
39+
return nil, err
40+
} else if !has {
41+
return nil, nil
42+
}
43+
44+
if res, err := validation.IsValid(*federatedUser); !res {
45+
return nil, err
46+
}
47+
48+
return federatedUser, nil
49+
}
50+
51+
func GetFederatedUserByKeyID(ctx context.Context, keyID string) (*FederatedUser, error) {
52+
return getFederatedUserFromDB(ctx, "key_id=?", keyID)
53+
}
54+
55+
func GetFederatedUserByUserID(ctx context.Context, userID int64) (*FederatedUser, error) {
56+
return getFederatedUserFromDB(ctx, "user_id=?", userID)
57+
}
58+
2959
func (user FederatedUser) Validate() []string {
3060
var result []string
3161
result = append(result, validation.ValidateNotEmpty(user.UserID, "UserID")...)

models/user/user.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -311,11 +311,6 @@ func (u *User) HTMLURL() string {
311311
return setting.AppURL + url.PathEscape(u.Name)
312312
}
313313

314-
// APActorID returns the IRI to the api endpoint of the user
315-
func (u *User) APActorID() string {
316-
return fmt.Sprintf("%vapi/v1/activitypub/user-id/%v", setting.AppURL, url.PathEscape(fmt.Sprintf("%v", u.ID)))
317-
}
318-
319314
// OrganisationLink returns the organization sub page link.
320315
func (u *User) OrganisationLink() string {
321316
return setting.AppSubURL + "/org/" + url.PathEscape(u.Name)

models/user/user_system.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,30 +73,30 @@ func (u *User) IsActions() bool {
7373
}
7474

7575
const (
76-
APActorUserID = -3
77-
APActorUserName = "actor"
78-
APActorEmail = "[email protected]"
76+
APServerActorUserID = -3
77+
APServerActorUserName = "actor"
78+
APServerActorEmail = "[email protected]"
7979
)
8080

81-
func NewAPActorUser() *User {
81+
func NewAPServerActor() *User {
8282
return &User{
83-
ID: APActorUserID,
84-
Name: APActorUserName,
85-
LowerName: APActorUserName,
83+
ID: APServerActorUserID,
84+
Name: APServerActorUserName,
85+
LowerName: APServerActorUserName,
8686
IsActive: true,
87-
Email: APActorEmail,
87+
Email: APServerActorEmail,
8888
KeepEmailPrivate: true,
89-
LoginName: APActorUserName,
89+
LoginName: APServerActorUserName,
9090
Type: UserTypeIndividual,
9191
Visibility: structs.VisibleTypePublic,
9292
}
9393
}
9494

95-
func APActorUserAPActorID() string {
95+
func APServerActorID() string {
9696
path, _ := url.JoinPath(setting.AppURL, "/api/v1/activitypub/actor")
9797
return path
9898
}
9999

100-
func (u *User) IsAPActor() bool {
101-
return u != nil && u.ID == APActorUserID
100+
func (u *User) IsAPServerActor() bool {
101+
return u != nil && u.ID == APServerActorUserID
102102
}

models/user/user_test.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,21 @@ func TestAPActorID(t *testing.T) {
139139
user := user_model.User{ID: 1}
140140
url := user.APActorID()
141141
expected := "https://try.gitea.io/api/v1/activitypub/user-id/1"
142-
if url != expected {
143-
t.Errorf("unexpected APActorID, expected: %q, actual: %q", expected, url)
144-
}
142+
assert.Equal(t, expected, url)
143+
}
144+
145+
func TestAPActorID_APActorID(t *testing.T) {
146+
user := user_model.User{ID: user_model.APServerActorUserID}
147+
url := user.APActorID()
148+
expected := "https://try.gitea.io/api/v1/activitypub/actor"
149+
assert.Equal(t, expected, url)
150+
}
151+
152+
func TestAPActorKeyID(t *testing.T) {
153+
user := user_model.User{ID: 1}
154+
url := user.APActorKeyID()
155+
expected := "https://try.gitea.io/api/v1/activitypub/user-id/1#main-key"
156+
assert.Equal(t, expected, url)
145157
}
146158

147159
func TestSearchUsers(t *testing.T) {

modules/activitypub/client.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,17 @@ func (c *Client) GetBody(uri string) ([]byte, error) {
191191
return nil, err
192192
}
193193
defer response.Body.Close()
194-
body, err := io.ReadAll(response.Body)
194+
if response.ContentLength > setting.Federation.MaxSize {
195+
return nil, fmt.Errorf("Request returned %d bytes (max allowed incomming size: %d bytes)", response.ContentLength, setting.Federation.MaxSize)
196+
} else if response.ContentLength == -1 {
197+
log.Warn("Request to %v returned an unknown content length, response may be truncated to %d bytes", uri, setting.Federation.MaxSize)
198+
}
199+
200+
body, err := io.ReadAll(io.LimitReader(response.Body, setting.Federation.MaxSize))
195201
if err != nil {
196202
return nil, err
197203
}
204+
198205
log.Debug("Client: got body: %v", charLimiter(string(body), 120))
199206
return body, nil
200207
}

0 commit comments

Comments
 (0)