From 1e2db7a32f72ee01497a08c67e6f7f507890ee71 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Sun, 20 Aug 2023 13:35:55 +0200
Subject: [PATCH] [feature/bugfix] Probe S3 storage for CSP uri, add config
flag for extra URIs (#2134)
* [feature/bugfix] Probe S3 storage for CSP uri, add config flag for extra URIs
* env parsing tests, my coy mistress
---
cmd/gotosocial/action/server/server.go | 23 +++
cmd/gotosocial/action/testrig/testrig.go | 30 +++-
docs/configuration/advanced.md | 18 +++
example/config.yaml | 18 +++
internal/config/config.go | 1 +
internal/config/defaults.go | 1 +
internal/config/flags.go | 1 +
internal/config/helpers.gen.go | 25 ++++
internal/middleware/contentsecuritypolicy.go | 144 +++++++++++++++++++
internal/middleware/extraheaders.go | 55 -------
internal/middleware/middleware_test.go | 79 ++++------
internal/storage/storage.go | 57 ++++++++
test/envparsing.sh | 1 +
13 files changed, 343 insertions(+), 110 deletions(-)
create mode 100644 internal/middleware/contentsecuritypolicy.go
diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go
index eb76b8f43..e966c46be 100644
--- a/cmd/gotosocial/action/server/server.go
+++ b/cmd/gotosocial/action/server/server.go
@@ -204,6 +204,29 @@ var Start action.GTSAction = func(ctx context.Context) error {
middleware.ExtraHeaders(),
}...)
+ // Instantiate Content-Security-Policy
+ // middleware, with extra URIs.
+ cspExtraURIs := make([]string, 0)
+
+ // Probe storage to check if extra URI is needed in CSP.
+ // Error here means something is wrong with storage.
+ storageCSPUri, err := state.Storage.ProbeCSPUri(ctx)
+ if err != nil {
+ return fmt.Errorf("error deriving Content-Security-Policy uri from storage: %w", err)
+ }
+
+ // storageCSPUri may be empty string if
+ // not S3-backed storage; check for this.
+ if storageCSPUri != "" {
+ cspExtraURIs = append(cspExtraURIs, storageCSPUri)
+ }
+
+ // Add any extra CSP URIs from config.
+ cspExtraURIs = append(cspExtraURIs, config.GetAdvancedCSPExtraURIs()...)
+
+ // Add CSP to middlewares.
+ middlewares = append(middlewares, middleware.ContentSecurityPolicy(cspExtraURIs...))
+
// attach global middlewares which are used for every request
router.AttachGlobalMiddleware(middlewares...)
diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go
index 8f55c4b4a..ccf92a971 100644
--- a/cmd/gotosocial/action/testrig/testrig.go
+++ b/cmd/gotosocial/action/testrig/testrig.go
@@ -70,7 +70,11 @@ var Start action.GTSAction = func(ctx context.Context) error {
testrig.StandardDBSetup(state.DB, nil)
if os.Getenv("GTS_STORAGE_BACKEND") == "s3" {
- state.Storage, _ = storage.NewS3Storage()
+ var err error
+ state.Storage, err = storage.NewS3Storage()
+ if err != nil {
+ return fmt.Errorf("error initializing storage: %w", err)
+ }
} else {
state.Storage = testrig.NewInMemoryStorage()
}
@@ -136,6 +140,29 @@ var Start action.GTSAction = func(ctx context.Context) error {
middleware.ExtraHeaders(),
}...)
+ // Instantiate Content-Security-Policy
+ // middleware, with extra URIs.
+ cspExtraURIs := make([]string, 0)
+
+ // Probe storage to check if extra URI is needed in CSP.
+ // Error here means something is wrong with storage.
+ storageCSPUri, err := state.Storage.ProbeCSPUri(ctx)
+ if err != nil {
+ return fmt.Errorf("error deriving Content-Security-Policy uri from storage: %w", err)
+ }
+
+ // storageCSPUri may be empty string if
+ // not S3-backed storage; check for this.
+ if storageCSPUri != "" {
+ cspExtraURIs = append(cspExtraURIs, storageCSPUri)
+ }
+
+ // Add any extra CSP URIs from config.
+ cspExtraURIs = append(cspExtraURIs, config.GetAdvancedCSPExtraURIs()...)
+
+ // Add CSP to middlewares.
+ middlewares = append(middlewares, middleware.ContentSecurityPolicy(cspExtraURIs...))
+
// attach global middlewares which are used for every request
router.AttachGlobalMiddleware(middlewares...)
@@ -146,7 +173,6 @@ var Start action.GTSAction = func(ctx context.Context) error {
// build router modules
var idp oidc.IDP
- var err error
if config.GetOIDCEnabled() {
idp, err = oidc.NewIDP(ctx)
if err != nil {
diff --git a/docs/configuration/advanced.md b/docs/configuration/advanced.md
index 07e3376d5..530b75f0f 100644
--- a/docs/configuration/advanced.md
+++ b/docs/configuration/advanced.md
@@ -118,4 +118,22 @@ advanced-throttling-retry-after: "30s"
# 2 cpu = 1 concurrent sender
# 4 cpu = 1 concurrent sender
advanced-sender-multiplier: 2
+
+# Array of string. Extra URIs to add to 'img-src' and 'media-src'
+# when building the Content-Security-Policy header for your instance.
+#
+# This can be used to allow the browser to load resources from additional
+# sources like S3 buckets and so on when viewing your instance's pages
+# and profiles in the browser.
+#
+# Since non-proxying S3 storage will be probed on instance launch to
+# generate a correct Content-Security-Policy, you probably won't need
+# to ever touch this setting, but it's included in the 'spirit of more
+# configurable (usually) means more good'.
+#
+# See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
+#
+# Example: ["s3.example.org", "some-bucket-name.s3.example.org"]
+# Default: []
+advanced-csp-extra-uris: []
```
diff --git a/example/config.yaml b/example/config.yaml
index 0ab2b1a3b..d3a6102bd 100644
--- a/example/config.yaml
+++ b/example/config.yaml
@@ -903,3 +903,21 @@ advanced-throttling-retry-after: "30s"
# 2 cpu = 1 concurrent sender
# 4 cpu = 1 concurrent sender
advanced-sender-multiplier: 2
+
+# Array of string. Extra URIs to add to 'img-src' and 'media-src'
+# when building the Content-Security-Policy header for your instance.
+#
+# This can be used to allow the browser to load resources from additional
+# sources like S3 buckets and so on when viewing your instance's pages
+# and profiles in the browser.
+#
+# Since non-proxying S3 storage will be probed on instance launch to
+# generate a correct Content-Security-Policy, you probably won't need
+# to ever touch this setting, but it's included in the 'spirit of more
+# configurable (usually) means more good'.
+#
+# See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
+#
+# Example: ["s3.example.org", "some-bucket-name.s3.example.org"]
+# Default: []
+advanced-csp-extra-uris: []
diff --git a/internal/config/config.go b/internal/config/config.go
index 5a26222ed..f1a5bf6e5 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -150,6 +150,7 @@ type Configuration struct {
AdvancedThrottlingMultiplier int `name:"advanced-throttling-multiplier" usage:"Multiplier to use per cpu for http request throttling. 0 or less turns throttling off."`
AdvancedThrottlingRetryAfter time.Duration `name:"advanced-throttling-retry-after" usage:"Retry-After duration response to send for throttled requests."`
AdvancedSenderMultiplier int `name:"advanced-sender-multiplier" usage:"Multiplier to use per cpu for batching outgoing fedi messages. 0 or less turns batching off (not recommended)."`
+ AdvancedCSPExtraURIs []string `name:"advanced-csp-extra-uris" usage:"Additional URIs to allow when building content-security-policy for media + images."`
// HTTPClient configuration vars.
HTTPClient HTTPClientConfiguration `name:"http-client"`
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index 536f1b0a3..61a037157 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -124,6 +124,7 @@ var Defaults = Configuration{
AdvancedThrottlingMultiplier: 8, // 8 open requests per CPU
AdvancedThrottlingRetryAfter: time.Second * 30,
AdvancedSenderMultiplier: 2, // 2 senders per CPU
+ AdvancedCSPExtraURIs: []string{},
Cache: CacheConfiguration{
// Rough memory target that the total
diff --git a/internal/config/flags.go b/internal/config/flags.go
index 321400252..386e47293 100644
--- a/internal/config/flags.go
+++ b/internal/config/flags.go
@@ -151,6 +151,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) {
cmd.Flags().Int(AdvancedThrottlingMultiplierFlag(), cfg.AdvancedThrottlingMultiplier, fieldtag("AdvancedThrottlingMultiplier", "usage"))
cmd.Flags().Duration(AdvancedThrottlingRetryAfterFlag(), cfg.AdvancedThrottlingRetryAfter, fieldtag("AdvancedThrottlingRetryAfter", "usage"))
cmd.Flags().Int(AdvancedSenderMultiplierFlag(), cfg.AdvancedSenderMultiplier, fieldtag("AdvancedSenderMultiplier", "usage"))
+ cmd.Flags().StringSlice(AdvancedCSPExtraURIsFlag(), cfg.AdvancedCSPExtraURIs, fieldtag("AdvancedCSPExtraURIs", "usage"))
cmd.Flags().String(RequestIDHeaderFlag(), cfg.RequestIDHeader, fieldtag("RequestIDHeader", "usage"))
})
diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go
index 03411853f..aed111129 100644
--- a/internal/config/helpers.gen.go
+++ b/internal/config/helpers.gen.go
@@ -2324,6 +2324,31 @@ func GetAdvancedSenderMultiplier() int { return global.GetAdvancedSenderMultipli
// SetAdvancedSenderMultiplier safely sets the value for global configuration 'AdvancedSenderMultiplier' field
func SetAdvancedSenderMultiplier(v int) { global.SetAdvancedSenderMultiplier(v) }
+// GetAdvancedCSPExtraURIs safely fetches the Configuration value for state's 'AdvancedCSPExtraURIs' field
+func (st *ConfigState) GetAdvancedCSPExtraURIs() (v []string) {
+ st.mutex.RLock()
+ v = st.config.AdvancedCSPExtraURIs
+ st.mutex.RUnlock()
+ return
+}
+
+// SetAdvancedCSPExtraURIs safely sets the Configuration value for state's 'AdvancedCSPExtraURIs' field
+func (st *ConfigState) SetAdvancedCSPExtraURIs(v []string) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.AdvancedCSPExtraURIs = v
+ st.reloadToViper()
+}
+
+// AdvancedCSPExtraURIsFlag returns the flag name for the 'AdvancedCSPExtraURIs' field
+func AdvancedCSPExtraURIsFlag() string { return "advanced-csp-extra-uris" }
+
+// GetAdvancedCSPExtraURIs safely fetches the value for global configuration 'AdvancedCSPExtraURIs' field
+func GetAdvancedCSPExtraURIs() []string { return global.GetAdvancedCSPExtraURIs() }
+
+// SetAdvancedCSPExtraURIs safely sets the value for global configuration 'AdvancedCSPExtraURIs' field
+func SetAdvancedCSPExtraURIs(v []string) { global.SetAdvancedCSPExtraURIs(v) }
+
// GetHTTPClientAllowIPs safely fetches the Configuration value for state's 'HTTPClient.AllowIPs' field
func (st *ConfigState) GetHTTPClientAllowIPs() (v []string) {
st.mutex.RLock()
diff --git a/internal/middleware/contentsecuritypolicy.go b/internal/middleware/contentsecuritypolicy.go
new file mode 100644
index 000000000..5984a75c3
--- /dev/null
+++ b/internal/middleware/contentsecuritypolicy.go
@@ -0,0 +1,144 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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 .
+
+package middleware
+
+import (
+ "strings"
+
+ "codeberg.org/gruf/go-debug"
+ "github.com/gin-gonic/gin"
+)
+
+func ContentSecurityPolicy(extraURIs ...string) gin.HandlerFunc {
+ csp := BuildContentSecurityPolicy(extraURIs...)
+
+ return func(c *gin.Context) {
+ // Inform the browser we only load
+ // CSS/JS/media using the given policy.
+ c.Header("Content-Security-Policy", csp)
+ }
+}
+
+func BuildContentSecurityPolicy(extraURIs ...string) string {
+ const (
+ defaultSrc = "default-src"
+ objectSrc = "object-src"
+ imgSrc = "img-src"
+ mediaSrc = "media-src"
+
+ self = "'self'"
+ none = "'none'"
+ blob = "blob:"
+ )
+
+ // CSP values keyed by directive.
+ values := make(map[string][]string, 4)
+
+ /*
+ default-src
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
+ */
+
+ if !debug.DEBUG {
+ // Restrictive 'self' policy
+ values[defaultSrc] = []string{self}
+ } else {
+ // If debug is enabled, allow
+ // serving things from localhost
+ // as well (regardless of port).
+ values[defaultSrc] = []string{
+ self,
+ "localhost:*",
+ "ws://localhost:*",
+ }
+ }
+
+ /*
+ object-src
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/object-src
+ */
+
+ // Disallow object-src as recommended.
+ values[objectSrc] = []string{none}
+
+ /*
+ img-src
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/img-src
+ */
+
+ // Restrictive 'self' policy,
+ // include extraURIs, and 'blob:'
+ // for previewing uploaded images
+ // (header, avi, emojis) in settings.
+ values[imgSrc] = append(
+ []string{self, blob},
+ extraURIs...,
+ )
+
+ /*
+ media-src
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/media-src
+ */
+
+ // Restrictive 'self' policy,
+ // include extraURIs.
+ values[mediaSrc] = append(
+ []string{self},
+ extraURIs...,
+ )
+
+ /*
+ Assemble policy directives.
+ */
+
+ // Iterate through an ordered slice rather than
+ // iterating through the map, since we want these
+ // policyDirectives in a determinate order.
+ policyDirectives := make([]string, 4)
+ for i, directive := range []string{
+ defaultSrc,
+ objectSrc,
+ imgSrc,
+ mediaSrc,
+ } {
+ // Each policy directive should look like:
+ // `[directive] [value1] [value2] [etc]`
+
+ // Get assembled values
+ // for this directive.
+ values := values[directive]
+
+ // Prepend values with
+ // the directive name.
+ directiveValues := append(
+ []string{directive},
+ values...,
+ )
+
+ // Space-separate them.
+ policyDirective := strings.Join(directiveValues, " ")
+
+ // Done.
+ policyDirectives[i] = policyDirective
+ }
+
+ // Content-security-policy looks like this:
+ // `Content-Security-Policy: ; `
+ // So join each policy directive appropriately.
+ return strings.Join(policyDirectives, "; ")
+}
diff --git a/internal/middleware/extraheaders.go b/internal/middleware/extraheaders.go
index 064e85cca..1a3f1d522 100644
--- a/internal/middleware/extraheaders.go
+++ b/internal/middleware/extraheaders.go
@@ -18,15 +18,11 @@
package middleware
import (
- "codeberg.org/gruf/go-debug"
"github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/config"
)
// ExtraHeaders returns a new gin middleware which adds various extra headers to the response.
func ExtraHeaders() gin.HandlerFunc {
- csp := BuildContentSecurityPolicy()
-
return func(c *gin.Context) {
// Inform all callers which server implementation this is.
c.Header("Server", "gotosocial")
@@ -39,56 +35,5 @@ func ExtraHeaders() gin.HandlerFunc {
//
// See: https://github.com/patcg-individual-drafts/topics
c.Header("Permissions-Policy", "browsing-topics=()")
-
- // Inform the browser we only load
- // CSS/JS/media using the given policy.
- c.Header("Content-Security-Policy", csp)
}
}
-
-func BuildContentSecurityPolicy() string {
- // Start with restrictive policy.
- policy := "default-src 'self'"
-
- if debug.DEBUG {
- // Debug is enabled, allow
- // serving things from localhost
- // as well (regardless of port).
- policy += " localhost:* ws://localhost:*"
- }
-
- // Disallow object-src as recommended https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/object-src
- policy += "; object-src 'none'"
-
- s3Endpoint := config.GetStorageS3Endpoint()
- if s3Endpoint == "" || config.GetStorageS3Proxy() {
- // S3 not configured or in proxy mode, just allow images from self and blob:
- policy += "; img-src 'self' blob:"
- return policy
- }
-
- // S3 is on and in non-proxy mode, so we need to add the S3 host to
- // the policy to allow images and video to be pulled from there too.
-
- // If secure is false,
- // use 'http' scheme.
- scheme := "https"
- if !config.GetStorageS3UseSSL() {
- scheme = "http"
- }
-
- // Construct endpoint URL.
- s3EndpointURLStr := scheme + "://" + s3Endpoint
-
- // When object storage is in use in non-proxied mode, GtS still serves some
- // assets itself like the logo, so keep 'self' in there. That should also
- // handle any redirects from the fileserver to object storage.
-
- // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/img-src
- policy += "; img-src 'self' blob: " + s3EndpointURLStr
-
- // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/media-src
- policy += "; media-src 'self' " + s3EndpointURLStr
-
- return policy
-}
diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go
index 29376304e..fad05931b 100644
--- a/internal/middleware/middleware_test.go
+++ b/internal/middleware/middleware_test.go
@@ -20,80 +20,53 @@ package middleware_test
import (
"testing"
- "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/middleware"
)
func TestBuildContentSecurityPolicy(t *testing.T) {
type cspTest struct {
- s3Endpoint string
- s3Proxy bool
- s3Secure bool
- expected string
- actual string
+ extraURLs []string
+ expected string
}
for _, test := range []cspTest{
{
- s3Endpoint: "",
- s3Proxy: false,
- s3Secure: false,
- expected: "default-src 'self'; object-src 'none'; img-src 'self' blob:",
+ extraURLs: nil,
+ expected: "default-src 'self'; object-src 'none'; img-src 'self' blob:; media-src 'self'",
},
{
- s3Endpoint: "some-bucket-provider.com",
- s3Proxy: false,
- s3Secure: true,
- expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://some-bucket-provider.com; media-src 'self' https://some-bucket-provider.com",
+ extraURLs: []string{
+ "https://some-bucket-provider.com",
+ },
+ expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://some-bucket-provider.com; media-src 'self' https://some-bucket-provider.com",
},
{
- s3Endpoint: "some-bucket-provider.com:6969",
- s3Proxy: false,
- s3Secure: true,
- expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://some-bucket-provider.com:6969; media-src 'self' https://some-bucket-provider.com:6969",
+ extraURLs: []string{
+ "https://some-bucket-provider.com:6969",
+ },
+ expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://some-bucket-provider.com:6969; media-src 'self' https://some-bucket-provider.com:6969",
},
{
- s3Endpoint: "some-bucket-provider.com:6969",
- s3Proxy: false,
- s3Secure: false,
- expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: http://some-bucket-provider.com:6969; media-src 'self' http://some-bucket-provider.com:6969",
+ extraURLs: []string{
+ "http://some-bucket-provider.com:6969",
+ },
+ expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: http://some-bucket-provider.com:6969; media-src 'self' http://some-bucket-provider.com:6969",
},
{
- s3Endpoint: "s3.nl-ams.scw.cloud",
- s3Proxy: false,
- s3Secure: true,
- expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://s3.nl-ams.scw.cloud; media-src 'self' https://s3.nl-ams.scw.cloud",
+ extraURLs: []string{
+ "https://s3.nl-ams.scw.cloud",
+ },
+ expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://s3.nl-ams.scw.cloud; media-src 'self' https://s3.nl-ams.scw.cloud",
},
{
- s3Endpoint: "some-bucket-provider.com",
- s3Proxy: true,
- s3Secure: true,
- expected: "default-src 'self'; object-src 'none'; img-src 'self' blob:",
- },
- {
- s3Endpoint: "some-bucket-provider.com:6969",
- s3Proxy: true,
- s3Secure: true,
- expected: "default-src 'self'; object-src 'none'; img-src 'self' blob:",
- },
- {
- s3Endpoint: "some-bucket-provider.com:6969",
- s3Proxy: true,
- s3Secure: true,
- expected: "default-src 'self'; object-src 'none'; img-src 'self' blob:",
- },
- {
- s3Endpoint: "s3.nl-ams.scw.cloud",
- s3Proxy: true,
- s3Secure: true,
- expected: "default-src 'self'; object-src 'none'; img-src 'self' blob:",
+ extraURLs: []string{
+ "https://s3.nl-ams.scw.cloud",
+ "https://s3.somewhere.else.example.org",
+ },
+ expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://s3.nl-ams.scw.cloud https://s3.somewhere.else.example.org; media-src 'self' https://s3.nl-ams.scw.cloud https://s3.somewhere.else.example.org",
},
} {
- config.SetStorageS3Endpoint(test.s3Endpoint)
- config.SetStorageS3Proxy(test.s3Proxy)
- config.SetStorageS3UseSSL(test.s3Secure)
-
- csp := middleware.BuildContentSecurityPolicy()
+ csp := middleware.BuildContentSecurityPolicy(test.extraURLs...)
if csp != test.expected {
t.Logf("expected '%s', got '%s'", test.expected, csp)
t.Fail()
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 588c586d8..c27037fba 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -32,6 +32,8 @@ import (
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
)
const (
@@ -145,6 +147,61 @@ func (d *Driver) URL(ctx context.Context, key string) *PresignedURL {
return &psu
}
+// ProbeCSPUri returns a URI string that can be added
+// to a content-security-policy to allow requests to
+// endpoints served by this driver.
+//
+// If the driver is not backed by non-proxying S3,
+// this will return an empty string and no error.
+//
+// Otherwise, this function probes for a CSP URI by
+// doing the following:
+//
+// 1. Create a temporary file in the S3 bucket.
+// 2. Generate a pre-signed URL for that file.
+// 3. Extract '[scheme]://[host]' from the URL.
+// 4. Remove the temporary file.
+// 5. Return the '[scheme]://[host]' string.
+func (d *Driver) ProbeCSPUri(ctx context.Context) (string, error) {
+ // Check whether S3 without proxying
+ // is enabled. If it's not, there's
+ // no need to add anything to the CSP.
+ s3, ok := d.Storage.(*storage.S3Storage)
+ if !ok || d.Proxy {
+ return "", nil
+ }
+
+ const cspKey = "gotosocial-csp-probe"
+
+ // Create an empty file in S3 storage.
+ if _, err := d.Put(ctx, cspKey, make([]byte, 0)); err != nil {
+ return "", gtserror.Newf("error putting file in bucket at key %s: %w", cspKey, err)
+ }
+
+ // Try to clean up file whatever happens.
+ defer func() {
+ if err := d.Delete(ctx, cspKey); err != nil {
+ log.Warnf(ctx, "error deleting file from bucket at key %s (%v); "+
+ "you may want to remove this file manually from your S3 bucket", cspKey, err)
+ }
+ }()
+
+ // Get a presigned URL for that empty file.
+ u, err := s3.Client().PresignedGetObject(ctx, d.Bucket, cspKey, 1*time.Second, nil)
+ if err != nil {
+ return "", err
+ }
+
+ // Create a stripped version of the presigned
+ // URL that includes only the host and scheme.
+ uStripped := &url.URL{
+ Scheme: u.Scheme,
+ Host: u.Host,
+ }
+
+ return uStripped.String(), nil
+}
+
func AutoConfig() (*Driver, error) {
switch backend := config.GetStorageBackend(); backend {
case "s3":
diff --git a/test/envparsing.sh b/test/envparsing.sh
index e03dc62f0..0cad04416 100755
--- a/test/envparsing.sh
+++ b/test/envparsing.sh
@@ -11,6 +11,7 @@ EXPECT=$(cat << "EOF"
"accounts-reason-required": false,
"accounts-registration-open": true,
"advanced-cookies-samesite": "strict",
+ "advanced-csp-extra-uris": [],
"advanced-rate-limit-requests": 6969,
"advanced-sender-multiplier": -1,
"advanced-throttling-multiplier": -1,