Allow the registered Home form destination and stack account details
4 files changed,
+73
−2
internal/docs/home_routes.go+3 −1internal/docs/home_security.go+9 −0internal/docs/home_security_test.go+60 −0internal/docs/views/home_link.html+1 −1
CI passed — run details
diff --git a/internal/docs/home_routes.go b/internal/docs/home_routes.go| index 01f05de..7f93cd0 100644 |
| --- a/internal/docs/home_routes.go |
| +++ b/internal/docs/home_routes.go |
| @@ -20,6 +20,7 @@ type homeRoutes struct { |
| bridge *homeBridge |
| navigation *localLinkNavigation |
| handlers *handlers |
| + formPolicy string |
| } |
| func newHomeRoutes(settings *homeSettings, origin string, db *sql.DB, auth *rauth.Auth, h *handlers) (*homeRoutes, error) { |
| @@ -36,7 +37,7 @@ func newHomeRoutes(settings *homeSettings, origin string, db *sql.DB, auth *raut |
| return nil, errHomeConfig |
| } |
| b.returnPath = navigation.returnPath |
| - return &homeRoutes{bridge: b, navigation: navigation, handlers: h}, nil |
| + return &homeRoutes{bridge: b, navigation: navigation, handlers: h, formPolicy: homeFormPolicy(settings.issuer)}, nil |
| } |
| func cookieCount(r *http.Request, name string) int { |
| @@ -59,6 +60,7 @@ func homeCSRF(origin string, home *homeRoutes) func(http.Handler) http.Handler { |
| return func(next http.Handler) http.Handler { |
| checked := protect(next) |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Security-Policy", home.formPolicy) |
| if r.Host != strings.TrimPrefix(origin, "https://") { |
| http.NotFound(w, r) |
| return |
diff --git a/internal/docs/home_security.go b/internal/docs/home_security.go| new file mode 100644 |
| index 0000000..5ef7b0d |
| --- /dev/null |
| +++ b/internal/docs/home_security.go |
| @@ -0,0 +1,9 @@ |
| +package docs |
| + |
| +// Browsers enforce form-action through a form's redirect chain. Linking starts |
| +// with a local POST and reaches Home; allow only the validated credential origin. |
| +// The drift test pins every other directive to the framework's actual default. |
| +func homeFormPolicy(issuer string) string { |
| + return "default-src 'self'; style-src 'self' 'unsafe-inline'; " + |
| + "img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' " + issuer |
| +} |
diff --git a/internal/docs/home_security_test.go b/internal/docs/home_security_test.go| new file mode 100644 |
| index 0000000..7644f1b |
| --- /dev/null |
| +++ b/internal/docs/home_security_test.go |
| @@ -0,0 +1,60 @@ |
| +package docs |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/rastrillo" |
| +) |
| + |
| +func TestHomeFormPolicyPreservesFrameworkAndStandalone(t *testing.T) { |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) |
| + baselineHandler, cleanup, err := rastrillo.Handler(rastrillo.Options{Mux: mux}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if cleanup != nil { |
| + defer cleanup() |
| + } |
| + rec := httptest.NewRecorder() |
| + baselineHandler.ServeHTTP(rec, httptest.NewRequest("GET", bridgeOrigin+"/", nil)) |
| + baseline := rec.Header().Get("Content-Security-Policy") |
| + want := strings.Replace(baseline, "form-action 'self'", "form-action 'self' "+bridgeIssuer, 1) |
| + if want == baseline { |
| + t.Fatal("framework form-action changed; review policy") |
| + } |
| + f := newRouterHomeFixture(t) |
| + wrapped, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.app.Handler()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if done != nil { |
| + defer done() |
| + } |
| + for _, path := range []string{"/signin/local", "/account/home", "/auth/start", "/signed-out"} { |
| + w := httptest.NewRecorder() |
| + wrapped.ServeHTTP(w, httptest.NewRequest("GET", bridgeOrigin+path, nil)) |
| + if got := w.Header().Get("Content-Security-Policy"); got != want { |
| + t.Fatalf("policy drift at %s: %q", path, got) |
| + } |
| + } |
| + app, err := NewForTestWith(t, Config{Origin: bridgeOrigin, SampleDocuments: "0"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + standalone, closeStandalone, err := rastrillo.Handler(rastrillo.Options{Mux: app.Handler()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if closeStandalone != nil { |
| + defer closeStandalone() |
| + } |
| + w := httptest.NewRecorder() |
| + standalone.ServeHTTP(w, httptest.NewRequest("GET", bridgeOrigin+"/signin", nil)) |
| + if got := w.Header().Get("Content-Security-Policy"); got != baseline { |
| + t.Fatal("standalone policy changed") |
| + } |
| +} |
diff --git a/internal/docs/views/home_link.html b/internal/docs/views/home_link.html| index d5b1e02..6da4860 100644 |
| --- a/internal/docs/views/home_link.html |
| +++ b/internal/docs/views/home_link.html |
| @@ -2,7 +2,7 @@ |
| {{define "content"}} |
| <header rst-page-header><div rst-page-header-titles><h1>{{.Title}}</h1></div></header> |
| <div rst-box> |
| - <div rst-box-head><p>{{.Body}}</p>{{if .Address}}<p><strong>{{.Address}}</strong></p>{{end}}</div> |
| + <p>{{.Body}}</p>{{if .Address}}<p style="overflow-wrap: anywhere"><strong>{{.Address}}</strong></p>{{end}} |
| {{if .PostAction}}<form method="post" action="{{.Action}}"><button rst-btn="primary" type="submit">{{.ActionLabel}}</button></form> |
| {{else}}<a rst-btn="primary" href="{{.Action}}">{{.ActionLabel}}</a>{{end}} |
| {{if .CancelLabel}}<a href="/">{{.CancelLabel}}</a>{{end}} |