mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[chore] The Big Middleware and API Refactor (tm) (#1250)
* interim commit: start refactoring middlewares into package under router * another interim commit, this is becoming a big job * another fucking massive interim commit * refactor bookmarks to new style * ambassador, wiz zeze commits you are spoiling uz * she compiles, we're getting there * we're just normal men; we're just innocent men * apiutil * whoopsie * i'm glad noone reads commit msgs haha :blob_sweat: * use that weirdo go-bytesize library for maxMultipartMemory * fix media module paths
This commit is contained in:
35
internal/middleware/cachecontrol.go
Normal file
35
internal/middleware/cachecontrol.go
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CacheControl returns a new gin middleware which allows callers to control cache settings on response headers.
|
||||
//
|
||||
// For directives, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
|
||||
func CacheControl(directives ...string) gin.HandlerFunc {
|
||||
ccHeader := strings.Join(directives, ", ")
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", ccHeader)
|
||||
}
|
||||
}
|
86
internal/middleware/cors.go
Normal file
86
internal/middleware/cors.go
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS returns a new gin middleware which allows CORS requests to be processed.
|
||||
// This is necessary in order for web/browser-based clients like Pinafore to work.
|
||||
func CORS() gin.HandlerFunc {
|
||||
cfg := cors.Config{
|
||||
// todo: use config to customize this
|
||||
AllowAllOrigins: true,
|
||||
|
||||
// adds the following:
|
||||
// "chrome-extension://"
|
||||
// "safari-extension://"
|
||||
// "moz-extension://"
|
||||
// "ms-browser-extension://"
|
||||
AllowBrowserExtensions: true,
|
||||
AllowMethods: []string{
|
||||
"POST",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"GET",
|
||||
"PATCH",
|
||||
"OPTIONS",
|
||||
},
|
||||
AllowHeaders: []string{
|
||||
// basic cors stuff
|
||||
"Origin",
|
||||
"Content-Length",
|
||||
"Content-Type",
|
||||
|
||||
// needed to pass oauth bearer tokens
|
||||
"Authorization",
|
||||
|
||||
// needed for websocket upgrade requests
|
||||
"Upgrade",
|
||||
"Sec-WebSocket-Extensions",
|
||||
"Sec-WebSocket-Key",
|
||||
"Sec-WebSocket-Protocol",
|
||||
"Sec-WebSocket-Version",
|
||||
"Connection",
|
||||
},
|
||||
AllowWebSockets: true,
|
||||
ExposeHeaders: []string{
|
||||
// needed for accessing next/prev links when making GET timeline requests
|
||||
"Link",
|
||||
|
||||
// needed so clients can handle rate limits
|
||||
"X-RateLimit-Reset",
|
||||
"X-RateLimit-Limit",
|
||||
"X-RateLimit-Remaining",
|
||||
"X-Request-Id",
|
||||
|
||||
// websocket stuff
|
||||
"Connection",
|
||||
"Sec-WebSocket-Accept",
|
||||
"Upgrade",
|
||||
},
|
||||
MaxAge: 2 * time.Minute,
|
||||
}
|
||||
|
||||
return cors.New(cfg)
|
||||
}
|
37
internal/middleware/extraheaders.go
Normal file
37
internal/middleware/extraheaders.go
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// ExtraHeaders returns a new gin middleware which adds various extra headers to the response.
|
||||
func ExtraHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Inform all callers which server implementation this is.
|
||||
c.Header("Server", "gotosocial")
|
||||
// Prevent google chrome cohort tracking. Originally this was referred
|
||||
// to as FlocBlock. Floc was replaced by Topics in 2022 and the spec says
|
||||
// that interest-cohort will also block Topics (as of 2022-Nov).
|
||||
//
|
||||
// See: https://smartframe.io/blog/google-topics-api-everything-you-need-to-know
|
||||
//
|
||||
// See: https://github.com/patcg-individual-drafts/topics
|
||||
c.Header("Permissions-Policy", "browsing-topics=()")
|
||||
}
|
||||
}
|
30
internal/middleware/gzip.go
Normal file
30
internal/middleware/gzip.go
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
ginGzip "github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Gzip returns a gzip gin middleware using default compression.
|
||||
func Gzip() gin.HandlerFunc {
|
||||
// todo: make this configurable
|
||||
return ginGzip.Gzip(ginGzip.DefaultCompression)
|
||||
}
|
99
internal/middleware/logger.go
Normal file
99
internal/middleware/logger.go
Normal file
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-bytesize"
|
||||
"codeberg.org/gruf/go-errors/v2"
|
||||
"codeberg.org/gruf/go-kv"
|
||||
"codeberg.org/gruf/go-logger/v2/level"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
// Logger returns a gin middleware which provides request logging and panic recovery.
|
||||
func Logger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Initialize the logging fields
|
||||
fields := make(kv.Fields, 6, 7)
|
||||
|
||||
// Determine pre-handler time
|
||||
before := time.Now()
|
||||
|
||||
// defer so that we log *after the request has completed*
|
||||
defer func() {
|
||||
code := c.Writer.Status()
|
||||
path := c.Request.URL.Path
|
||||
|
||||
if r := recover(); r != nil {
|
||||
if c.Writer.Status() == 0 {
|
||||
// No response was written, send a generic Internal Error
|
||||
c.Writer.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Append panic information to the request ctx
|
||||
err := fmt.Errorf("recovered panic: %v", r)
|
||||
_ = c.Error(err)
|
||||
|
||||
// Dump a stacktrace to error log
|
||||
callers := errors.GetCallers(3, 10)
|
||||
log.WithField("stacktrace", callers).Error(err)
|
||||
}
|
||||
|
||||
// NOTE:
|
||||
// It is very important here that we are ONLY logging
|
||||
// the request path, and none of the query parameters.
|
||||
// Query parameters can contain sensitive information
|
||||
// and could lead to storing plaintext API keys in logs
|
||||
|
||||
// Set request logging fields
|
||||
fields[0] = kv.Field{"latency", time.Since(before)}
|
||||
fields[1] = kv.Field{"clientIP", c.ClientIP()}
|
||||
fields[2] = kv.Field{"userAgent", c.Request.UserAgent()}
|
||||
fields[3] = kv.Field{"method", c.Request.Method}
|
||||
fields[4] = kv.Field{"statusCode", code}
|
||||
fields[5] = kv.Field{"path", path}
|
||||
|
||||
// Create log entry with fields
|
||||
l := log.WithFields(fields...)
|
||||
|
||||
// Default is info
|
||||
lvl := level.INFO
|
||||
|
||||
if code >= 500 {
|
||||
// This is a server error
|
||||
lvl = level.ERROR
|
||||
l = l.WithField("error", c.Errors)
|
||||
}
|
||||
|
||||
// Generate a nicer looking bytecount
|
||||
size := bytesize.Size(c.Writer.Size())
|
||||
|
||||
// Finally, write log entry with status text body size
|
||||
l.Logf(lvl, "%s: wrote %s", http.StatusText(code), size)
|
||||
}()
|
||||
|
||||
// Process request
|
||||
c.Next()
|
||||
}
|
||||
}
|
77
internal/middleware/ratelimit.go
Normal file
77
internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/ulule/limiter/v3"
|
||||
limitergin "github.com/ulule/limiter/v3/drivers/middleware/gin"
|
||||
"github.com/ulule/limiter/v3/drivers/store/memory"
|
||||
)
|
||||
|
||||
const rateLimitPeriod = 5 * time.Minute
|
||||
|
||||
// RateLimit returns a gin middleware that will automatically rate limit caller (by IP address),
|
||||
// and enrich the response header with the following headers:
|
||||
//
|
||||
// - `x-ratelimit-limit` - maximum number of requests allowed per time period (fixed).
|
||||
// - `x-ratelimit-remaining` - number of remaining requests that can still be performed.
|
||||
// - `x-ratelimit-reset` - unix timestamp when the rate limit will reset.
|
||||
//
|
||||
// If `x-ratelimit-limit` is exceeded, the request is aborted and an HTTP 429 TooManyRequests
|
||||
// status is returned.
|
||||
//
|
||||
// If the config AdvancedRateLimitRequests value is <= 0, then a noop handler will be returned,
|
||||
// which performs no rate limiting.
|
||||
func RateLimit() gin.HandlerFunc {
|
||||
// only enable rate limit middleware if configured
|
||||
// advanced-rate-limit-requests is greater than 0
|
||||
rateLimitRequests := config.GetAdvancedRateLimitRequests()
|
||||
if rateLimitRequests <= 0 {
|
||||
// use noop middleware if ratelimiting is disabled
|
||||
return func(c *gin.Context) {}
|
||||
}
|
||||
|
||||
rate := limiter.Rate{
|
||||
Period: rateLimitPeriod,
|
||||
Limit: int64(rateLimitRequests),
|
||||
}
|
||||
|
||||
limiterInstance := limiter.New(
|
||||
memory.NewStore(),
|
||||
rate,
|
||||
limiter.WithIPv6Mask(net.CIDRMask(64, 128)), // apply /64 mask to IPv6 addresses
|
||||
)
|
||||
|
||||
limitReachedHandler := func(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit reached"})
|
||||
}
|
||||
|
||||
middleware := limitergin.NewMiddleware(
|
||||
limiterInstance,
|
||||
limitergin.WithLimitReachedHandler(limitReachedHandler), // use custom rate limit reached error
|
||||
)
|
||||
|
||||
return middleware
|
||||
}
|
95
internal/middleware/session.go
Normal file
95
internal/middleware/session.go
Normal file
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/memstore"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
// SessionOptions returns the standard set of options to use for each session.
|
||||
func SessionOptions() sessions.Options {
|
||||
var samesite http.SameSite
|
||||
switch strings.TrimSpace(strings.ToLower(config.GetAdvancedCookiesSamesite())) {
|
||||
case "lax":
|
||||
samesite = http.SameSiteLaxMode
|
||||
case "strict":
|
||||
samesite = http.SameSiteStrictMode
|
||||
default:
|
||||
log.Warnf("%s set to %s which is not recognized, defaulting to 'lax'", config.AdvancedCookiesSamesiteFlag(), config.GetAdvancedCookiesSamesite())
|
||||
samesite = http.SameSiteLaxMode
|
||||
}
|
||||
|
||||
return sessions.Options{
|
||||
Path: "/",
|
||||
Domain: config.GetHost(),
|
||||
// 2 minutes
|
||||
MaxAge: 120,
|
||||
// only set secure over https
|
||||
Secure: config.GetProtocol() == "https",
|
||||
// forbid javascript from inspecting cookie
|
||||
HttpOnly: true,
|
||||
// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-same-site-00#section-4.1.1
|
||||
SameSite: samesite,
|
||||
}
|
||||
}
|
||||
|
||||
// SessionName is a utility function that derives an appropriate session name from the hostname.
|
||||
func SessionName() (string, error) {
|
||||
// parse the protocol + host
|
||||
protocol := config.GetProtocol()
|
||||
host := config.GetHost()
|
||||
u, err := url.Parse(fmt.Sprintf("%s://%s", protocol, host))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// take the hostname without any port attached
|
||||
strippedHostname := u.Hostname()
|
||||
if strippedHostname == "" {
|
||||
return "", fmt.Errorf("could not derive hostname without port from %s://%s", protocol, host)
|
||||
}
|
||||
|
||||
// make sure IDNs are converted to punycode or the cookie library breaks:
|
||||
// see https://en.wikipedia.org/wiki/Punycode
|
||||
punyHostname, err := idna.New().ToASCII(strippedHostname)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not convert %s to punycode: %s", strippedHostname, err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("gotosocial-%s", punyHostname), nil
|
||||
}
|
||||
|
||||
// Session returns a new gin middleware that implements session cookies using the given
|
||||
// sessionName, authentication key, and encryption key. Session name can be derived from the
|
||||
// SessionName utility function in this package.
|
||||
func Session(sessionName string, auth []byte, crypt []byte) gin.HandlerFunc {
|
||||
store := memstore.NewStore(auth, crypt)
|
||||
store.Options(SessionOptions())
|
||||
return sessions.Sessions(sessionName, store)
|
||||
}
|
95
internal/middleware/session_test.go
Normal file
95
internal/middleware/session_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/middleware"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type SessionTestSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (suite *SessionTestSuite) SetupTest() {
|
||||
testrig.InitTestConfig()
|
||||
}
|
||||
|
||||
func (suite *SessionTestSuite) TestDeriveSessionNameLocalhostWithPort() {
|
||||
config.SetProtocol("http")
|
||||
config.SetHost("localhost:8080")
|
||||
|
||||
sessionName, err := middleware.SessionName()
|
||||
suite.NoError(err)
|
||||
suite.Equal("gotosocial-localhost", sessionName)
|
||||
}
|
||||
|
||||
func (suite *SessionTestSuite) TestDeriveSessionNameLocalhost() {
|
||||
config.SetProtocol("http")
|
||||
config.SetHost("localhost")
|
||||
|
||||
sessionName, err := middleware.SessionName()
|
||||
suite.NoError(err)
|
||||
suite.Equal("gotosocial-localhost", sessionName)
|
||||
}
|
||||
|
||||
func (suite *SessionTestSuite) TestDeriveSessionNoProtocol() {
|
||||
config.SetProtocol("")
|
||||
config.SetHost("localhost")
|
||||
|
||||
sessionName, err := middleware.SessionName()
|
||||
suite.EqualError(err, "parse \"://localhost\": missing protocol scheme")
|
||||
suite.Equal("", sessionName)
|
||||
}
|
||||
|
||||
func (suite *SessionTestSuite) TestDeriveSessionNoHost() {
|
||||
config.SetProtocol("https")
|
||||
config.SetHost("")
|
||||
config.SetPort(0)
|
||||
|
||||
sessionName, err := middleware.SessionName()
|
||||
suite.EqualError(err, "could not derive hostname without port from https://")
|
||||
suite.Equal("", sessionName)
|
||||
}
|
||||
|
||||
func (suite *SessionTestSuite) TestDeriveSessionOK() {
|
||||
config.SetProtocol("https")
|
||||
config.SetHost("example.org")
|
||||
|
||||
sessionName, err := middleware.SessionName()
|
||||
suite.NoError(err)
|
||||
suite.Equal("gotosocial-example.org", sessionName)
|
||||
}
|
||||
|
||||
func (suite *SessionTestSuite) TestDeriveSessionIDNOK() {
|
||||
config.SetProtocol("https")
|
||||
config.SetHost("fóid.org")
|
||||
|
||||
sessionName, err := middleware.SessionName()
|
||||
suite.NoError(err)
|
||||
suite.Equal("gotosocial-xn--fid-gna.org", sessionName)
|
||||
}
|
||||
|
||||
func TestSessionTestSuite(t *testing.T) {
|
||||
suite.Run(t, &SessionTestSuite{})
|
||||
}
|
93
internal/middleware/signaturecheck.go
Normal file
93
internal/middleware/signaturecheck.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-fed/httpsig"
|
||||
)
|
||||
|
||||
var (
|
||||
// this mimics an untyped error returned by httpsig when no signature is present;
|
||||
// define it here so that we can use it to decide what to log without hitting
|
||||
// performance too hard
|
||||
noSignatureError = fmt.Sprintf("neither %q nor %q have signature parameters", httpsig.Signature, httpsig.Authorization)
|
||||
signatureHeader = string(httpsig.Signature)
|
||||
authorizationHeader = string(httpsig.Authorization)
|
||||
)
|
||||
|
||||
// SignatureCheck returns a gin middleware for checking http signatures.
|
||||
//
|
||||
// The middleware first checks whether an incoming http request has been http-signed with a well-formed signature.
|
||||
//
|
||||
// If so, it will check if the domain that signed the request is permitted to access the server, using the provided isURIBlocked function.
|
||||
//
|
||||
// If it is permitted, the handler will set the key verifier and the signature in the gin context for use down the line.
|
||||
//
|
||||
// If the domain is blocked, the middleware will abort the request chain instead with http code 403 forbidden.
|
||||
//
|
||||
// In case of an error, the request will be aborted with http code 500 internal server error.
|
||||
func SignatureCheck(isURIBlocked func(context.Context, *url.URL) (bool, db.Error)) func(*gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
// create the verifier from the request, this will error if the request wasn't signed
|
||||
verifier, err := httpsig.NewVerifier(c.Request)
|
||||
if err != nil {
|
||||
// Something went wrong, so we need to return regardless, but only actually
|
||||
// *abort* the request with 401 if a signature was present but malformed
|
||||
if err.Error() != noSignatureError {
|
||||
log.Debugf("http signature was present but invalid: %s", err)
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The request was signed!
|
||||
// The key ID should be given in the signature so that we know where to fetch it from the remote server.
|
||||
// This will be something like https://example.org/users/whatever_requesting_user#main-key
|
||||
requestingPublicKeyIDString := verifier.KeyId()
|
||||
requestingPublicKeyID, err := url.Parse(requestingPublicKeyIDString)
|
||||
if err != nil {
|
||||
log.Debugf("http signature requesting public key id %s could not be parsed as a url: %s", requestingPublicKeyIDString, err)
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
} else if requestingPublicKeyID == nil {
|
||||
// Key can sometimes be nil, according to url parse function:
|
||||
// 'Trying to parse a hostname and path without a scheme is invalid but may not necessarily return an error, due to parsing ambiguities'
|
||||
log.Debugf("http signature requesting public key id %s was nil after parsing as a url", requestingPublicKeyIDString)
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// we managed to parse the url!
|
||||
// if the domain is blocked we want to bail as early as possible
|
||||
if blocked, err := isURIBlocked(c.Request.Context(), requestingPublicKeyID); err != nil {
|
||||
log.Errorf("could not tell if domain %s was blocked or not: %s", requestingPublicKeyID.Host, err)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
} else if blocked {
|
||||
log.Infof("domain %s is blocked", requestingPublicKeyID.Host)
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// assume signature was set on Signature header (most common behavior),
|
||||
// but fall back to Authorization header if necessary
|
||||
var signature string
|
||||
if s := c.GetHeader(signatureHeader); s != "" {
|
||||
signature = s
|
||||
} else {
|
||||
signature = c.GetHeader(authorizationHeader)
|
||||
}
|
||||
|
||||
// set the verifier and signature on the context here to save some work further down the line
|
||||
c.Set(string(ap.ContextRequestingPublicKeyVerifier), verifier)
|
||||
c.Set(string(ap.ContextRequestingPublicKeySignature), signature)
|
||||
}
|
||||
}
|
140
internal/middleware/tokencheck.go
Normal file
140
internal/middleware/tokencheck.go
Normal file
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/oauth2/v4"
|
||||
)
|
||||
|
||||
// TokenCheck returns a new gin middleware for validating oauth tokens in requests.
|
||||
//
|
||||
// The middleware checks the request Authorization header for a valid oauth Bearer token.
|
||||
//
|
||||
// If no token was set in the Authorization header, or the token was invalid, the handler will return.
|
||||
//
|
||||
// If a valid oauth Bearer token was provided, it will be set on the gin context for further use.
|
||||
//
|
||||
// Then, it will check which *gtsmodel.User the token belongs to. If the user is not confirmed, not approved,
|
||||
// or has been disabled, then the middleware will return early. Otherwise, the User will be set on the
|
||||
// gin context for further processing by other functions.
|
||||
//
|
||||
// Next, it will look up the *gtsmodel.Account for the User. If the Account has been suspended, then the
|
||||
// middleware will return early. Otherwise, it will set the Account on the gin context too.
|
||||
//
|
||||
// Finally, it will check the client ID of the token to see if a *gtsmodel.Application can be retrieved
|
||||
// for that client ID. This will also be set on the gin context.
|
||||
//
|
||||
// If an invalid token is presented, or a user/account/application can't be found, then this middleware
|
||||
// won't abort the request, since the server might want to still allow public requests that don't have a
|
||||
// Bearer token set (eg., for public instance information and so on).
|
||||
func TokenCheck(dbConn db.DB, validateBearerToken func(r *http.Request) (oauth2.TokenInfo, error)) func(*gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if c.Request.Header.Get("Authorization") == "" {
|
||||
// no token set in the header, we can just bail
|
||||
return
|
||||
}
|
||||
|
||||
ti, err := validateBearerToken(c.Copy().Request)
|
||||
if err != nil {
|
||||
log.Debugf("token was passed in Authorization header but we could not validate it: %s", err)
|
||||
return
|
||||
}
|
||||
c.Set(oauth.SessionAuthorizedToken, ti)
|
||||
|
||||
// check for user-level token
|
||||
if userID := ti.GetUserID(); userID != "" {
|
||||
log.Tracef("authenticated user %s with bearer token, scope is %s", userID, ti.GetScope())
|
||||
|
||||
// fetch user for this token
|
||||
user, err := dbConn.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
if err != db.ErrNoEntries {
|
||||
log.Errorf("database error looking for user with id %s: %s", userID, err)
|
||||
return
|
||||
}
|
||||
log.Warnf("no user found for userID %s", userID)
|
||||
return
|
||||
}
|
||||
|
||||
if user.ConfirmedAt.IsZero() {
|
||||
log.Warnf("authenticated user %s has never confirmed thier email address", userID)
|
||||
return
|
||||
}
|
||||
|
||||
if !*user.Approved {
|
||||
log.Warnf("authenticated user %s's account was never approved by an admin", userID)
|
||||
return
|
||||
}
|
||||
|
||||
if *user.Disabled {
|
||||
log.Warnf("authenticated user %s's account was disabled'", userID)
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(oauth.SessionAuthorizedUser, user)
|
||||
|
||||
// fetch account for this token
|
||||
if user.Account == nil {
|
||||
acct, err := dbConn.GetAccountByID(ctx, user.AccountID)
|
||||
if err != nil {
|
||||
if err != db.ErrNoEntries {
|
||||
log.Errorf("database error looking for account with id %s: %s", user.AccountID, err)
|
||||
return
|
||||
}
|
||||
log.Warnf("no account found for userID %s", userID)
|
||||
return
|
||||
}
|
||||
user.Account = acct
|
||||
}
|
||||
|
||||
if !user.Account.SuspendedAt.IsZero() {
|
||||
log.Warnf("authenticated user %s's account (accountId=%s) has been suspended", userID, user.AccountID)
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(oauth.SessionAuthorizedAccount, user.Account)
|
||||
}
|
||||
|
||||
// check for application token
|
||||
if clientID := ti.GetClientID(); clientID != "" {
|
||||
log.Tracef("authenticated client %s with bearer token, scope is %s", clientID, ti.GetScope())
|
||||
|
||||
// fetch app for this token
|
||||
app := >smodel.Application{}
|
||||
if err := dbConn.GetWhere(ctx, []db.Where{{Key: "client_id", Value: clientID}}, app); err != nil {
|
||||
if err != db.ErrNoEntries {
|
||||
log.Errorf("database error looking for application with clientID %s: %s", clientID, err)
|
||||
return
|
||||
}
|
||||
log.Warnf("no app found for client %s", clientID)
|
||||
return
|
||||
}
|
||||
c.Set(oauth.SessionAuthorizedApplication, app)
|
||||
}
|
||||
}
|
||||
}
|
39
internal/middleware/useragent.go
Normal file
39
internal/middleware/useragent.go
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UserAgent returns a gin middleware which aborts requests with
|
||||
// empty user agent strings, returning code 418 - I'm a teapot.
|
||||
func UserAgent() gin.HandlerFunc {
|
||||
// todo: make this configurable
|
||||
return func(c *gin.Context) {
|
||||
if ua := c.Request.UserAgent(); ua == "" {
|
||||
code := http.StatusTeapot
|
||||
err := errors.New(http.StatusText(code) + ": no user-agent sent with request")
|
||||
c.AbortWithStatusJSON(code, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user