mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-14 11:11:01 +02:00
fix harden preview against self XSS
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
19 files changed
+421
-118
No files matched your search
@@ -69,6 +69,7 @@ const (
|
||||
ROUTE_V1_USER_MFA_TOTP = "/api/v1/user/mfa/totp"
|
||||
ROUTE_V1_QR_FROM_TOTP = "/api/v1/qr/totp"
|
||||
ROUTE_V1_QR_URL_TO_HTML = "/api/v1/qr/html"
|
||||
ROUTE_V1_PREVIEW = "/api/v1/preview"
|
||||
// session
|
||||
ROUTE_V1_SESSION_ID = "/api/v1/session/:id"
|
||||
ROUTE_V1_SESSION_PING = "/api/v1/session/ping"
|
||||
@@ -279,6 +280,12 @@ type administrationServer struct {
|
||||
embedBackendFS *embed.FS
|
||||
certMagicConfig *certmagic.Config
|
||||
softSessionHandler gin.HandlerFunc
|
||||
// the single page app index html with the SvelteKit csp meta tag removed,
|
||||
// and the full policy promoted to a response header, so the whole csp is
|
||||
// delivered server side. nil when the meta could not be read, in which case
|
||||
// index.html is served unchanged and the policy stays in its meta tag.
|
||||
spaIndexHTML []byte
|
||||
spaCSP string
|
||||
}
|
||||
|
||||
// NewAdministrationServer creates a new administration app
|
||||
@@ -381,6 +388,8 @@ func setupRoutes(
|
||||
// qr
|
||||
POST(ROUTE_V1_QR_FROM_TOTP, middleware.SessionHandler, controllers.QR.ToTOTPURL).
|
||||
POST(ROUTE_V1_QR_URL_TO_HTML, middleware.SessionHandler, controllers.QR.ToHTML).
|
||||
// preview renders admin authored content isolated with a sandbox CSP
|
||||
POST(ROUTE_V1_PREVIEW, middleware.SessionHandler, controllers.Preview.Render).
|
||||
// company
|
||||
POST(ROUTE_V1_COMPANY, middleware.SessionHandler, controllers.Company.Create).
|
||||
POST(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.ChangeName).
|
||||
@@ -843,6 +852,17 @@ func (a *administrationServer) loadEmbeddedFileSystem(
|
||||
embedFS := frontend.GetEmbededFS()
|
||||
// make embedded .html work
|
||||
frontend.LoadHTMLFromEmbedFS(a.router, *embedFS, "build/*.html")
|
||||
// promote the SvelteKit csp meta tag to a response header so the whole
|
||||
// policy is delivered server side and nothing is left in the document. The
|
||||
// meta holds script-src with the build time script hash plus style-src and
|
||||
// the other resource directives; we add the directives a meta can not carry.
|
||||
// If the meta can not be read we leave index.html and its meta untouched.
|
||||
if policy, indexHTML, ok := frontend.ExtractCSPFromIndex(*embedFS); ok {
|
||||
a.spaIndexHTML = indexHTML
|
||||
a.spaCSP = policy + "; frame-ancestors 'none'; form-action 'self'"
|
||||
} else {
|
||||
a.logger.Warn("could not read csp meta from index.html, serving it unchanged")
|
||||
}
|
||||
// serve favicons only to authenticated users, unauthenticated requests get 404
|
||||
// so the file is not indexable by scanners probing common paths
|
||||
for _, faviconPath := range []string{"/favicon.ico", "/favicon.png"} {
|
||||
@@ -862,9 +882,7 @@ func (a *administrationServer) loadEmbeddedFileSystem(
|
||||
if !entry.IsDir() {
|
||||
// special case for the frontpage
|
||||
if path == "index.html" {
|
||||
a.router.GET("/", func(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "build/index.html", nil)
|
||||
})
|
||||
a.router.GET("/", a.serveIndex)
|
||||
continue
|
||||
}
|
||||
// skip favicons, registered separately with session gating
|
||||
@@ -890,13 +908,24 @@ func (a *administrationServer) loadEmbeddedFileSystem(
|
||||
}
|
||||
}
|
||||
// fall back to the root index.html
|
||||
a.router.NoRoute(func(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "build/index.html", nil)
|
||||
})
|
||||
a.router.NoRoute(a.serveIndex)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveIndex serves the single page app shell. When the csp meta was promoted
|
||||
// to a header at startup it serves the stripped html with the full policy header,
|
||||
// which replaces the strict header the security middleware set. Otherwise it
|
||||
// serves the original template and the meta carries the resource directives.
|
||||
func (a *administrationServer) serveIndex(c *gin.Context) {
|
||||
if a.spaIndexHTML != nil {
|
||||
c.Header("Content-Security-Policy", a.spaCSP)
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", a.spaIndexHTML)
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "build/index.html", nil)
|
||||
}
|
||||
|
||||
func (a *administrationServer) StartServer(
|
||||
conf *config.Config,
|
||||
) (chan server.StartupMessage, net.Listener, error) {
|
||||
|
||||
@@ -28,6 +28,7 @@ type Controllers struct {
|
||||
CampaignTemplate *controller.CampaignTemplate
|
||||
Campaign *controller.Campaign
|
||||
QR *controller.QRGenerator
|
||||
Preview *controller.Preview
|
||||
APISender *controller.APISender
|
||||
AllowDeny *controller.AllowDeny
|
||||
GeoIP *controller.GeoIP
|
||||
@@ -163,6 +164,9 @@ func NewControllers(
|
||||
qr := &controller.QRGenerator{
|
||||
Common: common,
|
||||
}
|
||||
preview := &controller.Preview{
|
||||
Common: common,
|
||||
}
|
||||
apiSender := &controller.APISender{
|
||||
Common: common,
|
||||
APISenderService: services.APISender,
|
||||
@@ -273,6 +277,7 @@ func NewControllers(
|
||||
CampaignTemplate: campaignTemplate,
|
||||
Campaign: campaign,
|
||||
QR: qr,
|
||||
Preview: preview,
|
||||
APISender: apiSender,
|
||||
AllowDeny: allowDeny,
|
||||
GeoIP: geoIP,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Preview renders admin authored preview content isolated from the admin origin.
|
||||
type Preview struct {
|
||||
Common
|
||||
}
|
||||
|
||||
// previewMaxBodyBytes caps the posted content so an authenticated request can
|
||||
// not spill an unbounded body to disk during form parsing. A preview is a page
|
||||
// or email template plus inline data urls, well under this.
|
||||
const previewMaxBodyBytes = 20 << 20
|
||||
|
||||
// Render returns the posted content as an html document carrying a sandbox
|
||||
// content security policy. The sandbox flag gives the document an opaque origin,
|
||||
// so scripts in the previewed content can not read the admin session, cookies or
|
||||
// call the API, while the document still gets its own policy and loads its own
|
||||
// external images, styles, fonts and scripts for an accurate preview. The
|
||||
// security headers middleware sets the strict admin policy on every response;
|
||||
// this handler then replaces that header with its own sandbox policy, so the
|
||||
// preview does not inherit it. allow-same-origin is deliberately absent.
|
||||
func (p *Preview) Render(g *gin.Context) {
|
||||
_, _, ok := p.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
g.Request.Body = http.MaxBytesReader(g.Writer, g.Request.Body, previewMaxBodyBytes)
|
||||
content := g.PostForm("content")
|
||||
g.Header(
|
||||
"Content-Security-Policy",
|
||||
"sandbox allow-scripts allow-forms allow-popups allow-modals",
|
||||
)
|
||||
g.Data(http.StatusOK, "text/html; charset=utf-8", []byte(content))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package frontend
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
@@ -10,6 +11,41 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// cspMetaRe matches the content security policy meta tag SvelteKit bakes into
|
||||
// index.html during a hash mode build.
|
||||
var cspMetaRe = regexp.MustCompile(`(?is)<meta[^>]+http-equiv=["']content-security-policy["'][^>]*>`)
|
||||
|
||||
// cspContentRe pulls the policy out of the meta tag's content attribute. A csp
|
||||
// policy contains single quotes but never double quotes, so the content
|
||||
// attribute is double quoted and its value runs to the next double quote.
|
||||
var cspContentRe = regexp.MustCompile(`(?is)content="([^"]*)"`)
|
||||
|
||||
// ExtractCSPFromIndex reads build/index.html from the embedded file system,
|
||||
// pulls the policy out of the SvelteKit content security policy meta tag, and
|
||||
// returns that policy, the html with the meta tag removed, and whether a tag was
|
||||
// found. When nothing is found it returns the original bytes and false so the
|
||||
// caller can fall back to serving the document unchanged.
|
||||
func ExtractCSPFromIndex(embedFS embed.FS) (policy string, indexHTML []byte, ok bool) {
|
||||
data, err := embedFS.ReadFile("build/index.html")
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
tag := cspMetaRe.Find(data)
|
||||
if tag == nil {
|
||||
return "", data, false
|
||||
}
|
||||
m := cspContentRe.FindSubmatch(tag)
|
||||
if m == nil {
|
||||
return "", data, false
|
||||
}
|
||||
policy = strings.TrimSpace(html.UnescapeString(string(m[1])))
|
||||
if policy == "" {
|
||||
return "", data, false
|
||||
}
|
||||
stripped := cspMetaRe.ReplaceAll(data, []byte(""))
|
||||
return policy, stripped, true
|
||||
}
|
||||
|
||||
// The version of gin I used when writting this, did not support using embeded files as html
|
||||
// so I found this solution on good old https://stackoverflow.com/questions/26537299/golang-gin-framework-status-code-without-message-body
|
||||
|
||||
|
||||
@@ -4,11 +4,23 @@ import "github.com/gin-gonic/gin"
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// too many issues with previewing resources
|
||||
// c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-src data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
// The built single page app ships its own CSP as a meta tag inside
|
||||
// index.html: SvelteKit hash mode hashes its inline bootstrap script at
|
||||
// build time and bakes the script and its matching hash together, which a
|
||||
// static file can only carry in the document, not as a response header.
|
||||
// That meta holds the resource directives (script-src with the hash,
|
||||
// style-src, img-src and so on). This header adds only what a meta tag can
|
||||
// not express, frame-ancestors and form-action, plus object-src and
|
||||
// base-uri. It sets no script-src or style-src, so it never conflicts with
|
||||
// the baked hash. It is applied to every admin response, including the
|
||||
// single page app fallback on unmatched paths. The preview handler
|
||||
// replaces this header with its own sandbox policy on its authenticated
|
||||
// response, so the preview stays frameable while every other path keeps
|
||||
// frame-ancestors none.
|
||||
c.Header("Content-Security-Policy", "frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,14 @@ func (c *CampaignTemplate) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
// the redirect url is shown as a link in the admin area, so reject any
|
||||
// scheme other than http or https to keep a javascript url out of an href
|
||||
if v, err := c.AfterLandingPageRedirectURL.Get(); err == nil && v.String() != "" {
|
||||
if err := validate.ErrorIfInvalidURL(v.String()); err != nil {
|
||||
return validate.WrapErrorWithField(err, "afterLandingPageRedirectURL")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,19 @@ func (o *OAuthProvider) Validate() error {
|
||||
if err := validate.NullableFieldRequired("clientSecret", o.ClientSecret); err != nil {
|
||||
return err
|
||||
}
|
||||
// the auth url is opened in the operator browser during authorization, so
|
||||
// reject any scheme other than http or https to keep a javascript url out.
|
||||
// the token url is only called server side but is held to the same rule.
|
||||
if v, err := o.AuthURL.Get(); err == nil {
|
||||
if err := validate.ErrorIfNotHTTPURL(v.String()); err != nil {
|
||||
return validate.WrapErrorWithField(err, "authURL")
|
||||
}
|
||||
}
|
||||
if v, err := o.TokenURL.Get(); err == nil {
|
||||
if err := validate.ErrorIfNotHTTPURL(v.String()); err != nil {
|
||||
return validate.WrapErrorWithField(err, "tokenURL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -428,6 +428,27 @@ func OneOfNullableFieldsRequired(fields map[string]any) error {
|
||||
return fmt.Errorf("one of the fields (%s) must be supplied", strings.Join(keys, ", "))
|
||||
}
|
||||
|
||||
// ErrorIfNotHTTPURL validates that a string parses as a url with the http or
|
||||
// https scheme. Unlike ErrorIfInvalidURL it does not require a dotted public
|
||||
// domain, so a localhost or internal host is allowed. Use it where the goal is
|
||||
// only to keep a javascript or data url out, not to enforce a public host.
|
||||
func ErrorIfNotHTTPURL(urlStr string) error {
|
||||
if urlStr == "" {
|
||||
return errs.NewValidationError(errors.New("URL cannot be empty"))
|
||||
}
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errors.New("must be a valid URL"))
|
||||
}
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return errs.NewValidationError(errors.New("must use http or https protocol"))
|
||||
}
|
||||
if parsedURL.Host == "" {
|
||||
return errs.NewValidationError(errors.New("must have a valid host"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorIfInvalidURL validates that a string is a valid URL with http/https scheme
|
||||
func ErrorIfInvalidURL(urlStr string) error {
|
||||
if urlStr == "" {
|
||||
|
||||
Reference in new issue
Block a user