diff --git a/internal/configuration/setup/Setup.go b/internal/configuration/setup/Setup.go index 387defe4..1ac17001 100644 --- a/internal/configuration/setup/Setup.go +++ b/internal/configuration/setup/Setup.go @@ -34,6 +34,7 @@ import ( "github.com/forceu/gokapi/internal/helper" "github.com/forceu/gokapi/internal/models" "github.com/forceu/gokapi/internal/storage/filesystem/s3filesystem/aws" + "github.com/forceu/gokapi/internal/webserver/headers" "github.com/forceu/gokapi/internal/webserver/ratelimiter" ) @@ -128,7 +129,7 @@ func startSetupWebserver() { Addr: ":" + port, ReadTimeout: 2 * time.Minute, WriteTimeout: 2 * time.Minute, - Handler: mux, + Handler: headers.ContentSecurityPolicy(mux), } if debugDisableAuth { srv.Addr = "127.0.0.1:" + port diff --git a/internal/configuration/setup/Setup_test.go b/internal/configuration/setup/Setup_test.go index 2bd326fc..9ead9efa 100644 --- a/internal/configuration/setup/Setup_test.go +++ b/internal/configuration/setup/Setup_test.go @@ -299,6 +299,17 @@ func TestRunConfigModification(t *testing.T) { finish := make(chan bool) go func() { waitForServer(t, true) + response, err := http.Get("http://localhost:53842/setup/start") + if err != nil { + t.Errorf("requesting setup page: %v", err) + } else { + response.Body.Close() + expectedPolicy := "frame-ancestors 'none'; object-src 'none'; base-uri 'self'" + actualPolicy := response.Header.Get("Content-Security-Policy") + if actualPolicy != expectedPolicy { + t.Errorf("Content-Security-Policy = %q, want %q", actualPolicy, expectedPolicy) + } + } test.HttpPageResult(t, test.HttpTestConfig{ Url: "http://localhost:53842/setup/start", IsHtml: false, diff --git a/internal/webserver/Webserver.go b/internal/webserver/Webserver.go index 6b707e5b..e7b96a3f 100644 --- a/internal/webserver/Webserver.go +++ b/internal/webserver/Webserver.go @@ -45,6 +45,7 @@ import ( "github.com/forceu/gokapi/internal/webserver/errorHandling" "github.com/forceu/gokapi/internal/webserver/favicon" "github.com/forceu/gokapi/internal/webserver/fileupload" + "github.com/forceu/gokapi/internal/webserver/headers" "github.com/forceu/gokapi/internal/webserver/ratelimiter" "github.com/forceu/gokapi/internal/webserver/sse" "github.com/forceu/gokapi/internal/webserver/ssl" @@ -142,7 +143,7 @@ func Start() { Addr: configuration.Get().Port, ReadTimeout: timeOutWebserverRead, WriteTimeout: timeOutWebserverWrite, - Handler: mux, + Handler: headers.ContentSecurityPolicyWithStreamSaver(mux), } infoMessage := "Webserver can be accessed at " + configuration.Get().ServerUrl + "admin\nPress CTRL+C to stop Gokapi" if strings.Contains(configuration.Get().ServerUrl, "127.0.0.1") { diff --git a/internal/webserver/Webserver_test.go b/internal/webserver/Webserver_test.go index f5e2bf8a..ed562888 100644 --- a/internal/webserver/Webserver_test.go +++ b/internal/webserver/Webserver_test.go @@ -75,6 +75,45 @@ func TestStaticDirs(t *testing.T) { }) } +func TestContentSecurityPolicy(t *testing.T) { + const defaultPolicy = "frame-ancestors 'none'; object-src 'none'; base-uri 'self'" + const serviceWorkerPolicy = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'" + + testCases := []struct { + path string + expectedPolicy string + }{ + {path: "/index", expectedPolicy: defaultPolicy}, + {path: "/css/cover.css", expectedPolicy: defaultPolicy}, + {path: "/admin", expectedPolicy: defaultPolicy}, + {path: "/api/files/list", expectedPolicy: defaultPolicy}, + {path: "/main.wasm", expectedPolicy: defaultPolicy}, + {path: "/serviceworker/index.html?stream=1", expectedPolicy: serviceWorkerPolicy}, + {path: "/serviceworker/sw.js", expectedPolicy: defaultPolicy}, + {path: "/not-found", expectedPolicy: defaultPolicy}, + } + + client := &http.Client{ + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + for _, testCase := range testCases { + t.Run(testCase.path, func(t *testing.T) { + response, err := client.Get("http://localhost:53843" + testCase.path) + if err != nil { + t.Fatalf("request failed: %v", err) + } + response.Body.Close() + + actualPolicy := response.Header.Get("Content-Security-Policy") + if actualPolicy != testCase.expectedPolicy { + t.Errorf("Content-Security-Policy = %q, want %q", actualPolicy, testCase.expectedPolicy) + } + }) + } +} + func postValues(username, password, csrf string) []test.PostBody { return []test.PostBody{ {Key: "username", Value: username}, diff --git a/internal/webserver/headers/ContentSecurityPolicy.go b/internal/webserver/headers/ContentSecurityPolicy.go new file mode 100644 index 00000000..1c2abdc0 --- /dev/null +++ b/internal/webserver/headers/ContentSecurityPolicy.go @@ -0,0 +1,31 @@ +package headers + +import "net/http" + +const ( + contentSecurityPolicy = "frame-ancestors 'none'; object-src 'none'; base-uri 'self'" + streamSaverContentSecurityPolicy = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'" + streamSaverFramePath = "/serviceworker/index.html" +) + +// ContentSecurityPolicy adds the baseline policy to every response. +func ContentSecurityPolicy(next http.Handler) http.Handler { + return contentSecurityPolicyHandler(next, false) +} + +// ContentSecurityPolicyWithStreamSaver adds the baseline policy while allowing +// the StreamSaver document to be framed by Gokapi's same-origin download page. +func ContentSecurityPolicyWithStreamSaver(next http.Handler) http.Handler { + return contentSecurityPolicyHandler(next, true) +} + +func contentSecurityPolicyHandler(next http.Handler, allowStreamSaverFrame bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + policy := contentSecurityPolicy + if allowStreamSaverFrame && r.URL.Path == streamSaverFramePath { + policy = streamSaverContentSecurityPolicy + } + w.Header().Set("Content-Security-Policy", policy) + next.ServeHTTP(w, r) + }) +} diff --git a/internal/webserver/headers/ContentSecurityPolicy_test.go b/internal/webserver/headers/ContentSecurityPolicy_test.go new file mode 100644 index 00000000..96c77878 --- /dev/null +++ b/internal/webserver/headers/ContentSecurityPolicy_test.go @@ -0,0 +1,79 @@ +package headers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/forceu/gokapi/internal/models" +) + +func TestContentSecurityPolicy(t *testing.T) { + testCases := []struct { + name string + middleware func(http.Handler) http.Handler + requestPath string + expectedPolicy string + }{ + { + name: "baseline", + middleware: ContentSecurityPolicy, + requestPath: "/setup/start", + expectedPolicy: contentSecurityPolicy, + }, + { + name: "setup does not allow StreamSaver framing", + middleware: ContentSecurityPolicy, + requestPath: streamSaverFramePath, + expectedPolicy: contentSecurityPolicy, + }, + { + name: "application allows exact StreamSaver path", + middleware: ContentSecurityPolicyWithStreamSaver, + requestPath: streamSaverFramePath + "?stream=1", + expectedPolicy: streamSaverContentSecurityPolicy, + }, + { + name: "application denies StreamSaver sibling", + middleware: ContentSecurityPolicyWithStreamSaver, + requestPath: "/serviceworker/sw.js", + expectedPolicy: contentSecurityPolicy, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, testCase.requestPath, nil) + testCase.middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(recorder, request) + + actualPolicy := recorder.Result().Header.Get("Content-Security-Policy") + if actualPolicy != testCase.expectedPolicy { + t.Errorf("Content-Security-Policy = %q, want %q", actualPolicy, testCase.expectedPolicy) + } + }) + } +} + +func TestContentSecurityPolicyPreservesInlineFileSandbox(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/downloadFile", nil) + file := models.File{Name: "example.html", ContentType: "text/html", SizeBytes: 42} + + ContentSecurityPolicy(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + Write(file, w, false, false) + })).ServeHTTP(recorder, request) + + policies := recorder.Result().Header.Values("Content-Security-Policy") + if len(policies) != 2 { + t.Fatalf("Content-Security-Policy values = %q, want baseline and sandbox policies", policies) + } + if policies[0] != contentSecurityPolicy { + t.Errorf("baseline policy = %q, want %q", policies[0], contentSecurityPolicy) + } + if policies[1] != "sandbox" { + t.Errorf("inline file policy = %q, want %q", policies[1], "sandbox") + } +} diff --git a/internal/webserver/headers/Headers.go b/internal/webserver/headers/Headers.go index 68b2ec0f..0323eb9b 100644 --- a/internal/webserver/headers/Headers.go +++ b/internal/webserver/headers/Headers.go @@ -17,7 +17,7 @@ func Write(file models.File, w http.ResponseWriter, forceDownload, serveDecrypte disposition := "attachment" if !forceDownload { disposition = "inline" - w.Header().Set("Content-Security-Policy", "sandbox") + w.Header().Add("Content-Security-Policy", "sandbox") } w.Header().Set("Content-Disposition", disposition+"; filename=\""+file.Name+"\"; filename*=UTF-8''"+encodedName)