diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 277b66cf..0378ea91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,7 @@ jobs: -w /app/backend \ golang@sha256:f86f1a6701e3dcc445fec097a42f78b758f15950ccf032c2d3e54e2754d32fdb `# golang:1.27.1-alpine linux/amd64` \ sh -c "apk add --no-cache gcc musl-dev && go build -trimpath \ - -ldflags='-X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }} -linkmode=external -extldflags=-static' \ + -ldflags='-s -w -X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }} -linkmode=external -extldflags=-static' \ -tags production -o ../build/amd64/phishingclub main.go" # build arm64 binary — run natively under QEMU (already set up above) @@ -80,7 +80,7 @@ jobs: -w /app/backend \ golang@sha256:df4c4a0eeb85873e0122c6e2eb1b436f3131576f572505c1ea61954b00fa6460 `# golang:1.27.1-alpine linux/arm64` \ sh -c "apk add --no-cache gcc musl-dev && go build -trimpath \ - -ldflags='-X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }} -linkmode=external -extldflags=-static' \ + -ldflags='-s -w -X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }} -linkmode=external -extldflags=-static' \ -tags production -o ../build/arm64/phishingclub main.go" - name: Fix build directory permissions diff --git a/README.md b/README.md index 71def9e8..a07058a7 100644 --- a/README.md +++ b/README.md @@ -73,19 +73,32 @@ Phishing Club provides a lot of features for simulation and red teaming, such as Wrote a blog post or write up about Phishing Club? Tell us about it and we might add it here. Reach out via a GitHub issue, discord or find our email :) -## Template Development +## More from Phishing Club -### Phishing Template Workbench +Open source projects and learning resources built for students and red teamers. -Speed up your template development with our template workbench tool: +### Training Labs -**[Phishing Template Workbench](https://github.com/phishingclub/templates)** - A developer-focused environment for creating and testing phishing simulation templates. +**[Phishing Club Training Labs](https://labs.phishing.club/)** - Free hands on labs for adversary in the middle and remote browser phishing. Steal the session, not just the password. Capture credentials, hijack live sessions, and defeat real defenses like CSP pinning, beacon detection and passkey prompts. -- **Preview** - Preview templates -- **Variable support** - See `{{.FirstName}}`, `{{.Email}}` substitution with realistic sample data +### Session Sushi + +**[Session Sushi](https://github.com/phishingclub/session-sushi)** - A zero dependency browser extension for handling cookies, Microsoft 365 OAuth tokens, and Graph API interactions. Built for security professionals. + +- **Cookie Management** - View, import/export as JSON, search, filter, and clear cookies, with incognito session support +- **Microsoft 365 Sessions** - Acquire OAuth tokens, store multiple M365 refresh tokens as sessions, refresh manually or automatically, and import/export sessions +- **M365 Data Browsers** - Graph, User, Directory, Mailbox, Calendar, OneDrive, SharePoint and Teams + +### Template Workbench + +**[Phishing Template Workbench](https://github.com/phishingclub/templates)** - A developer focused environment for creating and testing phishing simulation templates. + +- **Preview** - See how templates render with test data +- **Variable support** - `{{.BaseURL}}`, `{{.Email}}`, `{{.FirstName}}` substitution with realistic sample data - **Naive Responsive Testing** - Preview templates across mobile, tablet, and desktop -- **Export Ready** - Compatible with Phishing Club formats -- **Included Templates** - Comes with example templates covering common phishing scenarios that you can import and customize +- **Naive Email Testing** - Check email templates in Mailpit and score HTML/CSS and SpamAssassin across clients +- **Asset Management** - Resolve template assets with fallback to global asset directories +- **Export Ready** - Copy processed HTML, download template folders, and export collections compatible with Phishing Club ## Development diff --git a/RELEASE.md b/RELEASE.md index f07c36cd..3fc91b88 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,36 @@ # Changelog +## [1.43.0] - 2026-10-01 +- Added scripts feature with support in campaigns, AiTM proxy and Remote Browser +- Added custom branding settings +- Added AiTM Proxy status engine +- Added Remote Browser request() API. +- Added Remote Browser state machine API for managing flow in sessions +- Added Remote Browser can manipulate headers +- Added hotkey for VIM toggle in editor +- Added custom EHLO/HELO on SMTP +- Added updatable external IP and ASN data for filters +- Added ASN look up tool +- Added change of campaign trend graph type when over 60 campaigns in view +- Updated Remote Browser default template +- Fix filters deleted during scheduling update +- Fix modal text is now globally selectable +- Fix message-id was leaking internals +- Fix added implicit TLS on SMTP when using port 465 +- Fix malformed request when using browser impersonation +- Fix AiTM Proxy did not collect cookie when required was set to false +- Fix global company assets could not be deleted +- Fix improve error message when browser start fails +- Fix campaign creation schedule time matched against current time in some timezones +- Fix domain listing could 500 via API +- Fix Remote Browser 'disableFidoUI' and renamed it - Deprecated disableFioUI +- Fix loader location on settings page +- Fix send test email search recipient ondemand +- Fix alignment on TextFieldSearchSelect component +- Fix reduce binary size ~50% by stripping js.map's and debug from build +- Deprecated notification for embedded IP filter list +- Bumped dependencies + ## [1.42.0] - 2026-09-11 - Added position and department statistics to campaigns - Added anonymous campaign mode diff --git a/backend/acme/certmagic.go b/backend/acme/certmagic.go index 9eeef127..c031d775 100644 --- a/backend/acme/certmagic.go +++ b/backend/acme/certmagic.go @@ -62,7 +62,7 @@ func setupCertMagic( return nil } // allow on-demand ACME only for domains that use managed TLS (let's encrypt / acme). - // own_managed_tls and self_signed_tls domains must never trigger ACME acquisition — + // own_managed_tls and self_signed_tls domains must never trigger ACME acquisition, // their certificates are provided manually or generated internally. res := db. Select("id"). diff --git a/backend/app/administration.go b/backend/app/administration.go index 2911856a..7d0eca8c 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -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" @@ -85,7 +86,7 @@ const ( ROUTE_V1_COMPANY_REPORT_CONFIG_LOG = "/api/v1/company/report-config/:companyID/log" ROUTE_V1_REPORT_CONFIG_GLOBAL = "/api/v1/report-config" ROUTE_V1_REPORT_CONFIG_SEND = "/api/v1/report-config/send/:id" - // scim v2 provisioning endpoints (public — authenticated via bearer token) + // scim v2 provisioning endpoints (public, authenticated via bearer token) ROUTE_SCIM_V2_SERVICE_PROVIDER_CONFIG = "/api/v1/scim/v2/:companyID/ServiceProviderConfig" ROUTE_SCIM_V2_RESOURCE_TYPES = "/api/v1/scim/v2/:companyID/ResourceTypes" ROUTE_SCIM_V2_SCHEMAS = "/api/v1/scim/v2/:companyID/Schemas" @@ -207,6 +208,9 @@ const ( ROUTE_V1_ASSET_DOMAIN_CONTEXT = "/api/v1/asset/domain/:domain" ROUTE_V1_ASSET_GLOBAL_CONTEXT = "/api/v1/asset/domain/" ROUTE_V1_ASSET_DOMAIN_VIEW = "/api/v1/asset/view/domain/:domain/*path" + ROUTE_V1_ASSET_ID_CONTENT = "/api/v1/asset/:id/content" + ROUTE_V1_ASSET_ID_MOVE = "/api/v1/asset/:id/move" + ROUTE_V1_ASSET_ID_FILE = "/api/v1/asset/:id/file" // attachments ROUTE_V1_ATTACHMENT = "/api/v1/attachment" ROUTE_V1_ATTACHMENT_ID = "/api/v1/attachment/:id" @@ -276,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 @@ -312,12 +322,13 @@ func setupRoutes( controllers *Controllers, middleware *Middlewares, ) *gin.Engine { - // SCIM v2 provisioning endpoints are NOT served here — they live on the + // SCIM v2 provisioning endpoints are NOT served here, they live on the // phishing server (app/server.go AssignRoutes), gated to a single global // domain, so the admin port does not need public exposure for SCIM. - // all other admin routes are protected by the ip allowlist middleware - admin := r.Group("/", middleware.IPLimiter) + // ip allowlist on all admin routes; NoStore keeps API responses out of the + // browser disk cache. static assets are served off the group and stay cacheable. + admin := r.Group("/", middleware.IPLimiter, middleware.NoStore) _ = admin if !build.Flags.Production { @@ -377,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). @@ -548,7 +561,7 @@ func setupRoutes( POST(ROUTE_V1_CAMPAIGN_ANONYMIZE_DATA, middleware.SessionHandler, controllers.Campaign.AnonymizeDataByID). DELETE(ROUTE_V1_CAMPAIGN_DEVICE_CODES, middleware.SessionHandler, controllers.Campaign.DeleteDeviceCodesByCampaignID). DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID). - // campaign PDF report — ExtendedTimeout required for headless browser rendering + // campaign PDF report, ExtendedTimeout required for headless browser rendering GET(ROUTE_V1_CAMPAIGN_REPORT, middleware.ExtendedTimeout(3*time.Minute), middleware.SessionHandler, controllers.ReportTemplate.GeneratePDFByCampaignID). // report templates GET(ROUTE_V1_REPORT_TEMPLATE, middleware.SessionHandler, controllers.ReportTemplate.GetAll). @@ -568,6 +581,10 @@ func setupRoutes( GET(ROUTE_V1_ASSET_DOMAIN_VIEW, middleware.SessionHandler, controllers.Asset.GetContentByID). GET(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.GetByID). PATCH(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.UpdateByID). + GET(ROUTE_V1_ASSET_ID_CONTENT, middleware.SessionHandler, controllers.Asset.GetEditableContentByID). + PUT(ROUTE_V1_ASSET_ID_CONTENT, middleware.SessionHandler, controllers.Asset.SaveContentByID). + PATCH(ROUTE_V1_ASSET_ID_MOVE, middleware.SessionHandler, controllers.Asset.MoveByID). + POST(ROUTE_V1_ASSET_ID_FILE, middleware.SessionHandler, controllers.Asset.ReplaceFileByID). GET(ROUTE_V1_ASSET_DOMAIN_CONTEXT, middleware.SessionHandler, controllers.Asset.GetAllForContext). GET(ROUTE_V1_ASSET_GLOBAL_CONTEXT, middleware.SessionHandler, controllers.Asset.GetAllForContext). POST(ROUTE_V1_ASSET, middleware.SessionHandler, controllers.Asset.Create). @@ -835,7 +852,18 @@ func (a *administrationServer) loadEmbeddedFileSystem( embedFS := frontend.GetEmbededFS() // make embedded .html work frontend.LoadHTMLFromEmbedFS(a.router, *embedFS, "build/*.html") - // serve favicons only to authenticated users — unauthenticated requests get 404 + // 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"} { fp := faviconPath @@ -854,12 +882,10 @@ 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 + // skip favicons, registered separately with session gating if path == "favicon.png" || path == "favicon.ico" { continue } @@ -882,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) { diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 7c5845d7..661da11c 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -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, diff --git a/backend/app/middleware.go b/backend/app/middleware.go index 6b838868..e45b7213 100644 --- a/backend/app/middleware.go +++ b/backend/app/middleware.go @@ -15,6 +15,7 @@ type Middlewares struct { LoginRateLimiter gin.HandlerFunc SessionHandler gin.HandlerFunc SoftSessionHandler gin.HandlerFunc + NoStore gin.HandlerFunc } // NewMiddlewares creates a collection of middlewares @@ -48,6 +49,7 @@ func NewMiddlewares( LoginRateLimiter: loginThrottle, SessionHandler: sessionHandler, SoftSessionHandler: softSessionHandler, + NoStore: middleware.NoStore(), } } diff --git a/backend/app/server.go b/backend/app/server.go index df6fa3b7..80648ad8 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -1664,7 +1664,7 @@ func (s *Server) checkAndServePhishingPage( // expects when the request arrives at the proxy. urlParam := cTemplate.URLIdentifier.Name.MustGet() - // collect rewrite_urls rules for the start domain — host-specific first, then global + // collect rewrite_urls rules for the start domain, host-specific first, then global var rewriteRules []service.ProxyServiceURLRewriteRule if hostCfg, ok := parsedConfig.Hosts[startDomain]; ok && hostCfg != nil { rewriteRules = append(rewriteRules, hostCfg.RewriteURLs...) diff --git a/backend/controller/apiSender.go b/backend/controller/apiSender.go index 88394bcb..9d6b66c5 100644 --- a/backend/controller/apiSender.go +++ b/backend/controller/apiSender.go @@ -182,10 +182,16 @@ func (a *APISender) SendTest(g *gin.Context) { if !ok { return } + // parse the optional test values, an empty body means use the defaults + var req model.APISenderTestRequest + if ok := a.handleParseOptionalRequest(g, &req); !ok { + return + } data, err := a.APISenderService.SendTest( g.Request.Context(), session, id, + &req, ) // output the error if err != nil { diff --git a/backend/controller/asset.go b/backend/controller/asset.go index 95d667ea..624140f8 100644 --- a/backend/controller/asset.go +++ b/backend/controller/asset.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "github.com/go-errors/errors" @@ -482,6 +483,197 @@ func (a *Asset) UpdateByID(g *gin.Context) { a.Response.OK(g, gin.H{}) } +// GetEditableContentByID returns an asset's content and whether it can be +// edited as text in the editor. +func (a *Asset) GetEditableContentByID(g *gin.Context) { + // handle session + session, _, ok := a.handleSession(g) + if !ok { + return + } + // parse request + id, ok := a.handleParseIDParam(g) + if !ok { + return + } + // cap the read at the configured upload size so a huge asset is not loaded + // into memory just to answer an edit request + maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB) + if ok := a.handleErrors(g, err); !ok { + return + } + maxBytes := int64(64 << 20) + // only a positive option overrides the fallback, so a "0" or negative option + // cannot disable the cap and allow an unbounded read. + if maxMB, perr := strconv.Atoi(maxFile.Value.String()); perr == nil && maxMB > 0 { + maxBytes = int64(maxMB) * 1024 * 1024 + } + // get the content + ctx := g.Request.Context() + content, editable, err := a.AssetService.GetContentByID(ctx, session, id, maxBytes) + if a.fileMissing(g, err) { + return + } + if ok := a.handleErrors(g, err); !ok { + return + } + a.Response.OK(g, gin.H{ + "content": base64.StdEncoding.EncodeToString(content), + "editable": editable, + }) +} + +// fileMissing answers 404 when the error is a backing file that is gone (a DB +// row whose file was removed), instead of letting it fall through to a 500. +func (a *Asset) fileMissing(g *gin.Context, err error) bool { + if err != nil && os.IsNotExist(err) { + a.Response.NotFound(g) + return true + } + return false +} + +// SaveContentByID overwrites a text asset's content from the editor. +func (a *Asset) SaveContentByID(g *gin.Context) { + // handle session + session, _, ok := a.handleSession(g) + if !ok { + return + } + // parse request + id, ok := a.handleParseIDParam(g) + if !ok { + return + } + // load the size limit first so the request body can be capped before it is + // read into memory, instead of only checking the size after buffering it all. + maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB) + if ok := a.handleErrors(g, err); !ok { + return + } + // cap the body generously above the content limit since JSON adds quoting and + // escaping; the exact size is still checked below. a malformed option falls + // back to a large cap so valid saves are not blocked at this stage. + bodyLimit := int64(64 << 20) + if maxMB, perr := strconv.Atoi(maxFile.Value.String()); perr == nil && maxMB > 0 { + bodyLimit = int64(maxMB)*1024*1024 + (1 << 20) + } + g.Request.Body = http.MaxBytesReader(g.Writer, g.Request.Body, bodyLimit) + + var req struct { + Content string `json:"content"` + } + if ok := a.handleParseRequest(g, &req); !ok { + return + } + content := []byte(req.Content) + okSize, err := utils.CompareFileSizeFromString(int64(len(content)), maxFile.Value.String()) + if err != nil { + a.Logger.Errorw("invalid max upload size option", "value", maxFile.Value.String(), "error", err) + a.Response.ServerErrorMessage(g, "upload size limit is misconfigured") + return + } + if !okSize { + a.Response.ValidationFailed(g, "Content", fmt.Errorf("content is too large")) + return + } + // save the content + ctx := g.Request.Context() + err = a.AssetService.SaveContentByID(ctx, session, id, content) + if a.fileMissing(g, err) { + return + } + if ok := a.handleErrors(g, err); !ok { + return + } + a.Response.OK(g, gin.H{}) +} + +// MoveByID renames or moves an asset to a new path within its context. +func (a *Asset) MoveByID(g *gin.Context) { + // handle session + session, _, ok := a.handleSession(g) + if !ok { + return + } + // parse request + id, ok := a.handleParseIDParam(g) + if !ok { + return + } + var req struct { + Path string `json:"path"` + } + if ok := a.handleParseRequest(g, &req); !ok { + return + } + path, err := vo.NewRelativeFilePath(req.Path) + if err != nil { + a.Logger.Debugw("failed to parse path", "error", err) + a.Response.ValidationFailed(g, "Path", err) + return + } + // move the asset + ctx := g.Request.Context() + err = a.AssetService.MoveByID(ctx, session, id, nullable.NewNullableWithValue(*path)) + if a.fileMissing(g, err) { + return + } + if ok := a.handleErrors(g, err); !ok { + return + } + a.Response.OK(g, gin.H{}) +} + +// ReplaceFileByID replaces an asset's file content with an uploaded file. +func (a *Asset) ReplaceFileByID(g *gin.Context) { + // handle session + session, _, ok := a.handleSession(g) + if !ok { + return + } + // parse request + id, ok := a.handleParseIDParam(g) + if !ok { + return + } + file, err := g.FormFile("file") + if err != nil { + a.Logger.Debugw("no file provided", "error", err) + a.Response.BadRequestMessage(g, "No file selected") + return + } + // enforce the max file size option + maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB) + if ok := a.handleErrors(g, err); !ok { + return + } + okSize, err := utils.CompareFileSizeFromString(file.Size, maxFile.Value.String()) + if err != nil { + a.Logger.Errorw("invalid max upload size option", "value", maxFile.Value.String(), "error", err) + a.Response.ServerErrorMessage(g, "upload size limit is misconfigured") + return + } + if !okSize { + a.Response.ValidationFailed( + g, + "File", + fmt.Errorf("file '%s' is too large", utils.ReadableFileName(file.Filename)), + ) + return + } + // replace the file + ctx := g.Request.Context() + err = a.AssetService.ReplaceFileByID(ctx, session, id, file) + if a.fileMissing(g, err) { + return + } + if ok := a.handleErrors(g, err); !ok { + return + } + a.Response.OK(g, gin.H{}) +} + // RemoveByID removes an static asset // if the asset is a directory, it will be removed recursively func (a *Asset) RemoveByID(g *gin.Context) { diff --git a/backend/controller/preview.go b/backend/controller/preview.go new file mode 100644 index 00000000..6704bb3c --- /dev/null +++ b/backend/controller/preview.go @@ -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)) +} diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index 9bc124a6..d033c491 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -102,7 +102,7 @@ func (a *activeSession) setBrowserPage(page *rod.Page) { // interest in it. get returns the latest frame (nil until the first arrives); release // drops interest and stops the screencast when the last consumer leaves. Fixed params // (native-resolution ceiling, quality 90) so every consumer sees identical frames -// regardless of who else is watching — this is what keeps a victim stream invariant to +// regardless of who else is watching, this is what keeps a victim stream invariant to // operator presence. func (a *activeSession) scAcquire(page *rod.Page) (get func() *scFrame, release func()) { tid := page.TargetID @@ -532,7 +532,7 @@ func (m *RemoteBrowserController) RunByID(g *gin.Context) { } }() - // Drain StreamCh — test runner doesn't serve cropped streams. + // Drain StreamCh, test runner doesn't serve cropped streams. go func() { for range runner.StreamCh { } @@ -698,7 +698,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { } sess.victimConnected.Store(true) - // One active session per campaign recipient — cancel any previous one. + // One active session per campaign recipient, cancel any previous one. // Exception: if the previous session is in keepAlive state the script has // parked and is waiting for operator takeover; cancelling it would destroy // a live browser the operator may be about to use. In that case put the @@ -714,7 +714,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { } defer func() { // For keepAlive sessions the runner is still parked waiting for the - // operator — do not cancel or remove it here. CloseLiveSession handles + // operator, do not cancel or remove it here. CloseLiveSession handles // cleanup when the operator explicitly ends the session. if !sess.isKeepAlive.Load() { m.RemoteBrowserService.CompareAndDeleteSession(crIDStr, sess) @@ -785,7 +785,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { case runner.Incoming <- remotebrowser.IncomingMsg{Event: "disconnect"}: default: } - // keepAlive: browser is parked for operator takeover — a victim + // keepAlive: browser is parked for operator takeover, a victim // disconnect must not kill the session, the operator still needs it. if !sess.isKeepAlive.Load() { cancel() @@ -1569,7 +1569,7 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) { // Editor test runs only: size the target to the admin's own resolution // so remote control is pixel-accurate instead of the headless 800x600 // default. Gated on isTest so a live recipient's viewport is never - // touched here — the victim owns the shared target's size, and resizing + // touched here, the victim owns the shared target's size, and resizing // it under an operator would change what the recipient sees. if controlMode && sess.isTest && header.Width > 0 && header.Height > 0 { if p := getActivePage(); p != nil { @@ -2228,7 +2228,7 @@ func (m *RemoteBrowserController) runNamedStream( // Subscribe to the session's shared screencast for this page. Victim frames are // always cropped from this one screencast, so the stream is identical whether or not - // an operator is also viewing — no operator-presence leak. releaseSC stops the + // an operator is also viewing, no operator-presence leak. releaseSC stops the // screencast once this and every other consumer of the target has gone. getFrame, releaseSC := sess.scAcquire(page) defer releaseSC() diff --git a/backend/controller/scim.go b/backend/controller/scim.go index 3dae49d5..3a7e908c 100644 --- a/backend/controller/scim.go +++ b/backend/controller/scim.go @@ -430,7 +430,7 @@ func (c *Scim) DeleteGroup(g *gin.Context) { return } go c.ScimService.UpdateLastSync(context.Background(), result.Config) - // rfc 7644 §3.6 — successful DELETE returns 204 No Content + // rfc 7644 §3.6, successful DELETE returns 204 No Content g.Status(http.StatusNoContent) } @@ -634,7 +634,7 @@ func (c *Scim) DeleteUser(g *gin.Context) { return } go c.ScimService.UpdateLastSync(context.Background(), result.Config) - // RFC 7644 §3.6 — successful DELETE returns 204 No Content + // RFC 7644 §3.6, successful DELETE returns 204 No Content g.Status(http.StatusNoContent) } diff --git a/backend/controller/utils.go b/backend/controller/utils.go index 97a7a349..f42cb7a7 100644 --- a/backend/controller/utils.go +++ b/backend/controller/utils.go @@ -83,6 +83,26 @@ func (c *Common) handleGlobalAuthorization( func (c *Common) handleParseRequest( g *gin.Context, req any, +) bool { + return c.parseRequest(g, req, false) +} + +// handleParseOptionalRequest parses a request body like handleParseRequest but +// treats an empty body as no input, leaving req at its zero value. Use it for +// endpoints where every field is optional. +func (c *Common) handleParseOptionalRequest( + g *gin.Context, + req any, +) bool { + return c.parseRequest(g, req, true) +} + +// parseRequest reads and unmarshals the request body. When allowEmpty is true an +// empty body is accepted and leaves req untouched. +func (c *Common) parseRequest( + g *gin.Context, + req any, + allowEmpty bool, ) bool { body, err := io.ReadAll(g.Request.Body) if err != nil { @@ -92,6 +112,9 @@ func (c *Common) handleParseRequest( c.Response.BadRequest(g) return false } + if allowEmpty && len(bytes.TrimSpace(body)) == 0 { + return true + } if err := utils.Unmarshal(body, &req); err != nil { c.Logger.Debugw("failed to parse request", "error", err, diff --git a/backend/embedded/remotebrowser_inject.js b/backend/embedded/remotebrowser_inject.js index 1a99bd29..6e91b840 100644 --- a/backend/embedded/remotebrowser_inject.js +++ b/backend/embedded/remotebrowser_inject.js @@ -85,7 +85,7 @@ streamLastStart[m.name] = m; var st = streams[m.name]; if (st) { - // Stream already mounted — this is a resize/reposition update only. + // Stream already mounted, this is a resize/reposition update only. // Do NOT re-fire user handlers; that would call mountStream() again // and create duplicate canvases. applyStreamStart(st, m); diff --git a/backend/frontend/embed.go b/backend/frontend/embed.go index 168a28a4..3c8142d4 100644 --- a/backend/frontend/embed.go +++ b/backend/frontend/embed.go @@ -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)]+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 diff --git a/backend/middleware/noStore.go b/backend/middleware/noStore.go new file mode 100644 index 00000000..c0e2b86b --- /dev/null +++ b/backend/middleware/noStore.go @@ -0,0 +1,12 @@ +package middleware + +import "github.com/gin-gonic/gin" + +// NoStore keeps responses out of the browser disk cache. A handler may set +// its own Cache-Control afterwards to override it. +func NoStore() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.Next() + } +} diff --git a/backend/middleware/ratelimiter.go b/backend/middleware/ratelimiter.go index 69ee52e8..2681987b 100644 --- a/backend/middleware/ratelimiter.go +++ b/backend/middleware/ratelimiter.go @@ -112,7 +112,7 @@ func (r *KeyRateLimiter) GetLimiter(key string) *rate.Limiter { entry.lastAccess.Store(time.Now().UnixNano()) // LoadOrStore atomically either stores our new entry or returns the - // existing one — correctly handles both the common case and concurrent + // existing one, correctly handles both the common case and concurrent // goroutines racing to create an entry for the same key actual, loaded := r.key.LoadOrStore(key, entry) if loaded { diff --git a/backend/middleware/securityHeaders.go b/backend/middleware/securityHeaders.go index 3a273570..6b871c56 100644 --- a/backend/middleware/securityHeaders.go +++ b/backend/middleware/securityHeaders.go @@ -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() } } diff --git a/backend/middleware/session.go b/backend/middleware/session.go index 6cfc627e..c1c3e3db 100644 --- a/backend/middleware/session.go +++ b/backend/middleware/session.go @@ -98,7 +98,7 @@ func handleAPISession( found := false // compare each key using constant-time comparison to avoid hash timing leaks. // early exit via break is acceptable here because the key slice is derived from - // a go map, which randomizes iteration order on every call — an attacker cannot + // a go map, which randomizes iteration order on every call, an attacker cannot // correlate iteration count to a stable key position across requests. var rApiUser *model.APIUser for _, apiUser := range apiUsers { diff --git a/backend/model/apiSender.go b/backend/model/apiSender.go index 2968cc8d..04a8d1a0 100644 --- a/backend/model/apiSender.go +++ b/backend/model/apiSender.go @@ -37,6 +37,25 @@ type APISender struct { ExpectedResponseBody nullable.Nullable[vo.OptionalString1MB] `json:"expectedResponseBody"` } +// APISenderTestRequest holds optional values the user sets when running a +// test send. A nil or empty field falls back to the built in default. +type APISenderTestRequest struct { + Email *string `json:"email"` + FirstName *string `json:"firstName"` + LastName *string `json:"lastName"` + Subject *string `json:"subject"` + Content *string `json:"content"` + FromEmail *string `json:"fromEmail"` + FromName *string `json:"fromName"` + Phone *string `json:"phone"` + ExtraIdentifier *string `json:"extraIdentifier"` + Position *string `json:"position"` + Department *string `json:"department"` + City *string `json:"city"` + Country *string `json:"country"` + Misc *string `json:"misc"` +} + // Validate checks if the API sender has a valid state func (a *APISender) Validate() error { if err := validate.NullableFieldRequired("name", a.Name); err != nil { diff --git a/backend/model/campaign.go b/backend/model/campaign.go index 2b416b2c..1a3d1792 100644 --- a/backend/model/campaign.go +++ b/backend/model/campaign.go @@ -250,7 +250,7 @@ func (c *Campaign) Validate() error { return validate.WrapErrorWithField(errors.New("anonymize at must be after close date"), "AnonymizeAt") } } - if v, err := c.SendEndAt.Get(); err != nil { + if v, err := c.SendEndAt.Get(); err == nil { if anonymizeAt.Before(v) { return validate.WrapErrorWithField(errors.New("anonymize at must be after end date"), "AnonymizeAt") } diff --git a/backend/model/campaignTemplate.go b/backend/model/campaignTemplate.go index c5d44cb6..d42c84b5 100644 --- a/backend/model/campaignTemplate.go +++ b/backend/model/campaignTemplate.go @@ -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 } diff --git a/backend/model/oauthProvider.go b/backend/model/oauthProvider.go index f74f07dd..2f37fb53 100644 --- a/backend/model/oauthProvider.go +++ b/backend/model/oauthProvider.go @@ -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 } diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 45617dee..affd50f8 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -2811,7 +2811,7 @@ func (m *ProxyHandler) rewriteRuleMatchesRequest(rule service.ProxyServiceReplac } if rule.Path != "" { if rule.PathRe == nil { - // path was set but failed to compile — skip rule safely + // path was set but failed to compile, skip rule safely return false } if !rule.PathRe.MatchString(req.URL.Path) { @@ -2889,11 +2889,11 @@ func (m *ProxyHandler) applyReplacementWithVariables(body []byte, replacement se case "dom": return m.applyDomReplacement(body, interpolatedReplacement, sessionID, contentType) case "header": - // header engine operates directly on http.Header — it cannot be used in a body rewrite context. + // header engine operates directly on http.Header, it cannot be used in a body rewrite context. // callers that handle headers (applyCustomResponseHeaderReplacementsWithVariables, // applyCustomResponseHeaderReplacementsWithoutSession, applyEarlyRequestHeaderReplacements) // intercept this engine before reaching here. - m.logger.Errorw("header engine used in a non-header rewrite context — use from: request_header or response_header", "sessionID", sessionID, "rule", replacement.Name) + m.logger.Errorw("header engine used in a non-header rewrite context, use from: request_header or response_header", "sessionID", sessionID, "rule", replacement.Name) return body default: m.logger.Errorw("unsupported replacement engine", "engine", engine, "sessionID", sessionID) @@ -3840,7 +3840,7 @@ func (m *ProxyHandler) buildCampaignFlowRedirectURL(session *service.ProxySessio } // createCampaignInfoEvent saves captured data as a low-priority info event instead of a submit event. -// The capture still participates in completion tracking and flow progression normally — only the +// The capture still participates in completion tracking and flow progression normally, only the // saved event type differs. func (m *ProxyHandler) createCampaignInfoEvent(session *service.ProxySession, capturedData map[string]interface{}, req *http.Request, originalUserAgent string) { if session.CampaignID == nil || session.CampaignRecipientID == nil { @@ -4785,7 +4785,7 @@ func (m *ProxyHandler) checkAndServeEvasionPage(req *http.Request, reqCtx *Reque } // build the post-evasion redirect url using the start url as the source of truth for - // path and query params — the incoming request only has the campaign id param, not the + // path and query params, the incoming request only has the campaign id param, not the // real start url params (client_id, redirect_uri etc.). apply rewrite_urls rules so the // victim sees the friendly path and remapped param names, not the real ones. var startPath string @@ -4805,7 +4805,7 @@ func (m *ProxyHandler) checkAndServeEvasionPage(req *http.Request, reqCtx *Reque startQuery = url.Values{} } - // collect rewrite_urls rules — host-specific first, then global + // collect rewrite_urls rules, host-specific first, then global var rewriteRules []service.ProxyServiceURLRewriteRule if hostCfg, ok := reqCtx.ProxyConfig.Hosts[reqCtx.TargetDomain]; ok && hostCfg != nil { rewriteRules = append(rewriteRules, hostCfg.RewriteURLs...) diff --git a/backend/remotebrowser/browser.go b/backend/remotebrowser/browser.go index 13da2765..051cc41b 100644 --- a/backend/remotebrowser/browser.go +++ b/backend/remotebrowser/browser.go @@ -82,7 +82,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, // frameCtxs tracks execution contexts for same-process sub-frames, keyed by context ID. // Value is [3]string{frameId, origin, name}. Populated on demand by resolveSameOriginFrames // via Page.createIsolatedWorld (not Runtime execution-context events, which would enable - // the Runtime domain — a CDP tell). frameWorlds caches which frame already has a world so + // the Runtime domain, a CDP tell). frameWorlds caches which frame already has a world so // we don't recreate one every scan; navigation invalidates the entry. var frameCtxs sync.Map // proto.RuntimeExecutionContextID → [3]string{frameId, origin, name} var frameWorlds sync.Map // frameId string → proto.RuntimeExecutionContextID @@ -125,7 +125,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, // IMPORTANT (opsec): do NOT subscribe to any Runtime.* events here. rod auto-enables // a domain for every event type passed to EachEvent, and enabling the Runtime domain - // is a detectable CDP tell — the console/Error.stack serialization leak that trips + // is a detectable CDP tell, the console/Error.stack serialization leak that trips // isAutomatedWithCDP. We only track OOPIF targets (Target.*, no such leak); same-origin // sub-frame contexts are resolved on demand via Page.createIsolatedWorld instead. waitFrameEvt := page.EachEvent( @@ -151,7 +151,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, return false }, // Page.* is safe to subscribe to (no Runtime-enable tell). On navigation a frame's - // isolated world is destroyed, so drop the cache entry — resolveSameOriginFrames + // isolated world is destroyed, so drop the cache entry, resolveSameOriginFrames // recreates it on the next scan. func(e *proto.PageFrameNavigated) bool { if e.Frame == nil { @@ -176,7 +176,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, // same-process child frame (Page.createIsolatedWorld). This replaces the old Runtime // execution-context event tracking, which enabled the Runtime domain (a CDP tell). // OOPIF frames live in another process, so createIsolatedWorld fails for them and they - // are skipped — those are scanned separately via framePages. Worlds are cached per + // are skipped, those are scanned separately via framePages. Worlds are cached per // frame (frameWorlds) and invalidated on navigation, so scanning does not churn worlds. // Throttled so a tight poll loop issues at most one getFrameTree per interval. var lastFrameResolve time.Time diff --git a/backend/remotebrowser/runner.go b/backend/remotebrowser/runner.go index a3dd2b8c..fa056fa2 100644 --- a/backend/remotebrowser/runner.go +++ b/backend/remotebrowser/runner.go @@ -343,7 +343,7 @@ var knownGoErrors = []struct { substr string message string }{ - {"connection refused", "browser connection refused — is Chrome running?"}, + {"connection refused", "browser connection refused, is Chrome running?"}, {"use of closed network connection", "browser connection closed unexpectedly"}, {"i/o timeout", "browser CDP connection timed out"}, {"EOF", "browser disconnected unexpectedly"}, @@ -512,8 +512,8 @@ func (r *Runner) Run(ctx context.Context) error { vm := goja.New() // Interrupt the JS VM on termination. Two cases: - // DeadlineExceeded — real timeout: interrupt immediately. - // Canceled — either keepAlive() cancelled the script timeout to + // DeadlineExceeded, real timeout: interrupt immediately. + // Canceled , either keepAlive() cancelled the script timeout to // park the session, or the operator cancelled. Either // way wait for the outer context so we only interrupt // when the operator actually ends the session. @@ -1054,16 +1054,16 @@ func (r *Runner) Run(ctx context.Context) error { } // Note: console.* are deliberately left native. Replacing them with noop // functions (to suppress page logging) makes console.log.toString() non-native, - // which trips TamperedFunctions. The console is not captured anyway — we never + // which trips TamperedFunctions. The console is not captured anyway, we never // enable the Runtime domain (see the opsec note in browser.go), so no page // console output reaches the server regardless. } // Identity handling differs by mode: - // local — the UA is already set via the --user-agent launch flag above, + // local , the UA is already set via the --user-agent launch flag above, // which keeps every context (including service workers) consistent // and preserves Chrome's native client hints. No CDP override here. - // remote — no launch flag is possible, so align the main frame via CDP: + // remote, no launch flag is possible, so align the main frame via CDP: // fetch the real UA, strip any "HeadlessChrome" token, and set a // matching platform and client-hint metadata. if opts.Remote != "" { @@ -1124,7 +1124,7 @@ func (r *Runner) Run(ctx context.Context) error { _, err := fn(goja.Undefined(), tmpSession) if err != nil { // If our own timeout context expired, return false instead of - // propagating — lets callers branch without try/catch. + // propagating, lets callers branch without try/catch. if tCtx.Err() != nil { return vm.ToValue(false) } @@ -1163,14 +1163,14 @@ func (r *Runner) Run(ctx context.Context) error { Time: time.Now().UTC().Format(time.RFC3339Nano), }) r.keepAliveActive.Store(true) - timeoutCancel() // release script timeout — operator controls lifetime now + timeoutCancel() // release script timeout, operator controls lifetime now return goja.Undefined() }) // Event-driven API: s.on(event, fn) + s.listen() + s.done() // Built-in lifecycle events emitted by the server: - // "disconnect" — victim WebSocket connection dropped - // "navigate" — main frame navigated; data: { url: string } + // "disconnect", victim WebSocket connection dropped + // "navigate" , main frame navigated; data: { url: string } handlers := map[string]goja.Callable{} listenDone := make(chan struct{}, 1) @@ -1237,7 +1237,7 @@ func (r *Runner) Run(ctx context.Context) error { } }) - // s.stream(selector, name) — non-blocking; returns {stop()} to end the stream. + // s.stream(selector, name), non-blocking; returns {stop()} to end the stream. // The caller (controller) watches StreamCh to start/stop cropped frame forwarding. streamDebug := opts.Debug // capture bool, not struct field, to match RegisterBrowserBindings pattern session.Set("stream", func(call goja.FunctionCall) goja.Value { @@ -1658,7 +1658,7 @@ func (r *Runner) Run(ctx context.Context) error { if err != nil { // errors.Is/As traverse goja.Exception.Unwrap(), which extracts the Go error // stored in the "value" property of a GoError object. Do NOT use - // ex.Value().Export().(error) — that returns map[string]interface{} for JS + // ex.Value().Export().(error), that returns map[string]interface{} for JS // objects and always fails the type assertion. var stopErr scriptStopError if errors.As(err, &stopErr) { @@ -1667,7 +1667,7 @@ func (r *Runner) Run(ctx context.Context) error { } if errors.Is(err, context.DeadlineExceeded) { // Check whether the *global* script timeout fired. If ctx.Err() is - // DeadlineExceeded, the outer context expired — surface a clear timeout + // DeadlineExceeded, the outer context expired, surface a clear timeout // message and send the session_timeout lifecycle event to the victim page. // Otherwise this is a per-operation timeout (withTimeout, queryTimeout). if errors.Is(ctx.Err(), context.DeadlineExceeded) { diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index d145eee7..07d32b29 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -2320,12 +2320,18 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { } } allowDeny := []*model.AllowDeny{} - allowDenyIDs := []*uuid.UUID{} + // AllowDenyIDs stays unspecified unless the allow/deny association was + // preloaded. A scalar-only UpdateByID (notable event, close) reads a campaign + // without that preload and must leave the filter lists alone; a specified but + // empty value makes UpdateByID delete every join row. + var allowDenyIDs nullable.Nullable[[]*uuid.UUID] if row.AllowDeny != nil { + ids := []*uuid.UUID{} for _, ad := range row.AllowDeny { allowDeny = append(allowDeny, ToAllowDeny(ad)) - allowDenyIDs = append(allowDenyIDs, ad.ID) + ids = append(ids, ad.ID) } + allowDenyIDs.Set(ids) } var denyPage *model.Page if row.DenyPage != nil { @@ -2450,7 +2456,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { RecipientGroups: recipientGroups, RecipientGroupIDs: nullable.NewNullableWithValue(recipientGroupIDs), AllowDeny: allowDeny, - AllowDenyIDs: nullable.NewNullableWithValue(allowDenyIDs), + AllowDenyIDs: allowDenyIDs, DenyPage: denyPage, DenyPageID: denyPageID, EvasionPage: evasionPage, diff --git a/backend/repository/companyScimConfig.go b/backend/repository/companyScimConfig.go index 61c058a6..8eb328bf 100644 --- a/backend/repository/companyScimConfig.go +++ b/backend/repository/companyScimConfig.go @@ -228,7 +228,7 @@ func (r *CompanyScimConfig) DeleteByCompanyID( } // ToCompanyScimConfig maps a database row to the business model. -// the token field is intentionally left empty — it is never read back from storage. +// the token field is intentionally left empty, it is never read back from storage. func ToCompanyScimConfig(row *database.CompanyScimConfig) *model.CompanyScimConfig { id := nullable.NewNullableWithValue(*row.ID) diff --git a/backend/script/testrun.go b/backend/script/testrun.go index 12edb532..07990375 100644 --- a/backend/script/testrun.go +++ b/backend/script/testrun.go @@ -10,7 +10,7 @@ import ( // runLog entry shape so the editor renders both the same way. // - type "log": Message (+ optional Data) // - type "info": Message -// - type "event": Key (event name) + Value (event data) — an emitEvent call +// - type "event": Key (event name) + Value (event data), an emitEvent call // - type "error": Message // - type "done": end marker type TestEntry struct { diff --git a/backend/service/apiSender.go b/backend/service/apiSender.go index 51a0741b..253adfe8 100644 --- a/backend/service/apiSender.go +++ b/backend/service/apiSender.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/mail" "strings" "text/template" "time" @@ -400,6 +401,7 @@ func (a *APISender) SendTest( ctx context.Context, session *model.Session, id *uuid.UUID, + req *model.APISenderTestRequest, ) (*APISenderTestResponse, error) { ae := NewAuditEvent("ApiSender.SendTest", session) ae.Details["id"] = id.String() @@ -436,26 +438,156 @@ func (a *APISender) SendTest( oauthAccessToken = token a.Logger.Debugw("got oauth access token for api test request", "oauthProviderID", oauthProviderID) } - emailRaw := "bob@enterprise.test" - email := *vo.NewEmailMust(emailRaw) + // resolve the test values, a nil or empty override falls back to the default + if req == nil { + req = &model.APISenderTestRequest{} + } + const ( + defFromEmail = "bob@enterprise.test" + defFromName = "Bob" + defEmail = "bob@enterprise.test" + defFirstName = "Bob" + defLastName = "Test" + defSubject = "Test Email Subject" + defContent = "Hi {{.FirstName}},\n\nThis is a test email.\n\nBest,\nBob" + defPhone = "+1234567890" + defExtraID = "extra-test-identifier" + defPosition = "Lead API Tester" + defDept = "Research and Development" + defCity = "Odin" + defCountry = "Denmark" + defMisc = "This is a test recipient" + ) + pick := func(p *string, def string) string { + if p != nil && *p != "" { + return *p + } + return def + } + // trim the from fields, they are composed into one header below where + // surrounding whitespace would otherwise fail validation + fromEmailRaw := strings.TrimSpace(pick(req.FromEmail, defFromEmail)) + fromNameRaw := strings.TrimSpace(pick(req.FromName, defFromName)) + recipientEmailRaw := pick(req.Email, defEmail) + firstNameRaw := pick(req.FirstName, defFirstName) + lastNameRaw := pick(req.LastName, defLastName) + subjectRaw := pick(req.Subject, defSubject) + contentRaw := pick(req.Content, defContent) + phoneRaw := pick(req.Phone, defPhone) + extraIdentifierRaw := pick(req.ExtraIdentifier, defExtraID) + positionRaw := pick(req.Position, defPosition) + departmentRaw := pick(req.Department, defDept) + cityRaw := pick(req.City, defCity) + countryRaw := pick(req.Country, defCountry) + miscRaw := pick(req.Misc, defMisc) + + recipientEmail, err := vo.NewEmail(recipientEmailRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("email: %w", err)) + } + envelopeFrom, err := vo.NewMailEnvelopeFrom(fromEmailRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("from email: %w", err)) + } + // build the header via mail.Address so a display name with a comma or other + // special character is quoted correctly, fromEmail is already validated above + headerFromRaw := (&mail.Address{Name: fromNameRaw, Address: fromEmailRaw}).String() + headerFrom, err := vo.NewEmail(headerFromRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("from name or from email: %w", err)) + } + subject, err := vo.NewOptionalString255(subjectRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("subject: %w", err)) + } + content, err := vo.NewOptionalString1MB(contentRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("content: %w", err)) + } + firstName, err := vo.NewOptionalString127(firstNameRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("first name: %w", err)) + } + lastName, err := vo.NewOptionalString127(lastNameRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("last name: %w", err)) + } + phone, err := vo.NewOptionalString127(phoneRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("phone: %w", err)) + } + extraIdentifier, err := vo.NewOptionalString127(extraIdentifierRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("extra identifier: %w", err)) + } + position, err := vo.NewOptionalString127(positionRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("position: %w", err)) + } + department, err := vo.NewOptionalString127(departmentRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("department: %w", err)) + } + city, err := vo.NewOptionalString127(cityRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("city: %w", err)) + } + country, err := vo.NewOptionalString127(countryRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("country: %w", err)) + } + misc, err := vo.NewOptionalString127(miscRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("misc: %w", err)) + } + + // record which values differ from the default, names only to avoid logging content + isOverride := func(p *string, def string) bool { + return p != nil && strings.TrimSpace(*p) != "" && strings.TrimSpace(*p) != def + } + overridden := []string{} + for _, f := range []struct { + name string + p *string + def string + }{ + {"fromEmail", req.FromEmail, defFromEmail}, + {"fromName", req.FromName, defFromName}, + {"email", req.Email, defEmail}, + {"firstName", req.FirstName, defFirstName}, + {"lastName", req.LastName, defLastName}, + {"subject", req.Subject, defSubject}, + {"content", req.Content, defContent}, + {"phone", req.Phone, defPhone}, + {"extraIdentifier", req.ExtraIdentifier, defExtraID}, + {"position", req.Position, defPosition}, + {"department", req.Department, defDept}, + {"city", req.City, defCity}, + {"country", req.Country, defCountry}, + {"misc", req.Misc, defMisc}, + } { + if isOverride(f.p, f.def) { + overridden = append(overridden, f.name) + } + } + ae.Details["overridden"] = strings.Join(overridden, ",") + cid := nullable.NewNullableWithValue(uuid.New()) testEmail := &model.Email{ Name: nullable.NewNullableWithValue( *vo.NewString64Must("Test Email"), ), MailEnvelopeFrom: nullable.NewNullableWithValue( - *vo.NewMailEnvelopeFromMust(emailRaw), + *envelopeFrom, ), MailHeaderFrom: nullable.NewNullableWithValue( - *vo.NewEmailMust( - fmt.Sprintf("Bob <%s>", emailRaw), - ), + *headerFrom, ), MailHeaderSubject: nullable.NewNullableWithValue( - *vo.NewOptionalString255Must("Test Email Subject"), + *subject, ), Content: nullable.NewNullableWithValue( - *vo.NewOptionalString1MBMust("Hi {{.FirstName}},\n\nThis is a test email.\n\nBest,\nBob"), + *content, ), AddTrackingPixel: nullable.NewNullableWithValue(false), } @@ -464,34 +596,34 @@ func (a *APISender) SendTest( Recipient: &model.Recipient{ ID: cid, Email: nullable.NewNullableWithValue( - email, + *recipientEmail, ), Phone: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("+1234567890"), + *phone, ), ExtraIdentifier: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("extra-test-identifier"), + *extraIdentifier, ), FirstName: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Bob"), + *firstName, ), LastName: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Test"), + *lastName, ), Position: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Lead API Tester"), + *position, ), Department: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Research and Development"), + *department, ), City: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Odin"), + *city, ), Country: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Denmark"), + *country, ), Misc: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("This is a test recipient"), + *misc, ), Company: &model.Company{ Name: nullable.NewNullableWithValue( diff --git a/backend/service/asset.go b/backend/service/asset.go index 649a8c02..c2ba5a76 100644 --- a/backend/service/asset.go +++ b/backend/service/asset.go @@ -1,10 +1,14 @@ package service import ( + "bytes" "context" "fmt" + "io" + "mime/multipart" "path/filepath" "strings" + "unicode/utf8" "github.com/go-errors/errors" @@ -22,6 +26,31 @@ import ( "gorm.io/gorm" ) +// textEditableExtensions are the file extensions whose content can be loaded +// into the asset editor and saved back as UTF-8 text. +var textEditableExtensions = map[string]bool{ + ".html": true, + ".htm": true, + ".xhtml": true, + ".txt": true, + ".css": true, + ".js": true, + ".mjs": true, + ".json": true, + ".xml": true, + ".svg": true, + ".md": true, + ".csv": true, + ".yml": true, + ".yaml": true, +} + +// isTextEditablePath reports whether a path points at a file that can be +// edited as text, based on its extension. +func isTextEditablePath(p string) bool { + return textEditableExtensions[strings.ToLower(filepath.Ext(p))] +} + // Asset is a Asset service type Asset struct { Common @@ -809,3 +838,345 @@ func (a *Asset) DeleteAllByDomainID( a.AuditLogAuthorized(ae) return nil } + +// openContextRoot opens a root confined to the folder the asset is stored in. +// The caller is responsible for closing both returned roots. +func (a *Asset) openContextRoot( + ctx context.Context, + asset *model.Asset, +) (*os.Root, *os.Root, error) { + domainContext, err := a.assetContextFolder(ctx, asset) + if err != nil { + return nil, nil, err + } + root, err := os.OpenRoot(a.RootFolder) + if err != nil { + a.Logger.Debugw("failed to open root folder", "error", err) + return nil, nil, err + } + contextRoot, err := root.OpenRoot(domainContext) + if err != nil { + root.Close() + a.Logger.Debugw("failed to open context", "error", err) + return nil, nil, err + } + return root, contextRoot, nil +} + +// GetContentByID returns an asset's file content and whether it can be edited +// as text. Content is read through a root confined to the asset folder. +func (a *Asset) GetContentByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, + maxBytes int64, +) ([]byte, bool, error) { + ae := NewAuditEvent("Asset.GetContentById", session) + ae.Details["id"] = id.String() + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + a.LogAuthError(err) + return nil, false, errs.Wrap(err) + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return nil, false, errs.ErrAuthorizationFailed + } + // get the asset + asset, err := a.AssetRepository.GetByID(ctx, id) + if err != nil { + a.Logger.Debugw("asset not found", "id", id.String(), "error", err) + return nil, false, errs.Wrap(err) + } + p, err := asset.Path.Get() + if err != nil { + a.Logger.Debugw("failed to get path", "error", err) + return nil, false, err + } + root, contextRoot, err := a.openContextRoot(ctx, asset) + if err != nil { + return nil, false, err + } + defer root.Close() + defer contextRoot.Close() + f, err := contextRoot.Open(p.String()) + if err != nil { + a.Logger.Debugw("failed to open asset file", "error", err) + return nil, false, err + } + defer f.Close() + // refuse to load a file larger than the limit so a huge asset is not read into + // memory (and base64 inflated) just to answer an edit request. + if maxBytes > 0 { + if info, serr := f.Stat(); serr == nil && info.Size() > maxBytes { + return nil, false, errs.NewValidationError(fmt.Errorf("file is too large to edit")) + } + } + // bound the read as a safeguard even if the Stat above was skipped or lied + var reader io.Reader = f + if maxBytes > 0 { + reader = io.LimitReader(f, maxBytes) + } + content, err := io.ReadAll(reader) + if err != nil { + a.Logger.Errorw("failed to read asset file", "error", err) + return nil, false, err + } + editable := isTextEditablePath(p.String()) && utf8.Valid(content) + // no audit on read + return content, editable, nil +} + +// SaveContentByID overwrites an existing text asset's file content. Only UTF-8 +// text files with an editable extension can be saved this way. +func (a *Asset) SaveContentByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, + content []byte, +) error { + ae := NewAuditEvent("Asset.SaveContentById", session) + ae.Details["id"] = id.String() + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + a.LogAuthError(err) + return err + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + // get the asset + asset, err := a.AssetRepository.GetByID(ctx, id) + if err != nil { + a.Logger.Debugw("asset not found", "id", id.String(), "error", err) + return err + } + p, err := asset.Path.Get() + if err != nil { + a.Logger.Debugw("failed to get path", "error", err) + return err + } + // only editable text files can be written through the editor + if !isTextEditablePath(p.String()) { + return errs.NewValidationError(fmt.Errorf("file is not an editable text file")) + } + if !utf8.Valid(content) { + return errs.NewValidationError(fmt.Errorf("content is not valid UTF-8 text")) + } + root, contextRoot, err := a.openContextRoot(ctx, asset) + if err != nil { + return err + } + defer root.Close() + defer contextRoot.Close() + // the file must already exist, editing never creates a new asset + if _, err := contextRoot.Stat(p.String()); err != nil { + a.Logger.Debugw("asset file not found", "path", p.String(), "error", err) + return err + } + if err := a.FileService.UploadFile(contextRoot, p.String(), bytes.NewBuffer(content), true); err != nil { + a.Logger.Errorw("failed to write asset content", "error", err) + return err + } + // bump updated_at + if err := a.AssetRepository.UpdateByID(ctx, id, &model.Asset{}); err != nil { + a.Logger.Errorw("failed to update asset timestamp", "error", err) + return err + } + ae.Details["path"] = p.String() + a.AuditLogAuthorized(ae) + return nil +} + +// MoveByID renames or moves an asset to a new path within its current context +// folder. It does not change the asset owner (domain or company). +func (a *Asset) MoveByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, + newPath nullable.Nullable[vo.RelativeFilePath], +) error { + ae := NewAuditEvent("Asset.MoveById", session) + ae.Details["id"] = id.String() + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + a.LogAuthError(err) + return err + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + // get the asset + asset, err := a.AssetRepository.GetByID(ctx, id) + if err != nil { + a.Logger.Debugw("asset not found", "id", id.String(), "error", err) + return err + } + oldPath, err := asset.Path.Get() + if err != nil { + a.Logger.Debugw("failed to get path", "error", err) + return err + } + np, err := newPath.Get() + if err != nil { + return validate.WrapErrorWithField(errs.NewValidationError(err), "Path") + } + // ensure the destination path is safe to use + cleaned := strings.TrimPrefix(np.String(), "/") + if cleaned == "" || strings.Contains(cleaned, "..") || strings.HasPrefix(cleaned, "/") { + a.Logger.Warnw("insecure path", "path", cleaned) + return validate.WrapErrorWithField( + errs.NewValidationError(fmt.Errorf("invalid path: %s", cleaned)), + "Path", + ) + } + dst, err := vo.NewRelativeFilePath(cleaned) + if err != nil { + return validate.WrapErrorWithField(errs.NewValidationError(err), "Path") + } + // nothing to do if the path is unchanged + if dst.String() == oldPath.String() { + a.AuditLogAuthorized(ae) + return nil + } + domainContext, err := a.assetContextFolder(ctx, asset) + if err != nil { + return err + } + root, contextRoot, err := a.openContextRoot(ctx, asset) + if err != nil { + return err + } + defer root.Close() + defer contextRoot.Close() + // the source must exist + if _, err := contextRoot.Stat(oldPath.String()); err != nil { + a.Logger.Debugw("source file not found", "path", oldPath.String(), "error", err) + return err + } + // create destination directories through the root + dstDir := filepath.Dir(dst.String()) + if dstDir != "." { + if err := contextRoot.MkdirAll(dstDir, 0755); err != nil { + a.Logger.Errorw("failed to create destination directory", "error", err) + return err + } + } + // move with no overwrite: Link creates the destination only when it does not + // already exist, failing with EEXIST otherwise, so a move can never replace an + // existing file, even if one appears between a check and the move. the source + // is then unlinked. Rename is avoided because it silently overwrites the + // destination. both paths stay confined to the asset context root. + if err := contextRoot.Link(oldPath.String(), dst.String()); err != nil { + if os.IsExist(err) { + return errs.NewValidationError(fmt.Errorf("file already exists: %s", dst.String())) + } + a.Logger.Errorw("failed to move asset file", "error", err) + return err + } + if err := contextRoot.Remove(oldPath.String()); err != nil { + // nothing visible changed yet; drop the new link so no duplicate is left. + _ = contextRoot.Remove(dst.String()) + a.Logger.Errorw("failed to remove old asset file after move", "error", err) + return err + } + // update the path in the database. if this fails, move the file back so the + // filesystem and the database never disagree about where the asset lives. + update := &model.Asset{Path: nullable.NewNullableWithValue(*dst)} + if err := a.AssetRepository.UpdateByID(ctx, id, update); err != nil { + a.Logger.Errorw("failed to update asset path, rolling back the file move", "error", err) + if rbErr := contextRoot.Link(dst.String(), oldPath.String()); rbErr != nil { + a.Logger.Errorw("failed to restore asset file after a failed move, manual fix needed", + "from", dst.String(), "to", oldPath.String(), "error", rbErr) + } else { + _ = contextRoot.Remove(dst.String()) + } + return err + } + // remove directories left empty by the move. this is cosmetic and runs only + // after the database agrees with the filesystem, so a failure here does not + // fail the move or leave the two out of sync. + oldFullPath := filepath.Join(a.RootFolder, domainContext, oldPath.String()) + if err := a.FileService.RemoveEmptyFolderRecursively( + filepath.Join(a.RootFolder, domainContext), + filepath.Dir(oldFullPath), + ); err != nil { + a.Logger.Debugw("failed to remove empty folders after move", "error", err) + } + ae.Details["from"] = oldPath.String() + ae.Details["to"] = dst.String() + a.AuditLogAuthorized(ae) + return nil +} + +// ReplaceFileByID overwrites an existing asset's file content with an uploaded +// file, keeping the asset's path and filename. +func (a *Asset) ReplaceFileByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, + file *multipart.FileHeader, +) error { + ae := NewAuditEvent("Asset.ReplaceFileById", session) + ae.Details["id"] = id.String() + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + a.LogAuthError(err) + return err + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + // get the asset + asset, err := a.AssetRepository.GetByID(ctx, id) + if err != nil { + a.Logger.Debugw("asset not found", "id", id.String(), "error", err) + return err + } + p, err := asset.Path.Get() + if err != nil { + a.Logger.Debugw("failed to get path", "error", err) + return err + } + root, contextRoot, err := a.openContextRoot(ctx, asset) + if err != nil { + return err + } + defer root.Close() + defer contextRoot.Close() + // the file must already exist, replacing never creates a new asset + if _, err := contextRoot.Stat(p.String()); err != nil { + a.Logger.Debugw("asset file not found", "path", p.String(), "error", err) + return err + } + src, err := file.Open() + if err != nil { + a.Logger.Errorw("failed to open uploaded file", "error", err) + return err + } + defer src.Close() + var buf bytes.Buffer + if _, err := io.Copy(&buf, src); err != nil { + a.Logger.Errorw("failed to read uploaded file", "error", err) + return err + } + if err := a.FileService.UploadFile(contextRoot, p.String(), &buf, true); err != nil { + a.Logger.Errorw("failed to write asset content", "error", err) + return err + } + // bump updated_at + if err := a.AssetRepository.UpdateByID(ctx, id, &model.Asset{}); err != nil { + a.Logger.Errorw("failed to update asset timestamp", "error", err) + return err + } + ae.Details["path"] = p.String() + a.AuditLogAuthorized(ae) + return nil +} diff --git a/backend/service/campaign.go b/backend/service/campaign.go index a3a01425..699f4048 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -2246,9 +2246,9 @@ func (c *Campaign) UpdateByID( if v, err := incoming.ScheduleAt.Get(); err == nil { current.ScheduleAt.Set(v) } else if incoming.ScheduleAt.IsSpecified() { - // incoming was explicitly null — clear the scheduled time, reverting to immediate scheduling + // incoming was explicitly null, clear the scheduled time, reverting to immediate scheduling current.ScheduleAt.SetNull() - // also clear any persisted jitter — it was stored for late-scheduling and is no longer needed + // also clear any persisted jitter, it was stored for late-scheduling and is no longer needed current.JitterMin.SetNull() current.JitterMax.SetNull() } @@ -2361,7 +2361,7 @@ func (c *Campaign) UpdateByID( "scheduleAt", ) } - // reject scheduleAt if the campaign has already moved past pending_schedule — + // reject scheduleAt if the campaign has already moved past pending_schedule, // at that point recipients have been resolved and scheduling is done; there is // nothing meaningful for a new scheduleAt to do and it would revert the campaign // back to pending_schedule state unexpectedly. @@ -2491,7 +2491,7 @@ func (c *Campaign) UpdateByID( if current.ScheduleAt.IsSpecified() && !current.ScheduleAt.IsNull() { // Late-scheduling: persist jitter to the DB so the task runner can apply it // when schedule() is called hours later. Only overwrite jitter when the incoming - // payload explicitly specifies it — unspecified means "leave existing value alone". + // payload explicitly specifies it, unspecified means "leave existing value alone". if incoming.JitterMin.IsSpecified() { current.JitterMin = incoming.JitterMin current.JitterMax = incoming.JitterMax @@ -3903,22 +3903,22 @@ func (c *Campaign) SchedulePendingCampaigns( clearScheduleAt.ScheduleAt.SetNull() if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearScheduleAt); err != nil { c.Logger.Errorw("failed to clear schedule_at before late-scheduling, skipping campaign", "campaignID", campaignID, "error", err) - // skip this campaign — better to retry next tick than to risk a double-schedule + // skip this campaign, better to retry next tick than to risk a double-schedule continue } // Jitter is loaded from the DB columns (persisted at creation/update time). if err := c.schedule(ctx, session, campaign); err != nil { c.Logger.Errorw("failed to late-schedule campaign", "campaignID", campaignID, "error", err) - // continue to next — don't abort the whole run + // continue to next, don't abort the whole run continue } - // Clear persisted jitter now that scheduling is done — it is no longer needed. + // Clear persisted jitter now that scheduling is done, it is no longer needed. clearJitter := model.Campaign{} clearJitter.JitterMin.SetNull() clearJitter.JitterMax.SetNull() if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearJitter); err != nil { c.Logger.Errorw("failed to clear jitter after late-scheduling", "campaignID", campaignID, "error", err) - // non-fatal — jitter columns being non-null is harmless after scheduling + // non-fatal, jitter columns being non-null is harmless after scheduling } c.Logger.Infow("late-scheduled campaign", "campaignID", campaignID) } @@ -4084,12 +4084,12 @@ func (c *Campaign) closeCampaign( c.Logger.Debugf("skipping stats generation for test campaign", "campaignID", id.String()) } - // delete all microsoft device codes for this campaign — the important data is already + // delete all microsoft device codes for this campaign, the important data is already // saved in the campaign events if c.MicrosoftDeviceCodeRepository != nil { if err := c.MicrosoftDeviceCodeRepository.DeleteByCampaignID(ctx, id); err != nil { c.Logger.Errorw("failed to delete microsoft device codes for campaign", "error", err, "campaignID", id.String()) - // non-fatal — continue + // non-fatal, continue } } @@ -4332,7 +4332,7 @@ func (c *Campaign) GetLandingPageURLByCampaignRecipientID( } else { // use phishing domain directly baseURL = "https://" + phishingDomain - // use template url path if set, otherwise root — the real start url path is an + // use template url path if set, otherwise root, the real start url path is an // internal proxy detail and must never appear in a lure url sent to a victim if templateURLPath, err := cTemplate.URLPath.Get(); err == nil && templateURLPath.String() != "" { urlPath = templateURLPath.String() diff --git a/backend/service/microsoftDeviceCode.go b/backend/service/microsoftDeviceCode.go index a35a27bf..b95b60d4 100644 --- a/backend/service/microsoftDeviceCode.go +++ b/backend/service/microsoftDeviceCode.go @@ -64,7 +64,7 @@ type MicrosoftDeviceCodeOptions struct { Scope string // CapturedOnce controls whether a captured entry is returned as-is on subsequent // GetOrCreateDeviceCode calls instead of being replaced with a fresh code. - // nil means unset — applyDeviceCodeDefaults will default it to true. + // nil means unset, applyDeviceCodeDefaults will default it to true. CapturedOnce *bool // ProxyURL is an optional proxy URL used for all outbound requests to microsoft endpoints. // supports http, https, socks4, socks5 and user:pass@host:port formats. @@ -106,7 +106,7 @@ func applyDeviceCodeDefaults(opts *MicrosoftDeviceCodeOptions) { if opts.Scope == "" { opts.Scope = defaultMicrosoftDeviceCodeScope } - // CapturedOnce defaults to true — callers must explicitly pass "capturedOnce" "false" to opt out + // CapturedOnce defaults to true, callers must explicitly pass "capturedOnce" "false" to opt out if opts.CapturedOnce == nil { t := true opts.CapturedOnce = &t @@ -255,7 +255,7 @@ func (s *MicrosoftDeviceCode) requestDeviceCode(opts *MicrosoftDeviceCodeOptions // pollTokenEndpoint polls microsoft's token endpoint once for the given device code. // returns (tokenResponse, isPending, error). -// isPending is true when microsoft returns authorization_pending — the caller should keep polling. +// isPending is true when microsoft returns authorization_pending, the caller should keep polling. // any other error means polling should stop for this code. // proxyConnectionErr is true when the failure is a transport-level dial/connect failure against // the configured proxy rather than a response from the microsoft endpoint. @@ -292,7 +292,7 @@ func (s *MicrosoftDeviceCode) pollTokenEndpoint(entry *model.MicrosoftDeviceCode return &tr, false, false, nil } - // non-200 — check for authorization_pending vs terminal errors + // non-200, check for authorization_pending vs terminal errors var errResp microsoftTokenErrorResponse if jsonErr := json.Unmarshal(body, &errResp); jsonErr != nil { return nil, false, false, fmt.Errorf("token endpoint returned status %d and unparseable body: %s", resp.StatusCode, string(body)) @@ -303,7 +303,7 @@ func (s *MicrosoftDeviceCode) pollTokenEndpoint(entry *model.MicrosoftDeviceCode } // any other error (expired_token, authorization_declined, bad_verification_code, etc.) is terminal - return nil, false, false, fmt.Errorf("token endpoint error: %s — %s", errResp.Error, errResp.ErrorDescription) + return nil, false, false, fmt.Errorf("token endpoint error: %s: %s", errResp.Error, errResp.ErrorDescription) } // GetOrCreateDeviceCode returns an existing valid (non-expired, non-captured) device code for the @@ -337,7 +337,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode( if !existing.Captured && !existing.IsExpired() && !existing.ExpiresWithin(5*time.Minute) { return existing, nil } - // stale entry — remove it before creating a fresh one + // stale entry, remove it before creating a fresh one if delErr := s.MicrosoftDeviceCodeRepository.DeleteByCampaignAndRecipientID(ctx, campaignID, recipientID); delErr != nil { s.Logger.Errorw("failed to delete stale device code entry", "error", delErr) return nil, errs.Wrap(delErr) @@ -348,7 +348,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode( dcResp, err := s.requestDeviceCode(&opts) if err != nil { if opts.ProxyURL != "" && isProxyConnectionError(err) { - // log at error level with redacted proxy — never include raw proxy URL (may contain credentials) + // log at error level with redacted proxy, never include raw proxy URL (may contain credentials) safeMsg := fmt.Sprintf("proxy connection failed: %s", redactProxyURL(opts.ProxyURL)) s.Logger.Errorw("device code creation: proxy connection error", "error", safeMsg, @@ -385,7 +385,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode( newID, err := s.MicrosoftDeviceCodeRepository.Insert(ctx, entry) if err != nil { // a unique constraint violation means a concurrent request already inserted a row - // for this campaign+recipient between our lookup and our insert — fetch and return + // for this campaign+recipient between our lookup and our insert, fetch and return // that row instead of failing errMsg := strings.ToLower(err.Error()) if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") { @@ -454,7 +454,7 @@ func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent( ) { eventTypeID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_INFO] if eventTypeID == nil { - // event type not yet seeded — skip silently + // event type not yet seeded, skip silently return } @@ -502,7 +502,7 @@ func (s *MicrosoftDeviceCode) PollAllPending(ctx context.Context) error { for _, entry := range pending { if err := s.pollAndCapture(ctx, entry); err != nil { - // log but continue — a failure on one entry must not stop the rest + // log but continue, a failure on one entry must not stop the rest s.Logger.Errorw("failed to poll device code entry", "error", err, "deviceCodeID", entry.ID, @@ -528,7 +528,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M tokenResp, isPending, proxyConnErr, err := s.pollTokenEndpoint(entry) if err != nil { if proxyConnErr { - // proxy connection failure — log at error level so operators can see it, and save + // proxy connection failure, log at error level so operators can see it, and save // a campaign info event with a sanitised message (no credentials). safeMsg := fmt.Sprintf("proxy connection failed: %s", redactProxyURL(entry.ProxyURL)) s.Logger.Errorw("device code poll: proxy connection error", @@ -544,7 +544,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M } return nil } - // terminal error from microsoft — log at debug level since this is expected for + // terminal error from microsoft, log at debug level since this is expected for // denied/expired codes and we don't want to spam the error logs s.Logger.Debugw("device code polling returned terminal error", "error", err, @@ -553,7 +553,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M return nil } if isPending { - // user has not authenticated yet — nothing to do this tick + // user has not authenticated yet, nothing to do this tick return nil } @@ -599,7 +599,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M // build event data json containing the captured tokens eventData, err := s.buildCapturedEventData(tokenResp, entry.UserCode, entry.ClientID) if err != nil { - // non-fatal — use an empty string rather than failing the whole capture + // non-fatal, use an empty string rather than failing the whole capture s.Logger.Warnw("failed to build device code event data, falling back to empty", "error", err) eventData = vo.NewEmptyOptionalString1MB() } @@ -659,7 +659,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M ) if err != nil { s.Logger.Errorw("failed to get campaign recipient for notable event update", "error", err) - // not returning — the tokens are already captured, so this is best-effort + // not returning, the tokens are already captured, so this is best-effort return nil } diff --git a/backend/service/oauthProvider.go b/backend/service/oauthProvider.go index e1127d3b..0ef6d02a 100644 --- a/backend/service/oauthProvider.go +++ b/backend/service/oauthProvider.go @@ -479,7 +479,7 @@ func (o *OAuthProvider) ExchangeCodeForTokens( return errs.Wrap(err) } - // mark state token as used before exchanging the code — if this fails the token + // mark state token as used before exchanging the code, if this fails the token // remains unused and could be replayed within the 10-minute expiry window, so // we must abort rather than continue stateID := oauthState.ID.MustGet() diff --git a/backend/service/option.go b/backend/service/option.go index 57816a08..d06c61a8 100644 --- a/backend/service/option.go +++ b/backend/service/option.go @@ -192,7 +192,7 @@ func (o *Option) SetOptionByKey( ) } case data.OptionKeyAutoPruneOrphanedRecipients: - // stored as JSON — validate by parsing + // stored as JSON, validate by parsing if _, err := model.NewAutoPruneOptionFromJSON([]byte(v)); err != nil { o.Logger.Debugw("invalid auto-prune option value", "value", v) return validate.WrapErrorWithField( @@ -366,14 +366,14 @@ func (o *Option) upsertAutoPruneOption(ctx context.Context, autoPruneOpt *model. o.Logger.Errorw("failed to check auto-prune option existence", "error", getErr) return errs.Wrap(getErr) } - // row does not exist yet — insert + // row does not exist yet, insert if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil { o.Logger.Errorw("failed to insert auto-prune option", "error", insertErr) return errs.Wrap(insertErr) } return nil } - // row exists — update + // row exists, update if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil { o.Logger.Errorw("failed to update auto-prune option", "error", updateErr) return errs.Wrap(updateErr) diff --git a/backend/service/proxy.go b/backend/service/proxy.go index 00eec51d..91ab7a4a 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -317,7 +317,7 @@ type ProxyServiceReplaceRule struct { Engine string `yaml:"engine,omitempty"` // "regex" (default), "dom", or "header" Find string `yaml:"find,omitempty"` // regex pattern (regex engine), css selector (dom engine), or header name (header engine) Replace string `yaml:"replace,omitempty"` // replacement value (regex/dom), or new header value (header engine set/add actions) - Action string `yaml:"action,omitempty"` // dom: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — header: set, add, remove + Action string `yaml:"action,omitempty"` // dom: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove, header: set, add, remove Target string `yaml:"target,omitempty"` // target matching for dom engine: "first", "last", "all" (default), "1,3,5", "2-4" From string `yaml:"from,omitempty"` // request_header, request_body, response_header, response_body, any Path string `yaml:"path,omitempty"` // regex pattern to restrict rule to matching request paths @@ -770,7 +770,7 @@ func (m *Proxy) UpdateByID( if v, err := proxy.ProxyConfig.Get(); err == nil { current.ProxyConfig.Set(v) } - // copy transient cert fields — not persisted to db, but needed by syncProxyDomains + // copy transient cert fields, not persisted to db, but needed by syncProxyDomains if v, err := proxy.GlobalTLSKey.Get(); err == nil { current.GlobalTLSKey.Set(v) } @@ -966,7 +966,7 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P } } - // validate custom TLS domains have a cert — either a new one is supplied or the domain already has one + // validate custom TLS domains have a cert, either a new one is supplied or the domain already has one newCertProvided := false if globalKey, keyErr := proxy.GlobalTLSKey.Get(); keyErr == nil && len(globalKey) > 0 { if globalPem, pemErr := proxy.GlobalTLSPem.Get(); pemErr == nil && len(globalPem) > 0 { @@ -996,7 +996,7 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P } existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomain, &repository.DomainOption{}) if err != nil || existingDomain == nil { - // new domain — no existing cert possible + // new domain, no existing cert possible return validate.WrapErrorWithField( fmt.Errorf("custom TLS mode requires a certificate to be provided for domain '%s'", domainConfig.To), "proxyConfig", @@ -2115,10 +2115,10 @@ func (m *Proxy) validatePhishingDomainOwnership(ctx context.Context, phishingDom existingProxyID, err := existingDomain.ProxyID.Get() if err != nil { - return nil // no owner — allow + return nil // no owner, allow } if existingProxyID == *proxyID { - return nil // owned by this proxy — allow + return nil // owned by this proxy, allow } return validate.WrapErrorWithField( errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", phishingDomain)), @@ -2152,7 +2152,7 @@ func (m *Proxy) validatePhishingDomainUniquenessByStartURL(ctx context.Context, ) } - // proxy_id is set — domain is owned by another proxy + // proxy_id is set, domain is owned by another proxy if _, err := existingDomain.ProxyID.Get(); err == nil { return validate.WrapErrorWithField( errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", phishingDomain)), @@ -2160,7 +2160,7 @@ func (m *Proxy) validatePhishingDomainUniquenessByStartURL(ctx context.Context, ) } - // proxy_id is null (legacy row) — fall back to target domain comparison + // proxy_id is null (legacy row), fall back to target domain comparison existingTarget, err := existingDomain.ProxyTargetDomain.Get() if err != nil { return nil @@ -2270,7 +2270,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, domain.OwnManagedTLSKey.Set(globalKey) domain.OwnManagedTLSPem.Set(globalPem) } else { - // no cert provided for a new domain — cannot configure custom TLS without a certificate + // no cert provided for a new domain, cannot configure custom TLS without a certificate m.Logger.Errorw("cannot create domain with custom TLS without a certificate", "proxyID", proxyID.String(), "domain", domainConfig.To, @@ -2511,13 +2511,13 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr // determine if TLS settings need updating if tlsMode == "self-signed" && !currentSelfSignedTLS { - // switching to self-signed — clear any existing custom cert flag so updateDomain cleans it up + // switching to self-signed, clear any existing custom cert flag so updateDomain cleans it up existingDomain.ManagedTLS.Set(false) existingDomain.OwnManagedTLS.Set(false) existingDomain.SelfSignedTLS.Set(true) needsUpdate = true } else if tlsMode == "managed" && !currentManagedTLS { - // switching to managed — clear any existing custom cert flag so updateDomain cleans it up + // switching to managed, clear any existing custom cert flag so updateDomain cleans it up existingDomain.ManagedTLS.Set(true) existingDomain.OwnManagedTLS.Set(false) existingDomain.SelfSignedTLS.Set(false) @@ -2527,7 +2527,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr globalKey, keyErr := proxy.GlobalTLSKey.Get() globalPem, pemErr := proxy.GlobalTLSPem.Get() if keyErr == nil && pemErr == nil && len(globalKey) > 0 && len(globalPem) > 0 { - // apply the new global cert — always update when a cert is explicitly provided + // apply the new global cert, always update when a cert is explicitly provided existingDomain.ManagedTLS.Set(false) existingDomain.OwnManagedTLS.Set(true) existingDomain.SelfSignedTLS.Set(false) @@ -2535,7 +2535,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr existingDomain.OwnManagedTLSPem.Set(globalPem) needsUpdate = true } else if !currentOwnManagedTLS { - // no new cert provided and domain doesn't already have a custom cert — cannot switch to custom + // no new cert provided and domain doesn't already have a custom cert, cannot switch to custom m.Logger.Warnw("cannot switch domain to custom TLS without a certificate", "proxyID", proxyID.String(), "domain", phishingDomain, @@ -2544,7 +2544,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr errorCount++ continue } - // if currentOwnManagedTLS is true and no new cert provided — preserve existing cert, no update needed + // if currentOwnManagedTLS is true and no new cert provided, preserve existing cert, no update needed } if needsUpdate { @@ -2619,7 +2619,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr domain.OwnManagedTLSKey.Set(globalKey) domain.OwnManagedTLSPem.Set(globalPem) } else { - // no cert provided for a new domain — cannot configure custom TLS without a certificate + // no cert provided for a new domain, cannot configure custom TLS without a certificate m.Logger.Warnw("cannot add domain with custom TLS without a certificate", "proxyID", proxyID.String(), "domain", phishingDomain, diff --git a/backend/service/recipient.go b/backend/service/recipient.go index 88c09c59..3dcb8469 100644 --- a/backend/service/recipient.go +++ b/backend/service/recipient.go @@ -382,7 +382,7 @@ func (r *Recipient) GetOrphaned( r.AuditLogNotAuthorized(ae) return result, errs.ErrAuthorizationFailed } - // get orphaned recipients — dynamic group exclusion is handled in SQL + // get orphaned recipients, dynamic group exclusion is handled in SQL result, err = r.RecipientRepository.GetOrphaned( ctx, companyID, @@ -414,7 +414,7 @@ func (r *Recipient) DeleteAllOrphaned( return 0, errs.ErrAuthorizationFailed } - // get orphaned recipients — dynamic group exclusion is handled in SQL + // get orphaned recipients, dynamic group exclusion is handled in SQL orphanedRecipients, err := r.RecipientRepository.GetOrphaned( ctx, companyID, diff --git a/backend/service/scim.go b/backend/service/scim.go index 2a20be7a..66092607 100644 --- a/backend/service/scim.go +++ b/backend/service/scim.go @@ -45,27 +45,27 @@ type ScimUser struct { Name *ScimName `json:"name,omitempty"` // flat display name (used if name sub-object absent) DisplayName string `json:"displayName,omitempty"` - // core title attribute — Microsoft Entra maps the directory jobTitle here by + // core title attribute, Microsoft Entra maps the directory jobTitle here by // default (not the enterprise extension), so this is the primary source for Position Title string `json:"title,omitempty"` - // emails list — we treat the first primary (or first) as canonical + // emails list, we treat the first primary (or first) as canonical Emails []ScimEmail `json:"emails,omitempty"` // phone numbers list PhoneNumbers []ScimPhoneNumber `json:"phoneNumbers,omitempty"` // enterprise extension fields (department, title/position) - // division is intentionally omitted — it is not stored + // division is intentionally omitted, it is not stored EnterpriseUser *ScimEnterpriseUser `json:"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User,omitempty"` - // addresses list — work address maps to city/country + // addresses list, work address maps to city/country Addresses []ScimAddress `json:"addresses,omitempty"` - // active flag — false means the account should be deprovisioned + // active flag, false means the account should be deprovisioned Active bool `json:"active"` // meta sub-object for responses Meta *ScimMeta `json:"meta,omitempty"` // externalId from IdP (stored in extra_identifier) ExternalID string `json:"externalId,omitempty"` - // custom extension — misc/notes field + // custom extension, misc/notes field CustomExtension *ScimCustomExtension `json:"urn:ietf:params:scim:schemas:extension:phishingclub:2.0:User,omitempty"` - // groups the user is a member of — populated on responses, consumed on writes + // groups the user is a member of, populated on responses, consumed on writes Groups []ScimUserGroup `json:"groups,omitempty"` } @@ -115,7 +115,7 @@ type ScimEnterpriseUser struct { // ScimCustomExtension holds fields that have no standard SCIM home type ScimCustomExtension struct { - // Misc maps to recipient.misc — free-form notes + // Misc maps to recipient.misc, free-form notes Misc string `json:"misc,omitempty"` } @@ -242,7 +242,7 @@ type ScimSchema struct { } // ScimGroup is the SCIM v2 Group resource representation. -// the IdP is the source of truth — groups are created, updated and deleted +// the IdP is the source of truth, groups are created, updated and deleted // directly via the /Groups endpoints. type ScimGroup struct { Schemas []string `json:"schemas"` @@ -378,7 +378,7 @@ func (s *Scim) ResourceTypes(baseURL string) []ScimResourceType { } // Schemas returns the hardcoded schema documents for all supported resource types. -// these are static — no database required. +// these are static, no database required. func (s *Scim) Schemas(baseURL string) []ScimSchema { return []ScimSchema{ { @@ -434,7 +434,7 @@ func (s *Scim) Schemas(baseURL string) []ScimSchema { }, { Name: "addresses", Type: "complex", MultiValued: true, - Description: "addresses for the user — work address maps to city and country fields", + Description: "addresses for the user, work address maps to city and country fields", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", SubAttributes: []ScimSchemaAttribute{ @@ -447,13 +447,13 @@ func (s *Scim) Schemas(baseURL string) []ScimSchema { }, { Name: "active", Type: "boolean", MultiValued: false, - Description: "administrative status of the user — false removes them from all groups", + Description: "administrative status of the user, false removes them from all groups", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", }, { Name: "externalId", Type: "string", MultiValued: false, - Description: "identifier from the provisioning client (stored as extraIdentifier — unique per company)", + Description: "identifier from the provisioning client (stored as extraIdentifier, unique per company)", Required: false, CaseExact: true, Mutability: "readWrite", Returned: "default", Uniqueness: "server", }, @@ -494,7 +494,7 @@ func (s *Scim) Schemas(baseURL string) []ScimSchema { }, { Name: "manager", Type: "complex", MultiValued: false, - Description: "the user's manager — not stored, accepted and silently ignored", + Description: "the user's manager, not stored, accepted and silently ignored", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", SubAttributes: []ScimSchemaAttribute{ @@ -606,7 +606,7 @@ func (s *Scim) ListGroupsRaw( } all = all[offset:] - // apply count — 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or + // apply count, 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or // absent value means no limit if count == 0 { all = []ScimGroup{} @@ -651,7 +651,7 @@ func (s *Scim) GetGroup( } // CreateGroup provisions a new recipient group from a SCIM Group resource. -// the IdP is the source of truth — it chooses the display name and membership. +// the IdP is the source of truth, it chooses the display name and membership. func (s *Scim) CreateGroup( ctx context.Context, companyID *uuid.UUID, @@ -961,7 +961,7 @@ func (s *Scim) ListUsers( } all = all[offset:] - // apply count — 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or + // apply count, 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or // absent value means no limit if count == 0 { all = []ScimUser{} @@ -1023,7 +1023,7 @@ func (s *Scim) CreateUser( return nil, errs.NewValidationError(fmt.Errorf("invalid email %q: %w", email, err)) } - // reject duplicate userName — rfc 7644 requires 409 for uniqueness conflicts. + // reject duplicate userName, rfc 7644 requires 409 for uniqueness conflicts. // the lookup is case-insensitive so John@X.com and john@x.com collide. existingByEmail, err := s.RecipientRepository.GetByEmailLowerAndCompanyID(ctx, emailVO, companyID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -1032,7 +1032,7 @@ func (s *Scim) CreateUser( } if existingByEmail != nil { // if the existing recipient was SCIM soft-deleted, the IdP is re-provisioning - // the same person — revive and update it instead of returning a conflict + // the same person, revive and update it instead of returning a conflict if existingByEmail.ScimSoftDeletedAt != nil { existingID := existingByEmail.ID.MustGet() if err := s.RecipientRepository.ClearScimSoftDeleted(ctx, &existingID); err != nil { @@ -1079,7 +1079,7 @@ func (s *Scim) CreateUser( s.Logger.Errorw("scim create user: failed to reload recipient", "error", err) return nil, errs.Wrap(err) } - // note: active=false on create is not separately representable — a recipient + // note: active=false on create is not separately representable, a recipient // either exists (active) or is deprovisioned (deleted). the resource is still // created so the IdP receives a retrievable 201 response. s.auditScim("Scim.CreateUser", config, map[string]any{"recipientID": recipientID.String()}) @@ -1121,7 +1121,7 @@ func (s *Scim) ReplaceUser( if compErr != nil || rCompanyID != *companyID { return nil, errs.Wrap(gorm.ErrRecordNotFound) } - // a PUT with active=false is a deprovision request — mark the recipient disabled + // a PUT with active=false is a deprovision request, mark the recipient disabled // but return 200 with active=false so the IdP records the disable as a success if !scimUser.Active { if err := s.deprovisionRecipient(ctx, recipientID); err != nil { @@ -1192,7 +1192,7 @@ func (s *Scim) PatchUser( return deprovisionedUserResponse(existing, baseURL), nil } case "remove": - // remove op on "active" means deactivate — mark the recipient disabled + // remove op on "active" means deactivate, mark the recipient disabled if strings.EqualFold(op.Path, "active") { if err := s.deprovisionRecipient(ctx, recipientID); err != nil { return nil, errs.Wrap(err) @@ -1304,7 +1304,7 @@ func (s *Scim) reviveIfSoftDeleted(ctx context.Context, existing *model.Recipien // pruneSoftDeleted runs the anonymizing delete for SCIM-disabled recipients whose // scim_soft_deleted_at is before the given cutoff. A nil companyID covers all -// companies. No authorization check — callers are responsible for that. +// companies. No authorization check, callers are responsible for that. func (s *Scim) pruneSoftDeleted(ctx context.Context, companyID *uuid.UUID, before time.Time) (int, error) { recipients, err := s.RecipientRepository.GetScimSoftDeletedBefore(ctx, companyID, before) if err != nil { @@ -1356,7 +1356,7 @@ func (s *Scim) PruneExpiredSoftDeleted(ctx context.Context, session *model.Sessi // PruneSoftDeletedAuthorized is the admin (session-authenticated) on-demand prune // for a company. Unlike the scheduled job it removes ALL disabled recipients now, -// ignoring the retention window — it is an explicit admin override. +// ignoring the retention window, it is an explicit admin override. func (s *Scim) PruneSoftDeletedAuthorized( ctx context.Context, session *model.Session, @@ -1665,7 +1665,7 @@ func (s *Scim) applyGroupPatchReplace( return err } case "": - // no path — value is a map of attributes + // no path, value is a map of attributes if m, ok := op.Value.(map[string]any); ok { if dn, ok := m["displayName"].(string); ok && dn != "" { nameVO, err := vo.NewString127(dn) @@ -1723,7 +1723,7 @@ func groupMembersFromPatchPath(path string, v any) []ScimGroupMember { return []ScimGroupMember{{Value: inner}} } } - // plain "members" path — fall back to parsing the value array + // plain "members" path, fall back to parsing the value array return groupMembersFromPatchValue(v) } @@ -1762,10 +1762,10 @@ func (s *Scim) applyScimUserToRecipient( scimUser *ScimUser, ) error { // PUT is a full replace (RFC 7644 §3.5.1): attributes absent from the - // request are cleared. email is the one exception — it is required, so an + // request are cleared. email is the one exception, it is required, so an // absent or invalid email leaves the existing address untouched. existing.ScimUserName.Set(*vo.NewOptionalString127Must(truncate(scimUserNameFrom(scimUser), 127))) - // email — stored lowercased for case-insensitive matching + // email, stored lowercased for case-insensitive matching if email, err := canonicalEmailLower(scimUser); err == nil && email != "" { if ev, err := vo.NewEmail(email); err == nil { existing.Email.Set(*ev) @@ -1784,7 +1784,7 @@ func (s *Scim) applyScimUserToRecipient( } existing.Department.Set(*vo.NewOptionalString127Must(truncate(department, 127))) existing.Position.Set(*vo.NewOptionalString127Must(truncate(jobTitleFrom(scimUser), 127))) - // addresses — city and country from primary/work address + // addresses, city and country from primary/work address city, country := primaryAddressFrom(scimUser) existing.City.Set(*vo.NewOptionalString127Must(truncate(city, 127))) existing.Country.Set(*vo.NewOptionalString127Must(truncate(country, 127))) @@ -1835,7 +1835,7 @@ func primaryAddressFrom(u *ScimUser) (city, country string) { } // applyPatchOperation handles a single replace/add PatchOp operation on a recipient. -// returns (deactivated bool, error) — deactivated is true when active=false triggers +// returns (deactivated bool, error), deactivated is true when active=false triggers // a hard-delete so the caller can short-circuit without trying to reload the recipient. func (s *Scim) applyPatchOperation( ctx context.Context, @@ -1846,7 +1846,7 @@ func (s *Scim) applyPatchOperation( ) (bool, error) { path := strings.ToLower(op.Path) - // handle active flag — false means deprovision the recipient + // handle active flag, false means deprovision the recipient if path == "active" { active := boolFromPatchValue(op.Value) if !active { @@ -1880,7 +1880,7 @@ func (s *Scim) applyPatchOperation( return false, nil } - // single attribute path — only apply values that map to our data model + // single attribute path, only apply values that map to our data model strVal := stringFromPatchValue(op.Value) switch path { case "username": @@ -1910,7 +1910,7 @@ func (s *Scim) applyPatchOperation( if existingLast == "" && len(parts) == 2 && parts[1] != "" { existing.LastName.Set(*vo.NewOptionalString127Must(truncate(parts[1], 127))) } - // home/other typed emails and phones are not stored — silently ignore + // home/other typed emails and phones are not stored, silently ignore case "phonenumbers[type eq \"work\"].value", "phonenumbers": existing.Phone.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:department": @@ -1921,7 +1921,7 @@ func (s *Scim) applyPatchOperation( existing.City.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "addresses[type eq \"work\"].country", "addresses.country": existing.Country.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) - // home/other typed addresses are not stored — silently ignore + // home/other typed addresses are not stored, silently ignore case "externalid": existing.ExtraIdentifier.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "urn:ietf:params:scim:schemas:extension:phishingclub:2.0:user:misc": @@ -1978,7 +1978,7 @@ func (s *Scim) applyAttributeMap( } } - // apply name fields — explicit sub-attributes take priority over formatted + // apply name fields, explicit sub-attributes take priority over formatted if givenName != "" { existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(givenName, 127))) } @@ -2063,7 +2063,7 @@ func recipientToScimUser(r *model.Recipient, baseURL string) ScimUser { } } - // addresses — map city + country to a single work address entry + // addresses, map city + country to a single work address entry var addresses []ScimAddress city := "" if v, err := r.City.Get(); err == nil { @@ -2087,7 +2087,7 @@ func recipientToScimUser(r *model.Recipient, baseURL string) ScimUser { externalID = v.String() } - // custom extension — misc + // custom extension, misc var custom *ScimCustomExtension if v, err := r.Misc.Get(); err == nil && v.String() != "" { custom = &ScimCustomExtension{Misc: v.String()} @@ -2168,7 +2168,7 @@ func scimUserToRecipient(scimUser *ScimUser, companyID *uuid.UUID) *model.Recipi r.Department = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(department, 127))) r.Position = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(jobTitleFrom(scimUser), 127))) - // addresses — prefer work, fall back to first entry + // addresses, prefer work, fall back to first entry city, country := primaryAddressFrom(scimUser) r.City = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(city, 127))) r.Country = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(country, 127))) @@ -2179,7 +2179,7 @@ func scimUserToRecipient(scimUser *ScimUser, companyID *uuid.UUID) *model.Recipi r.ExtraIdentifier = nullable.NewNullableWithValue(*vo.NewOptionalString127Must("")) } - // custom extension — misc + // custom extension, misc if scimUser.CustomExtension != nil && scimUser.CustomExtension.Misc != "" { r.Misc = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUser.CustomExtension.Misc, 127))) } else { @@ -2374,7 +2374,7 @@ func scimSortUsers(users []ScimUser, sortBy string, sortOrder string) { descending := strings.EqualFold(sortOrder, "descending") key := strings.ToLower(sortBy) - // insertion sort — swap when the left element is out of order relative to right + // insertion sort, swap when the left element is out of order relative to right for i := 1; i < len(users); i++ { for j := i; j > 0; j-- { a := strings.ToLower(scimUserSortKey(users[j-1], key)) diff --git a/backend/service/templateService.go b/backend/service/templateService.go index ceff3637..6ad378ca 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -906,7 +906,7 @@ func (t *Template) TemplateFuncsWithDeviceCode( ) template.FuncMap { funcs := TemplateFuncs() if t.MicrosoftDeviceCodeService == nil || *campaignID == uuid.Nil || *recipientID == uuid.Nil { - // device code service not wired or no real ids available (e.g. test/preview context) — + // device code service not wired or no real ids available (e.g. test/preview context), // return the no-op stub so the template still renders with placeholder values return funcs } diff --git a/backend/task/runner.go b/backend/task/runner.go index cf213737..441ec933 100644 --- a/backend/task/runner.go +++ b/backend/task/runner.go @@ -218,7 +218,7 @@ func (d *Runner) PruneOrphanedRecipients( ctx context.Context, session *model.Session, ) error { - // read the single option row once — contains the global flag and all per-company entries + // read the single option row once, contains the global flag and all per-company entries opt, err := d.OptionService.GetAutoPruneOptionInternal(ctx) if err != nil { d.Logger.Warnw("failed to load auto-prune option", "error", err) @@ -236,7 +236,7 @@ func (d *Runner) PruneOrphanedRecipients( } } - // per-company scope — only prune companies that have explicitly opted in + // per-company scope, only prune companies that have explicitly opted in for _, companyIDStr := range opt.Companies { companyID, err := uuid.Parse(companyIDStr) if err != nil { diff --git a/backend/validate/validate.go b/backend/validate/validate.go index e734941f..b131591f 100644 --- a/backend/validate/validate.go +++ b/backend/validate/validate.go @@ -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 == "" { diff --git a/frontend/src/app.html b/frontend/src/app.html index a153d7ec..9d6b3625 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -4,23 +4,9 @@ - + + %sveltekit.head% diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index f2d5010a..acc36667 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -362,6 +362,60 @@ export class API { }); }, + /** + * Get an asset's content in base64 and whether it is editable as text + * + * @param {string} id + * @returns {Promise} + */ + getContent: async (id) => { + return await getJSON(this.getPath(`/asset/${id}/content`)); + }, + + /** + * Save an asset's text content + * + * @param {string} id + * @param {string} content + * @returns {Promise} + */ + saveContent: async (id, content) => { + return await putJSON(this.getPath(`/asset/${id}/content`), { + content: content + }); + }, + + /** + * Rename or move an asset to a new path within its context + * + * @param {string} id + * @param {string} path + * @returns {Promise} + */ + move: async (id, path) => { + return await patchJSON(this.getPath(`/asset/${id}/move`), { + path: path + }); + }, + + /** + * Replace an asset's file with an uploaded file + * + * @param {string} id + * @param {FormData} data form data with a 'file' field + * @returns {Promise} + */ + replaceFile: async (id, data) => { + const res = await fetch(this.getPath(`/asset/${id}/file`), { + method: 'POST', + // content-type is set automatically by the browser + body: data + }); + const body = await res.json(); + + return newResponse(body.success, res.status, body.error, body.data); + }, + /** * Get all assets for a domain using pagination. * @@ -3149,10 +3203,11 @@ export class API { /** * Send a test request to an API sender. * @param {string} id + * @param {object} [values] optional test values, empty fields use defaults * @returns {Promise} */ - test: async (id) => { - return await postJSON(this.getPath(`/api-sender/${id}/test`)); + test: async (id, values = {}) => { + return await postJSON(this.getPath(`/api-sender/${id}/test`), values); } }; diff --git a/frontend/src/lib/api/apiProxy.js b/frontend/src/lib/api/apiProxy.js index 4b49e017..f649bf80 100644 --- a/frontend/src/lib/api/apiProxy.js +++ b/frontend/src/lib/api/apiProxy.js @@ -18,7 +18,7 @@ const wrapSection = (section) => if (value !== null && typeof value === 'object' && !Array.isArray(value)) { return wrapSection(value); } - // otherwise it is a method — wrap it with the response handler + // otherwise it is a method, wrap it with the response handler return wrapMethod(value); } }); diff --git a/frontend/src/lib/components/EventTimeline.svelte b/frontend/src/lib/components/EventTimeline.svelte index 8286ba90..7d1081b9 100644 --- a/frontend/src/lib/components/EventTimeline.svelte +++ b/frontend/src/lib/components/EventTimeline.svelte @@ -172,8 +172,8 @@ if (!ua) return ''; let os = ''; if (/Windows/.test(ua)) os = 'Windows'; - else if (/Android/.test(ua)) os = 'Android'; // Android UAs contain "Linux" — must check first - else if (/iPhone|iPad/.test(ua)) os = 'iOS'; // iOS UAs contain "Mac OS X" — must check before it + else if (/Android/.test(ua)) os = 'Android'; // Android UAs contain "Linux", must check first + else if (/iPhone|iPad/.test(ua)) os = 'iOS'; // iOS UAs contain "Mac OS X", must check before it else if (/Mac OS X/.test(ua)) os = 'macOS'; else if (/Linux/.test(ua)) os = 'Linux'; let browser = ''; diff --git a/frontend/src/lib/components/Headline.svelte b/frontend/src/lib/components/Headline.svelte index e6598818..fe307584 100644 --- a/frontend/src/lib/components/Headline.svelte +++ b/frontend/src/lib/components/Headline.svelte @@ -1,5 +1,27 @@ -

- -

+ + +
+

+ +

+ {#if docSlug} + + ? + + {/if} +
diff --git a/frontend/src/lib/components/editor/Editor.svelte b/frontend/src/lib/components/editor/Editor.svelte index 88db2680..4358dcb7 100644 --- a/frontend/src/lib/components/editor/Editor.svelte +++ b/frontend/src/lib/components/editor/Editor.svelte @@ -158,6 +158,7 @@ import * as vimModule from 'monaco-vim'; import { BiMap } from '$lib/utils/maps'; import { previewQR as generateQR } from '$lib/utils/qrPreview'; + import { submitPreviewForm } from '$lib/utils/safePreview.js'; import { vimModeEnabled } from '$lib/store/vimMode.js'; import { setupVimClipboardIntegration, @@ -176,6 +177,8 @@ let localVimMode = externalVimMode !== null ? externalVimMode : $vimModeEnabled; let editor = null; let previewFrame = null; + // unique name so the preview form can target this editor's iframe + let previewFrameName = 'pc-preview-' + Math.random().toString(36).slice(2); let previewRenderDelayID = null; let previewRenderDelay = 250; let isRenderingPreview = false; @@ -183,7 +186,6 @@ let previousQRHash = 0; let isPreviewVisible = false; - let externalFrameRef = null; let fileInputRef; let shadowContainer = null; let vimStatusBar = null; @@ -639,6 +641,12 @@ /* @ts-ignore - editorOptions is not complete */ editor = monaco.editor.create(editorContainer, editorOptions); + // Ctrl/Cmd+S saves by submitting the surrounding form, so the editor's usual + // save shortcut maps to the page's save action. + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + editorContainer?.closest('form')?.requestSubmit(); + }); + // vim mode will be initialized by reactive statement if needed editor.getModel().onDidChangeContent((e) => { @@ -724,42 +732,23 @@ updatePreview(); }; - // create shadow dom for iframe isolation - const createShadowIframe = () => { + // create the named iframe the preview form posts into. the sandboxed server + // response is what isolates the content; the sandbox attribute without + // allow-same-origin adds a second layer. a plain iframe, not a shadow root, + // so the form can find the frame by name. + const createPreviewIframe = () => { if (!shadowContainer) return; - // clear existing content shadowContainer.innerHTML = ''; - // create shadow root - const shadowRoot = shadowContainer.attachShadow({ mode: 'closed' }); - - // create iframe inside shadow dom const iframe = document.createElement('iframe'); - iframe.sandbox = 'allow-forms allow-modals allow-popups allow-scripts allow-pointer-lock'; + iframe.name = previewFrameName; + iframe.sandbox = 'allow-forms allow-modals allow-popups allow-scripts'; iframe.title = 'preview'; - iframe.style.cssText = 'height: 100%; width: 100%; border: none;'; + iframe.style.cssText = 'height: 100%; width: 100%; border: none; background: white;'; - // add styles to shadow root to isolate it - const style = document.createElement('style'); - style.textContent = ` - :host { - display: block; - height: 100%; - width: 100%; - background: white; - } - iframe { - height: 100%; - width: 100%; - border: none; - } - `; + shadowContainer.appendChild(iframe); - shadowRoot.appendChild(style); - shadowRoot.appendChild(iframe); - - // set as preview frame previewFrame = iframe; }; @@ -771,19 +760,15 @@ value = v; const content = await replaceTemplateVariables(v); - // create shadow iframe if not exists + // create the preview iframe if not exists if (shadowContainer && !previewFrame) { - createShadowIframe(); + createPreviewIframe(); } + // post the content to the backend, which returns it with a sandbox policy + // so the preview runs isolated and still loads its own external resources if (previewFrame) { - // use data url for null origin isolation - previewFrame.src = 'data:text/html;charset=utf-8,' + encodeURIComponent(content); - } - if (externalFrameRef) { - const embedContent = createEmbed(content); - const dataUrl = 'data:text/html;charset=utf-8,' + encodeURIComponent(embedContent); - externalFrameRef.location.replace(dataUrl); + submitPreviewForm(content, previewFrameName); } isRenderingPreview = false; }; @@ -1000,33 +985,14 @@ reader.readAsText(file); }; - const createEmbed = (content) => { - return ` - - - - - - - - - - - `; - }; - const openFullPagePreview = async (e) => { e.preventDefault(); const v = editor.getValue(); value = v; const content = await replaceTemplateVariables(v); - const blob = new Blob([createEmbed(content)], { type: 'text/html' }); - let url = URL.createObjectURL(blob); - externalFrameRef = window.open(url, '_blank'); + // open the current content isolated in a new tab through the backend so it + // gets its own sandbox policy and loads its own external resources + submitPreviewForm(content, '_blank'); }; const triggerFileInput = () => { diff --git a/frontend/src/lib/components/editor/SimpleCodeEditor.svelte b/frontend/src/lib/components/editor/SimpleCodeEditor.svelte index 3c986d20..12fe2ce0 100644 --- a/frontend/src/lib/components/editor/SimpleCodeEditor.svelte +++ b/frontend/src/lib/components/editor/SimpleCodeEditor.svelte @@ -130,6 +130,12 @@ /* @ts-ignore - editorOptions is not complete */ editor = monaco.editor.create(editorContainer, editorOptions); + // Ctrl/Cmd+S saves by submitting the surrounding form, so the editor's usual + // save shortcut maps to the page's save action. + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + editorContainer?.closest('form')?.requestSubmit(); + }); + // vim mode will be initialized by reactive statement if needed // Update value when editor content changes diff --git a/frontend/src/lib/components/modal/CompanyReportTemplateModal.svelte b/frontend/src/lib/components/modal/CompanyReportTemplateModal.svelte index 3e8a292e..690db815 100644 --- a/frontend/src/lib/components/modal/CompanyReportTemplateModal.svelte +++ b/frontend/src/lib/components/modal/CompanyReportTemplateModal.svelte @@ -135,7 +135,7 @@ {#if visible} - +
{ isSettingUp = true; try { @@ -113,7 +113,7 @@ } }; - // inline toggle — immediately persists the new enabled state + // inline toggle, immediately persists the new enabled state const onToggleEnabled = async () => { if (!scimConfig) return; isTogglingEnabled = true; @@ -258,7 +258,7 @@

Loading...

{:else if showTokenReveal && revealedToken} - +
diff --git a/frontend/src/lib/components/proxy/ProxyConfigBuilder.svelte b/frontend/src/lib/components/proxy/ProxyConfigBuilder.svelte index 17759018..3229a4ca 100644 --- a/frontend/src/lib/components/proxy/ProxyConfigBuilder.svelte +++ b/frontend/src/lib/components/proxy/ProxyConfigBuilder.svelte @@ -669,7 +669,7 @@ configData = configData; } - // url rewrite query param helpers — query is []{ find, replace } + // url rewrite query param helpers, query is []{ find, replace } function addURLRewriteQueryParam(rule) { rule.query = [...(rule.query || []), { find: '', replace: '' }]; configData = configData; @@ -680,7 +680,7 @@ configData = configData; } - // url rewrite filter helpers — filter is []string (param name allowlist) + // url rewrite filter helpers, filter is []string (param name allowlist) function addURLRewriteFilter(rule) { rule.filter = [...(rule.filter || []), '']; configData = configData; @@ -2517,7 +2517,7 @@
{/if} allowlist of query parameters to keep — if empty, all parameters are + >allowlist of query parameters to keep. If empty, all parameters are forwarded @@ -3459,7 +3459,7 @@ {/if} allowlist of query parameters to keep — if empty, all parameters are + >allowlist of query parameters to keep. If empty, all parameters are forwarded diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte index 8cae29ba..f51aab04 100644 --- a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte @@ -121,7 +121,7 @@ let logContainer; let userScrolledUp = false; - // live stream (View / Control) — populated once the backend sends {"type":"session","id":"..."} + // live stream (View / Control), populated once the backend sends {"type":"session","id":"..."} let streamSessionID = ''; let streamVisible = false; let streamControlMode = false; @@ -234,7 +234,7 @@ const remoteBrowserDTS = ` interface SessionOptions { - /** DevTools WebSocket URL — connects to an existing Chrome instead of launching one */ + /** DevTools WebSocket URL, connects to an existing Chrome instead of launching one */ remote?: string; /** SOCKS5 or HTTP proxy, e.g. "socks5://127.0.0.1:1080" */ proxy?: string; @@ -253,7 +253,7 @@ interface SessionOptions { /** * BCP 47 locale for Chrome's language setting, e.g. "en-US" or "en-GB". * Sets navigator.language, navigator.languages, and the Accept-Language header - * at the process level — consistent across the main frame AND Web Workers. + * at the process level, consistent across the main frame AND Web Workers. * Local mode only; ignored when connecting to a remote browser. * Prefer this over patching navigator.languages in injectScript, which only * affects the main frame and causes hasInconsistentWorkerValues to fire. @@ -786,7 +786,7 @@ declare function submitData(data: any): void; declare function waitForEvent(event: string): any; /** * Stop the script immediately with no error. - * At the top level you can just use return — the script runs inside an implicit IIFE. + * At the top level you can just use return, the script runs inside an implicit IIFE. * Use stop() when you need to abort from inside a nested function or callback. */ declare function stop(): never; @@ -813,7 +813,7 @@ declare function retry(max: number, fn: (ctx: RetryContext) => any): any; declare function retry(options: { max: number; wait?: number }, fn: (ctx: RetryContext) => any): any; // ECMAScript built-ins available in the goja runtime (ES2015+). -// (No DOM, no Node.js — those are not available in scripts.) +// (No DOM, no Node.js, those are not available in scripts.) // Instance methods for the string, number and boolean primitives. The String, // Number and Boolean call forms are declared further down; these interfaces are // what give a string value methods like indexOf, includes and split. @@ -1097,7 +1097,7 @@ declare var Infinity: number; monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ noSemanticValidation: false, noSyntaxValidation: false, - diagnosticCodesToIgnore: [1108] // 'return' outside function — valid here because the script runs inside an implicit IIFE + diagnosticCodesToIgnore: [1108] // 'return' outside function, valid here because the script runs inside an implicit IIFE }); monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ noLib: true, @@ -1340,7 +1340,7 @@ declare var Infinity: number; >

- Flags (one per line — --flag adds/overrides, !--flag removes) + Flags (one per line, --flag adds/overrides, !--flag removes)