Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion internal/configuration/setup/Setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/configuration/setup/Setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion internal/webserver/Webserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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") {
Expand Down
39 changes: 39 additions & 0 deletions internal/webserver/Webserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
31 changes: 31 additions & 0 deletions internal/webserver/headers/ContentSecurityPolicy.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
79 changes: 79 additions & 0 deletions internal/webserver/headers/ContentSecurityPolicy_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 1 addition & 1 deletion internal/webserver/headers/Headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down