Skip to content

Commit

Permalink
[bugfix] add stricter checks during all stages of dereferencing remot…
Browse files Browse the repository at this point in the history
…e AS objects (#2639)

* add stricter checks during all stages of dereferencing remote AS objects

* a comment
  • Loading branch information
NyaaaWhatsUpDoc committed Feb 14, 2024
1 parent 142b7ec commit 2bafd7d
Show file tree
Hide file tree
Showing 15 changed files with 345 additions and 162 deletions.
3 changes: 3 additions & 0 deletions cmd/gotosocial/action/testrig/testrig.go
Expand Up @@ -107,6 +107,9 @@ var Start action.GTSAction = func(ctx context.Context) error {
return &http.Response{
StatusCode: 200,
Body: r,
Header: http.Header{
"Content-Type": req.Header.Values("Accept"),
},
}, nil
}, ""))
mediaManager := testrig.NewTestMediaManager(&state)
Expand Down
114 changes: 113 additions & 1 deletion internal/api/util/mime.go
Expand Up @@ -17,18 +17,130 @@

package util

import "strings"

const (
// Possible GoToSocial mimetypes.
AppJSON = `application/json`
AppXML = `application/xml`
AppXMLXRD = `application/xrd+xml`
AppRSSXML = `application/rss+xml`
AppActivityJSON = `application/activity+json`
AppActivityLDJSON = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`
appActivityLDJSON = `application/ld+json` // without profile
AppActivityLDJSON = appActivityLDJSON + `; profile="https://www.w3.org/ns/activitystreams"`
AppJRDJSON = `application/jrd+json` // https://www.rfc-editor.org/rfc/rfc7033#section-10.2
AppForm = `application/x-www-form-urlencoded`
MultipartForm = `multipart/form-data`
TextXML = `text/xml`
TextHTML = `text/html`
TextCSS = `text/css`
)

// JSONContentType returns whether is application/json(;charset=utf-8)? content-type.
func JSONContentType(ct string) bool {
p := splitContentType(ct)
p, ok := isUTF8ContentType(p)
return ok && len(p) == 1 &&
p[0] == AppJSON
}

// JSONJRDContentType returns whether is application/(jrd+)?json(;charset=utf-8)? content-type.
func JSONJRDContentType(ct string) bool {
p := splitContentType(ct)
p, ok := isUTF8ContentType(p)
return ok && len(p) == 1 &&
p[0] == AppJSON ||
p[0] == AppJRDJSON
}

// XMLContentType returns whether is application/xml(;charset=utf-8)? content-type.
func XMLContentType(ct string) bool {
p := splitContentType(ct)
p, ok := isUTF8ContentType(p)
return ok && len(p) == 1 &&
p[0] == AppXML
}

// XMLXRDContentType returns whether is application/(xrd+)?xml(;charset=utf-8)? content-type.
func XMLXRDContentType(ct string) bool {
p := splitContentType(ct)
p, ok := isUTF8ContentType(p)
return ok && len(p) == 1 &&
p[0] == AppXML ||
p[0] == AppXMLXRD
}

// ASContentType returns whether is valid ActivityStreams content-types:
// - application/activity+json
// - application/ld+json;profile=https://w3.org/ns/activitystreams
func ASContentType(ct string) bool {
p := splitContentType(ct)
p, ok := isUTF8ContentType(p)
if !ok {
return false
}
switch len(p) {
case 1:
return p[0] == AppActivityJSON
case 2:
return p[0] == appActivityLDJSON &&
p[1] == "profile=https://www.w3.org/ns/activitystreams" ||
p[1] == "profile=\"https://www.w3.org/ns/activitystreams\""
default:
return false
}
}

// NodeInfo2ContentType returns whether is nodeinfo schema 2.0 content-type.
func NodeInfo2ContentType(ct string) bool {
p := splitContentType(ct)
p, ok := isUTF8ContentType(p)
if !ok {
return false
}
switch len(p) {
case 1:
return p[0] == AppJSON
case 2:
return p[0] == AppJSON &&
p[1] == "profile=\"http://nodeinfo.diaspora.software/ns/schema/2.0#\"" ||
p[1] == "profile=http://nodeinfo.diaspora.software/ns/schema/2.0#"
default:
return false
}
}

// isUTF8ContentType checks for a provided charset in given
// type parts list, removes it and returns whether is utf-8.
func isUTF8ContentType(p []string) ([]string, bool) {
const charset = "charset="
const charsetUTF8 = charset + "utf-8"
for i, part := range p {

// Only handle charset slice parts.
if part[:len(charset)] == charset {

// Check if is UTF-8 charset.
ok := (part == charsetUTF8)

// Drop this slice part.
_ = copy(p[i:], p[i+1:])
p = p[:len(p)-1]

return p, ok
}
}
return p, true
}

// splitContentType splits content-type into semi-colon
// separated parts. useful when a charset is provided.
// note this also maps all chars to their lowercase form.
func splitContentType(ct string) []string {
s := strings.Split(ct, ";")
for i := range s {
s[i] = strings.TrimSpace(s[i])
s[i] = strings.ToLower(s[i])
}
return s
}
75 changes: 75 additions & 0 deletions internal/api/util/mime_test.go
@@ -0,0 +1,75 @@
package util_test

import (
"testing"

"github.com/superseriousbusiness/gotosocial/internal/api/util"
)

func TestIsASContentType(t *testing.T) {
for _, test := range []struct {
Input string
Expect bool
}{
{
Input: "application/activity+json",
Expect: true,
},
{
Input: "application/activity+json; charset=utf-8",
Expect: true,
},
{
Input: "application/activity+json;charset=utf-8",
Expect: true,
},
{
Input: "application/activity+json ;charset=utf-8",
Expect: true,
},
{
Input: "application/activity+json ; charset=utf-8",
Expect: true,
},
{
Input: "application/ld+json;profile=https://www.w3.org/ns/activitystreams",
Expect: true,
},
{
Input: "application/ld+json;profile=\"https://www.w3.org/ns/activitystreams\"",
Expect: true,
},
{
Input: "application/ld+json ;profile=https://www.w3.org/ns/activitystreams",
Expect: true,
},
{
Input: "application/ld+json ;profile=\"https://www.w3.org/ns/activitystreams\"",
Expect: true,
},
{
Input: "application/ld+json ; profile=https://www.w3.org/ns/activitystreams",
Expect: true,
},
{
Input: "application/ld+json ; profile=\"https://www.w3.org/ns/activitystreams\"",
Expect: true,
},
{
Input: "application/ld+json; profile=https://www.w3.org/ns/activitystreams",
Expect: true,
},
{
Input: "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
Expect: true,
},
{
Input: "application/ld+json",
Expect: false,
},
} {
if util.ASContentType(test.Input) != test.Expect {
t.Errorf("did not get expected result %v for input: %s", test.Expect, test.Input)
}
}
}
47 changes: 26 additions & 21 deletions internal/federation/dereferencing/account.go
Expand Up @@ -606,6 +606,16 @@ func (d *Dereferencer) enrichAccount(
}

if account.Username == "" {
// Assume the host from the
// ActivityPub representation.
id := ap.GetJSONLDId(apubAcc)
if id == nil {
return nil, nil, gtserror.New("no id property found on person, or id was not an iri")
}

// Get IRI host value.
accHost := id.Host

// No username was provided, so no webfinger was attempted earlier.
//
// Now we have a username we can attempt again, to ensure up-to-date
Expand All @@ -616,42 +626,37 @@ func (d *Dereferencer) enrichAccount(
// https://example.org/@someone@somewhere.else and we've been redirected
// from example.org to somewhere.else: we want to take somewhere.else
// as the accountDomain then, not the example.org we were redirected from.

// Assume the host from the returned
// ActivityPub representation.
id := ap.GetJSONLDId(apubAcc)
if id == nil {
return nil, nil, gtserror.New("no id property found on person, or id was not an iri")
}

// Get IRI host value.
accHost := id.Host

latestAcc.Domain, _, err = d.fingerRemoteAccount(ctx,
tsport,
latestAcc.Username,
accHost,
)
if err != nil {
// We still couldn't webfinger the account, so we're not certain
// what the accountDomain actually is. Still, we can make a solid
// guess that it's the Host of the ActivityPub URI of the account.
// If we're wrong, we can just try again in a couple days.
log.Errorf(ctx, "error webfingering[2] remote account %s@%s: %v", latestAcc.Username, accHost, err)
latestAcc.Domain = accHost
// Webfingering account still failed, so we're not certain
// what the accountDomain actually is. Exit here for safety.
return nil, nil, gtserror.Newf(
"error webfingering remote account %s@%s: %w",
latestAcc.Username, accHost, err,
)
}
}

if latestAcc.Domain == "" {
// Ensure we have a domain set by this point,
// otherwise it gets stored as a local user!
//
// TODO: there is probably a more granular way
// way of checking this in each of the above parts,
// and honestly it could do with a smol refactor.
return nil, nil, gtserror.Newf("empty domain for %s", uri)
}

// Ensure the final parsed account URI / URL matches
// the input URI we fetched (or received) it as.
if expect := uri.String(); latestAcc.URI != expect &&
latestAcc.URL != expect {
return nil, nil, gtserror.Newf(
"dereferenced account uri %s does not match %s",
latestAcc.URI, expect,
)
}

/*
BY THIS POINT we have more or less a fullly-formed
representation of the target account, derived from
Expand Down
23 changes: 23 additions & 0 deletions internal/federation/dereferencing/account_test.go
Expand Up @@ -19,6 +19,7 @@ package dereferencing_test

import (
"context"
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -207,6 +208,28 @@ func (suite *AccountTestSuite) TestDereferenceLocalAccountWithUnknownUserURI() {
suite.Nil(fetchedAccount)
}

func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithNonMatchingURI() {
fetchingAccount := suite.testAccounts["local_account_1"]

const (
remoteURI = "https://turnip.farm/users/turniplover6969"
remoteAltURI = "https://turnip.farm/users/turniphater420"
)

// Create a copy of this remote account at alternative URI.
remotePerson := suite.client.TestRemotePeople[remoteURI]
suite.client.TestRemotePeople[remoteAltURI] = remotePerson

// Attempt to fetch account at alternative URI, it should fail!
fetchedAccount, _, err := suite.dereferencer.GetAccountByURI(
context.Background(),
fetchingAccount.Username,
testrig.URLMustParse(remoteAltURI),
)
suite.Equal(err.Error(), fmt.Sprintf("enrichAccount: dereferenced account uri %s does not match %s", remoteURI, remoteAltURI))
suite.Nil(fetchedAccount)
}

func TestAccountTestSuite(t *testing.T) {
suite.Run(t, new(AccountTestSuite))
}
4 changes: 3 additions & 1 deletion internal/federation/dereferencing/dereferencer_test.go
Expand Up @@ -35,6 +35,7 @@ type DereferencerStandardTestSuite struct {
db db.DB
storage *storage.Driver
state state.State
client *testrig.MockHTTPClient

testRemoteStatuses map[string]vocab.ActivityStreamsNote
testRemotePeople map[string]vocab.ActivityStreamsPerson
Expand Down Expand Up @@ -72,11 +73,12 @@ func (suite *DereferencerStandardTestSuite) SetupTest() {
converter,
)

suite.client = testrig.NewMockHTTPClient(nil, "../../../testrig/media")
suite.storage = testrig.NewInMemoryStorage()
suite.state.DB = suite.db
suite.state.Storage = suite.storage
media := testrig.NewTestMediaManager(&suite.state)
suite.dereferencer = dereferencing.NewDereferencer(&suite.state, converter, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")), media)
suite.dereferencer = dereferencing.NewDereferencer(&suite.state, converter, testrig.NewTestTransportController(&suite.state, suite.client), media)
testrig.StandardDBSetup(suite.db, nil)
}

Expand Down
11 changes: 6 additions & 5 deletions internal/federation/dereferencing/finger.go
Expand Up @@ -21,9 +21,9 @@ import (
"context"
"encoding/json"
"net/url"
"strings"

apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/transport"
Expand Down Expand Up @@ -74,10 +74,12 @@ func (d *Dereferencer) fingerRemoteAccount(
return "", nil, err
}

_, accountDomain, err := util.ExtractWebfingerParts(resp.Subject)
accUsername, accDomain, err := util.ExtractWebfingerParts(resp.Subject)
if err != nil {
err = gtserror.Newf("error extracting subject parts for %s: %w", target, err)
return "", nil, err
} else if accUsername != username {
return "", nil, gtserror.Newf("response username does not match input for %s: %w", target, err)
}

// Look through links for the first
Expand All @@ -92,8 +94,7 @@ func (d *Dereferencer) fingerRemoteAccount(
continue
}

if !strings.EqualFold(link.Type, "application/activity+json") &&
!strings.EqualFold(link.Type, "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") {
if !apiutil.ASContentType(link.Type) {
// Not an AP type, ignore.
continue
}
Expand Down Expand Up @@ -121,7 +122,7 @@ func (d *Dereferencer) fingerRemoteAccount(
}

// All looks good, return happily!
return accountDomain, uri, nil
return accDomain, uri, nil
}

return "", nil, gtserror.Newf("no suitable self, AP-type link found in webfinger response for %s", target)
Expand Down

0 comments on commit 2bafd7d

Please sign in to comment.