From 5027d0ced25f06c12208cd618cfbb83518610d79 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Thu, 4 May 2023 12:28:50 +0200 Subject: [PATCH] [bugfix] Serve correct 'application/jrd+json' content type for webfinger requests (#1738) * [bugfix] Return `application/jrd+json` from webfinger queries * update finger req content-type --- docs/api/swagger.yaml | 2 +- internal/api/util/mime.go | 2 +- internal/api/util/negotiate.go | 9 + .../api/wellknown/webfinger/webfinger_test.go | 36 --- .../api/wellknown/webfinger/webfingerget.go | 15 +- .../wellknown/webfinger/webfingerget_test.go | 221 ++++++++---------- internal/transport/finger.go | 4 +- 7 files changed, 128 insertions(+), 161 deletions(-) diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 540a7dbc3..25527439d 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2582,7 +2582,7 @@ paths: See: https://webfinger.net/ operationId: webfingerGet produces: - - application/json + - application/jrd+json responses: "200": description: "" diff --git a/internal/api/util/mime.go b/internal/api/util/mime.go index 89aa23741..edd0dcecf 100644 --- a/internal/api/util/mime.go +++ b/internal/api/util/mime.go @@ -20,7 +20,6 @@ package util // MIME represents a mime-type. type MIME string -// MIME type const ( AppJSON MIME = `application/json` AppXML MIME = `application/xml` @@ -28,6 +27,7 @@ const ( AppRSSXML MIME = `application/rss+xml` AppActivityJSON MIME = `application/activity+json` AppActivityLDJSON MIME = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` + AppJRDJSON MIME = `application/jrd+json` // https://www.rfc-editor.org/rfc/rfc7033#section-10.2 AppForm MIME = `application/x-www-form-urlencoded` MultipartForm MIME = `multipart/form-data` TextXML MIME = `text/xml` diff --git a/internal/api/util/negotiate.go b/internal/api/util/negotiate.go index 0889e0346..1a4df7c40 100644 --- a/internal/api/util/negotiate.go +++ b/internal/api/util/negotiate.go @@ -35,6 +35,15 @@ var JSONAcceptHeaders = []MIME{ AppJSON, } +// WebfingerJSONAcceptHeaders is a slice of offers that prefers the +// jrd+json content type, but will be chill and fall back to app/json. +// This is to be used specifically for webfinger responses. +// See https://www.rfc-editor.org/rfc/rfc7033#section-10.2 +var WebfingerJSONAcceptHeaders = []MIME{ + AppJRDJSON, + AppJSON, +} + // HTMLOrJSONAcceptHeaders is a slice of offers that prefers TextHTML and will // fall back to JSON if necessary. This is useful for error handling, since it can // be used to serve a nice HTML page if the caller accepts that, or just JSON if not. diff --git a/internal/api/wellknown/webfinger/webfinger_test.go b/internal/api/wellknown/webfinger/webfinger_test.go index 1d7902708..26143942c 100644 --- a/internal/api/wellknown/webfinger/webfinger_test.go +++ b/internal/api/wellknown/webfinger/webfinger_test.go @@ -18,12 +18,7 @@ package webfinger_test import ( - "crypto/rand" - "crypto/rsa" - "time" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/api/wellknown/webfinger" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" @@ -103,34 +98,3 @@ func (suite *WebfingerStandardTestSuite) TearDownTest() { testrig.StandardStorageTeardown(suite.storage) testrig.StopWorkers(&suite.state) } - -func accountDomainAccount() *gtsmodel.Account { - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - panic(err) - } - publicKey := &privateKey.PublicKey - - acct := >smodel.Account{ - ID: "01FG1K8EA7SYHEC7V6XKVNC4ZA", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Username: "aaaaa", - Domain: "", - Privacy: gtsmodel.VisibilityDefault, - Language: "en", - URI: "http://gts.example.org/users/aaaaa", - URL: "http://gts.example.org/@aaaaa", - InboxURI: "http://gts.example.org/users/aaaaa/inbox", - OutboxURI: "http://gts.example.org/users/aaaaa/outbox", - FollowingURI: "http://gts.example.org/users/aaaaa/following", - FollowersURI: "http://gts.example.org/users/aaaaa/followers", - FeaturedCollectionURI: "http://gts.example.org/users/aaaaa/collections/featured", - ActorType: ap.ActorPerson, - PrivateKey: privateKey, - PublicKey: publicKey, - PublicKeyURI: "http://gts.example.org/users/aaaaa/main-key", - } - - return acct -} diff --git a/internal/api/wellknown/webfinger/webfingerget.go b/internal/api/wellknown/webfinger/webfingerget.go index 5cd0a7a35..02f366ea6 100644 --- a/internal/api/wellknown/webfinger/webfingerget.go +++ b/internal/api/wellknown/webfinger/webfingerget.go @@ -18,6 +18,7 @@ package webfinger import ( + "encoding/json" "errors" "fmt" "net/http" @@ -48,14 +49,14 @@ import ( // - .well-known // // produces: -// - application/json +// - application/jrd+json // // responses: // '200': // schema: // "$ref": "#/definitions/wellKnownResponse" func (m *Module) WebfingerGETRequest(c *gin.Context) { - if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + if _, err := apiutil.NegotiateAccept(c, apiutil.WebfingerJSONAcceptHeaders...); err != nil { apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) return } @@ -86,5 +87,13 @@ func (m *Module) WebfingerGETRequest(c *gin.Context) { return } - c.JSON(http.StatusOK, resp) + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) + return + } + + // Always return "application/jrd+json" regardless of negotiated + // format. See https://www.rfc-editor.org/rfc/rfc7033#section-10.2 + c.Data(http.StatusOK, string(apiutil.AppJRDJSON), b) } diff --git a/internal/api/wellknown/webfinger/webfingerget_test.go b/internal/api/wellknown/webfinger/webfingerget_test.go index 36244dee0..5b1cc47dd 100644 --- a/internal/api/wellknown/webfinger/webfingerget_test.go +++ b/internal/api/wellknown/webfinger/webfingerget_test.go @@ -20,16 +20,21 @@ package webfinger_test import ( "bytes" "context" + "crypto/rand" + "crypto/rsa" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/ap" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/api/wellknown/webfinger" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -38,31 +43,85 @@ type WebfingerGetTestSuite struct { WebfingerStandardTestSuite } -func (suite *WebfingerGetTestSuite) TestFingerUser() { - targetAccount := suite.testAccounts["local_account_1"] - - // setup request - host := config.GetHost() - requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, host) - +func (suite *WebfingerGetTestSuite) finger(requestPath string) string { + // Set up the request. recorder := httptest.NewRecorder() ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") + ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) + ctx.Request.Header.Set("accept", "application/jrd+json") - // trigger the function being tested + // Trigger the handler. suite.webfingerModule.WebfingerGETRequest(ctx) - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - + // Read the result + return it + // as nicely indented JSON. result := recorder.Result() defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - dst := new(bytes.Buffer) - err = json.Indent(dst, b, "", " ") - suite.NoError(err) + + // Result should always use the + // webfinger content-type. + if ct := result.Header.Get("content-type"); ct != string(apiutil.AppJRDJSON) { + suite.FailNow("", "expected content type %s, got %s", apiutil.AppJRDJSON, ct) + } + + b, err := io.ReadAll(result.Body) + if err != nil { + suite.FailNow(err.Error()) + } + + var dst bytes.Buffer + if err := json.Indent(&dst, b, "", " "); err != nil { + suite.FailNow(err.Error()) + } + + return dst.String() +} + +func (suite *WebfingerGetTestSuite) funkifyAccountDomain(host string, accountDomain string) *gtsmodel.Account { + // Reset suite structs + config + // to new host + account domain. + config.SetHost(host) + config.SetAccountDomain(accountDomain) + suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaManager(&suite.state), &suite.state, suite.emailSender) + suite.webfingerModule = webfinger.New(suite.processor) + + // Generate a new account for the + // tester, which uses the new host. + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + publicKey := &privateKey.PublicKey + + targetAccount := >smodel.Account{ + ID: "01FG1K8EA7SYHEC7V6XKVNC4ZA", + Username: "new_account_domain_user", + Privacy: gtsmodel.VisibilityDefault, + URI: "http://" + host + "/users/new_account_domain_user", + URL: "http://" + host + "/@new_account_domain_user", + InboxURI: "http://" + host + "/users/new_account_domain_user/inbox", + OutboxURI: "http://" + host + "/users/new_account_domain_user/outbox", + FollowingURI: "http://" + host + "/users/new_account_domain_user/following", + FollowersURI: "http://" + host + "/users/new_account_domain_user/followers", + FeaturedCollectionURI: "http://" + host + "/users/new_account_domain_user/collections/featured", + ActorType: ap.ActorPerson, + PrivateKey: privateKey, + PublicKey: publicKey, + PublicKeyURI: "http://" + host + "/users/new_account_domain_user/main-key", + } + + if err := suite.db.PutAccount(context.Background(), targetAccount); err != nil { + suite.FailNow(err.Error()) + } + + return targetAccount +} + +func (suite *WebfingerGetTestSuite) TestFingerUser() { + targetAccount := suite.testAccounts["local_account_1"] + requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, config.GetHost()) + + resp := suite.finger(requestPath) suite.Equal(`{ "subject": "acct:the_mighty_zork@localhost:8080", "aliases": [ @@ -81,144 +140,68 @@ func (suite *WebfingerGetTestSuite) TestFingerUser() { "href": "http://localhost:8080/users/the_mighty_zork" } ] -}`, dst.String()) +}`, resp) } func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHost() { - config.SetHost("gts.example.org") - config.SetAccountDomain("example.org") + targetAccount := suite.funkifyAccountDomain("gts.example.org", "example.org") + requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, config.GetHost()) - suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaManager(&suite.state), &suite.state, suite.emailSender) - suite.webfingerModule = webfinger.New(suite.processor) - - targetAccount := accountDomainAccount() - if err := suite.db.Put(context.Background(), targetAccount); err != nil { - panic(err) - } - - // setup request - host := config.GetHost() - requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, host) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // trigger the function being tested - suite.webfingerModule.WebfingerGETRequest(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - dst := new(bytes.Buffer) - err = json.Indent(dst, b, "", " ") - suite.NoError(err) + resp := suite.finger(requestPath) suite.Equal(`{ - "subject": "acct:aaaaa@example.org", + "subject": "acct:new_account_domain_user@example.org", "aliases": [ - "http://gts.example.org/users/aaaaa", - "http://gts.example.org/@aaaaa" + "http://gts.example.org/users/new_account_domain_user", + "http://gts.example.org/@new_account_domain_user" ], "links": [ { "rel": "http://webfinger.net/rel/profile-page", "type": "text/html", - "href": "http://gts.example.org/@aaaaa" + "href": "http://gts.example.org/@new_account_domain_user" }, { "rel": "self", "type": "application/activity+json", - "href": "http://gts.example.org/users/aaaaa" + "href": "http://gts.example.org/users/new_account_domain_user" } ] -}`, dst.String()) +}`, resp) } func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByAccountDomain() { - config.SetHost("gts.example.org") - config.SetAccountDomain("example.org") + targetAccount := suite.funkifyAccountDomain("gts.example.org", "example.org") + requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, config.GetAccountDomain()) - suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaManager(&suite.state), &suite.state, suite.emailSender) - suite.webfingerModule = webfinger.New(suite.processor) - - targetAccount := accountDomainAccount() - if err := suite.db.Put(context.Background(), targetAccount); err != nil { - panic(err) - } - - // setup request - accountDomain := config.GetAccountDomain() - requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, accountDomain) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // trigger the function being tested - suite.webfingerModule.WebfingerGETRequest(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - dst := new(bytes.Buffer) - err = json.Indent(dst, b, "", " ") - suite.NoError(err) + resp := suite.finger(requestPath) suite.Equal(`{ - "subject": "acct:aaaaa@example.org", + "subject": "acct:new_account_domain_user@example.org", "aliases": [ - "http://gts.example.org/users/aaaaa", - "http://gts.example.org/@aaaaa" + "http://gts.example.org/users/new_account_domain_user", + "http://gts.example.org/@new_account_domain_user" ], "links": [ { "rel": "http://webfinger.net/rel/profile-page", "type": "text/html", - "href": "http://gts.example.org/@aaaaa" + "href": "http://gts.example.org/@new_account_domain_user" }, { "rel": "self", "type": "application/activity+json", - "href": "http://gts.example.org/users/aaaaa" + "href": "http://gts.example.org/users/new_account_domain_user" } ] -}`, dst.String()) +}`, resp) } func (suite *WebfingerGetTestSuite) TestFingerUserWithoutAcct() { + // Leave out the 'acct:' part in the request path; + // the handler should be generous + still work OK. targetAccount := suite.testAccounts["local_account_1"] + requestPath := fmt.Sprintf("/%s?resource=%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, config.GetHost()) - // setup request -- leave out the 'acct:' prefix, which is prettymuch what pixelfed currently does - host := config.GetHost() - requestPath := fmt.Sprintf("/%s?resource=%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, host) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // trigger the function being tested - suite.webfingerModule.WebfingerGETRequest(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - dst := new(bytes.Buffer) - err = json.Indent(dst, b, "", " ") - suite.NoError(err) + resp := suite.finger(requestPath) suite.Equal(`{ "subject": "acct:the_mighty_zork@localhost:8080", "aliases": [ @@ -237,7 +220,7 @@ func (suite *WebfingerGetTestSuite) TestFingerUserWithoutAcct() { "href": "http://localhost:8080/users/the_mighty_zork" } ] -}`, dst.String()) +}`, resp) } func TestWebfingerGetTestSuite(t *testing.T) { diff --git a/internal/transport/finger.go b/internal/transport/finger.go index 4c3cacd7d..f106019b5 100644 --- a/internal/transport/finger.go +++ b/internal/transport/finger.go @@ -59,8 +59,10 @@ func prepWebfingerReq(ctx context.Context, loc, domain, username string) (*http. value := url.QueryEscape("acct:" + username + "@" + domain) req.URL.RawQuery = "resource=" + value + // Prefer application/jrd+json, fall back to application/json. + // See https://www.rfc-editor.org/rfc/rfc7033#section-10.2. + req.Header.Add("Accept", string(apiutil.AppJRDJSON)) req.Header.Add("Accept", string(apiutil.AppJSON)) - req.Header.Add("Accept", "application/jrd+json") req.Header.Set("Host", req.URL.Host) return req, nil