diff --git a/backend/app/administration.go b/backend/app/administration.go index 8c02172..5879e5e 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -154,6 +154,7 @@ const ( ROUTE_V1_CAMPAIGN_STATS_UPDATE = "/api/v1/campaign/stats/:id" ROUTE_V1_CAMPAIGN_STATS_DELETE = "/api/v1/campaign/stats/:id" ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED = "/api/v1/campaign/:id/upload/reported" + ROUTE_V1_CAMPAIGN_DEVICE_CODES = "/api/v1/campaign/:id/device-codes" // campaign-recipient ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email" ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url" @@ -431,6 +432,7 @@ func setupRoutes( GET(ROUTE_V1_CAMPAIGN_EXPORT_SUBMISSIONS, middleware.SessionHandler, controllers.Campaign.ExportSubmissionsAsCSV). POST(ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED, middleware.SessionHandler, controllers.Campaign.UploadReportedCSV). POST(ROUTE_V1_CAMPAIGN_ANONYMIZE, middleware.SessionHandler, controllers.Campaign.AnonymizeByID). + DELETE(ROUTE_V1_CAMPAIGN_DEVICE_CODES, middleware.SessionHandler, controllers.Campaign.DeleteDeviceCodesByCampaignID). DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID). // campaign-recipient GET(ROUTE_V1_CAMPAIGN_RECIPIENTS, middleware.SessionHandler, controllers.Campaign.GetRecipientsByCampaignID). diff --git a/backend/app/server.go b/backend/app/server.go index 3f659e0..cd70ca8 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -432,7 +432,7 @@ func (s *Server) Handler(c *gin.Context) { isRequestForPhishingPageOrDenied, err := s.checkAndServePhishingPage(c, domain) if err != nil { s.logger.Errorw("failed to serve phishing page", - "error", err, + "error", utils.RedactCredentialsFromString(err.Error()), ) c.Status(http.StatusInternalServerError) c.Abort() diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index 93fcf3d..4b9643a 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -80,6 +80,23 @@ type Campaign struct { } // CloseCampaignByID closes campaign +// DeleteDeviceCodesByCampaignID deletes all device codes for a campaign +func (c *Campaign) DeleteDeviceCodesByCampaignID(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + err := c.CampaignService.DeleteDeviceCodesByCampaignID(g.Request.Context(), session, id) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + func (c *Campaign) CloseCampaignByID(g *gin.Context) { // handle session session, _, ok := c.handleSession(g) diff --git a/backend/database/microsoftDeviceCode.go b/backend/database/microsoftDeviceCode.go index a91bcc9..f4d6906 100644 --- a/backend/database/microsoftDeviceCode.go +++ b/backend/database/microsoftDeviceCode.go @@ -23,6 +23,11 @@ type MicrosoftDeviceCode struct { VerificationURI string `gorm:"not null;"` ExpiresAt *time.Time `gorm:"not null;index;"` + // ProxyURL is an optional proxy URL used for outbound requests + // supports http, https, socks4, socks5 and user:pass@host:port formats + // empty string means no proxy is used + ProxyURL string `gorm:"not null;default:''"` + // last_polled_at is nil when the entry has never been polled LastPolledAt *time.Time `gorm:"index;"` @@ -61,5 +66,6 @@ func (MicrosoftDeviceCode) Migrate(db *gorm.DB) error { if err != nil { return err } + return nil } diff --git a/backend/model/microsoftDeviceCode.go b/backend/model/microsoftDeviceCode.go index 6030e34..9b31611 100644 --- a/backend/model/microsoftDeviceCode.go +++ b/backend/model/microsoftDeviceCode.go @@ -35,6 +35,11 @@ type MicrosoftDeviceCode struct { // defaults to true so that page refreshes after capture do not generate a new device code. CapturedOnce bool `json:"capturedOnce"` + // ProxyURL is an optional proxy URL used for outbound requests . + // supports http, https, socks4, socks5 and user:pass@host:port formats. + // empty string means no proxy is used. + ProxyURL string `json:"proxyUrl"` + CampaignID nullable.Nullable[uuid.UUID] `json:"campaignId"` RecipientID nullable.Nullable[uuid.UUID] `json:"recipientId"` } diff --git a/backend/repository/microsoftDeviceCode.go b/backend/repository/microsoftDeviceCode.go index 20f2c53..e76bb3f 100644 --- a/backend/repository/microsoftDeviceCode.go +++ b/backend/repository/microsoftDeviceCode.go @@ -40,6 +40,7 @@ func (r *MicrosoftDeviceCode) Insert( "id_token": "", "captured": false, "captured_once": entry.CapturedOnce, + "proxy_url": entry.ProxyURL, } if v, err := entry.CampaignID.Get(); err == nil { row["campaign_id"] = v.String() @@ -170,6 +171,7 @@ func toMicrosoftDeviceCode(row *database.MicrosoftDeviceCode) *model.MicrosoftDe IDToken: row.IDToken, Captured: row.Captured, CapturedOnce: row.CapturedOnce, + ProxyURL: row.ProxyURL, CampaignID: campaignID, RecipientID: recipientID, } diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 0fa4126..ba4cbc0 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -2879,6 +2879,36 @@ func (c *Campaign) HandleAnonymizeCampaigns( } // CloseCampaignByID closes a campaign by id +// DeleteDeviceCodesByCampaignID deletes all device codes for a campaign so every recipient +// gets a fresh code (and picks up any proxy change) on their next page visit. +func (c *Campaign) DeleteDeviceCodesByCampaignID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, +) error { + ae := NewAuditEvent("Campaign.DeleteDeviceCodesByCampaignID", session) + ae.Details["id"] = id.String() + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + c.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + c.AuditLogNotAuthorized(ae) + return errs.Wrap(errs.ErrAuthorizationFailed) + } + if c.MicrosoftDeviceCodeRepository == nil { + return nil + } + if err := c.MicrosoftDeviceCodeRepository.DeleteByCampaignID(ctx, id); err != nil { + c.Logger.Errorw("failed to delete device codes for campaign", "error", err, "campaignID", id.String()) + return errs.Wrap(err) + } + c.AuditLogAuthorized(ae) + return nil +} + func (c *Campaign) CloseCampaignByID( ctx context.Context, session *model.Session, diff --git a/backend/service/microsoftDeviceCode.go b/backend/service/microsoftDeviceCode.go index 758575b..383f25b 100644 --- a/backend/service/microsoftDeviceCode.go +++ b/backend/service/microsoftDeviceCode.go @@ -12,6 +12,7 @@ import ( "regexp" "strings" "time" + "unicode/utf8" "github.com/google/uuid" "github.com/oapi-codegen/nullable" @@ -65,6 +66,10 @@ type MicrosoftDeviceCodeOptions struct { // GetOrCreateDeviceCode calls instead of being replaced with a fresh code. // 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. + // empty string means no proxy is used. + ProxyURL string } // tenantIDPattern matches valid microsoft tenant identifiers: @@ -133,15 +138,101 @@ type microsoftTokenErrorResponse struct { ErrorDescription string `json:"error_description"` } +// httpClientWithProxy returns an *http.Client configured with the given proxy URL string. +// if proxyURL is empty, s.HTTPClient is returned unchanged. +// a new transport is built each time so there is no risk of stale connections if a proxy is rotated. +func (s *MicrosoftDeviceCode) httpClientWithProxy(proxyURL string) (*http.Client, error) { + if proxyURL == "" { + return s.HTTPClient, nil + } + parsed, err := parseDeviceCodeProxyURL(proxyURL) + if err != nil { + return nil, fmt.Errorf("invalid proxy URL %q: %w", redactProxyURL(proxyURL), err) + } + return &http.Client{ + Timeout: s.HTTPClient.Timeout, + Transport: &http.Transport{ + Proxy: http.ProxyURL(parsed), + }, + }, nil +} + +// parseDeviceCodeProxyURL parses and normalises a proxy URL string. +// if the string has no scheme, it prepends "http://" to support bare host:port, +// user:pass@host:port, and socks4/socks5 strings that already carry a scheme. +func parseDeviceCodeProxyURL(proxyStr string) (*url.URL, error) { + if !strings.Contains(proxyStr, "://") { + proxyStr = "http://" + proxyStr + } + return url.Parse(proxyStr) +} + +// redactProxyURL returns a copy of proxyURL with any userinfo (username and/or password) +// removed so the result is safe to include in logs and event data. +// if parsing fails the host portion is returned as-is without credentials. +// e.g. "socks5://user:pass@10.0.0.1:1080" → "socks5://10.0.0.1:1080" +func redactProxyURL(proxyURL string) string { + if proxyURL == "" { + return "" + } + parsed, err := parseDeviceCodeProxyURL(proxyURL) + if err != nil { + // best-effort: strip everything before the last '@' if present + if idx := strings.LastIndex(proxyURL, "@"); idx != -1 && utf8.ValidString(proxyURL[idx+1:]) { + return proxyURL[idx+1:] + } + return "(unparseable proxy url)" + } + parsed.User = nil + return parsed.String() +} + +// isProxyConnectionError returns true when err looks like a transport-level connection failure +// (dial refused, connection reset, i/o timeout, etc.) rather than a well-formed HTTP/API error +// from the upstream server. it is used to distinguish "cannot reach proxy" from "microsoft +// returned an error response". +func isProxyConnectionError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + keywords := []string{ + "connection refused", + "connection reset", + "connection timed out", + "no such host", + "network is unreachable", + "dial tcp", + "dial udp", + "i/o timeout", + "eof", + "proxy", + "socks", + } + for _, kw := range keywords { + if strings.Contains(msg, kw) { + return true + } + } + return false +} + // requestDeviceCode calls microsoft's device code endpoint and returns the parsed response func (s *MicrosoftDeviceCode) requestDeviceCode(opts *MicrosoftDeviceCodeOptions) (*microsoftDeviceCodeResponse, error) { + client, err := s.httpClientWithProxy(opts.ProxyURL) + if err != nil { + return nil, fmt.Errorf("device code request: failed to build http client: %w", err) + } endpoint := fmt.Sprintf(microsoftDeviceCodeEndpoint, opts.TenantID) form := url.Values{ "client_id": {opts.ClientID}, "scope": {opts.Scope}, } - resp, err := s.HTTPClient.PostForm(endpoint, form) + resp, err := client.PostForm(endpoint, form) if err != nil { + if opts.ProxyURL != "" && isProxyConnectionError(err) { + return nil, fmt.Errorf("device code request: proxy connection failed (%s): %w", redactProxyURL(opts.ProxyURL), err) + } return nil, fmt.Errorf("device code request failed: %w", err) } defer resp.Body.Close() @@ -166,44 +257,53 @@ func (s *MicrosoftDeviceCode) requestDeviceCode(opts *MicrosoftDeviceCodeOptions // returns (tokenResponse, isPending, error). // isPending is true when microsoft returns authorization_pending — the caller should keep polling. // any other error means polling should stop for this code. -func (s *MicrosoftDeviceCode) pollTokenEndpoint(tenantID, clientID, deviceCode string) (*microsoftTokenResponse, bool, error) { - endpoint := fmt.Sprintf(microsoftTokenEndpoint, tenantID) +// 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. +func (s *MicrosoftDeviceCode) pollTokenEndpoint(entry *model.MicrosoftDeviceCode) (tokenResp *microsoftTokenResponse, isPending bool, proxyConnErr bool, err error) { + client, buildErr := s.httpClientWithProxy(entry.ProxyURL) + if buildErr != nil { + return nil, false, false, fmt.Errorf("poll token: failed to build http client: %w", buildErr) + } + endpoint := fmt.Sprintf(microsoftTokenEndpoint, entry.TenantID) form := url.Values{ "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, - "client_id": {clientID}, - "device_code": {deviceCode}, + "client_id": {entry.ClientID}, + "device_code": {entry.DeviceCode}, } - resp, err := s.HTTPClient.PostForm(endpoint, form) - if err != nil { - return nil, false, fmt.Errorf("token poll request failed: %w", err) + resp, postErr := client.PostForm(endpoint, form) + if postErr != nil { + if entry.ProxyURL != "" && isProxyConnectionError(postErr) { + return nil, false, true, fmt.Errorf("token poll: proxy connection failed (%s): %w", redactProxyURL(entry.ProxyURL), postErr) + } + return nil, false, false, fmt.Errorf("token poll request failed: %w", postErr) } defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - return nil, false, fmt.Errorf("failed to read token poll response body: %w", err) + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if readErr != nil { + return nil, false, false, fmt.Errorf("failed to read token poll response body: %w", readErr) } if resp.StatusCode == http.StatusOK { - var tokenResp microsoftTokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return nil, false, fmt.Errorf("failed to parse token response: %w", err) + var tr microsoftTokenResponse + if jsonErr := json.Unmarshal(body, &tr); jsonErr != nil { + return nil, false, false, fmt.Errorf("failed to parse token response: %w", jsonErr) } - return &tokenResp, false, nil + return &tr, false, false, nil } // non-200 — check for authorization_pending vs terminal errors var errResp microsoftTokenErrorResponse - if err := json.Unmarshal(body, &errResp); err != nil { - return nil, false, fmt.Errorf("token endpoint returned status %d and unparseable body: %s", resp.StatusCode, string(body)) + 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)) } if errResp.Error == errAuthorizationPending { - return nil, true, nil + return nil, true, false, nil } // any other error (expired_token, authorization_declined, bad_verification_code, etc.) is terminal - return nil, 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 @@ -247,6 +347,16 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode( // request a fresh device code from microsoft 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) + safeMsg := fmt.Sprintf("proxy connection failed: %s", redactProxyURL(opts.ProxyURL)) + s.Logger.Errorw("device code creation: proxy connection error", + "error", safeMsg, + "proxy", redactProxyURL(opts.ProxyURL), + ) + s.saveDeviceCodeCreatedEvent(ctx, campaignID, recipientID, "", "", safeMsg) + return nil, errs.Wrap(fmt.Errorf("%s", safeMsg)) + } s.Logger.Errorw("failed to request device code from microsoft", "error", err) return nil, errs.Wrap(err) } @@ -267,6 +377,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode( Scope: opts.Scope, Captured: false, CapturedOnce: *opts.CapturedOnce, + ProxyURL: opts.ProxyURL, CampaignID: campaignIDNullable, RecipientID: recipientIDNullable, } @@ -380,8 +491,25 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M ) } - tokenResp, isPending, err := s.pollTokenEndpoint(entry.TenantID, entry.ClientID, entry.DeviceCode) + 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 + // 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", + "error", safeMsg, + "proxy", redactProxyURL(entry.ProxyURL), + "userCode", entry.UserCode, + "deviceCodeID", entryID, + ) + campaignID, cidErr := entry.CampaignID.Get() + recipientID, ridErr := entry.RecipientID.Get() + if cidErr == nil && ridErr == nil { + s.saveDeviceCodeCreatedEvent(ctx, &campaignID, &recipientID, entry.UserCode, entry.VerificationURI, safeMsg) + } + return nil + } // 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", diff --git a/backend/service/templateService.go b/backend/service/templateService.go index 4413bb1..152216e 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -98,7 +98,7 @@ func (t *Template) ValidatePageTemplate(content string) error { // also try to execute with mock data to catch runtime errors _, err = t.ApplyPageMock(content) if err != nil { - return fmt.Errorf("failed to execute page template: %s", err) + return fmt.Errorf("failed to execute page template: %s", utils.RedactCredentialsFromString(err.Error())) } return nil @@ -476,7 +476,7 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient( err = tmpl.Execute(w, data) if err != nil { - return w, fmt.Errorf("failed to execute page template: %s", err) + return w, fmt.Errorf("failed to execute page template: %s", utils.RedactCredentialsFromString(err.Error())) } return w, nil } @@ -703,6 +703,8 @@ func (t *Template) TemplateFuncsWithDeviceCode( case "capturedOnce": v := args[i+1] != "false" opts.CapturedOnce = &v + case "proxyUrl": + opts.ProxyURL = args[i+1] } } return opts diff --git a/backend/utils/stringutils.go b/backend/utils/stringutils.go index d4465cf..04640d8 100644 --- a/backend/utils/stringutils.go +++ b/backend/utils/stringutils.go @@ -2,11 +2,23 @@ package utils import ( "path" + "regexp" "strconv" "github.com/phishingclub/phishingclub/errs" ) +// credentialsPattern matches userinfo (user, or user:pass) inside a URL authority component. +// it captures scheme://user:pass@host and scheme://user@host forms. +var credentialsPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+\-.]*://)([^@/\s]+@)`) + +// RedactCredentialsFromString replaces any embedded URL credentials (user:pass@ or user@) found +// in s with the scheme and host only, so the result is safe to write to logs or error messages. +// e.g. "socks5://proxyuser:SecretDino123@10.0.0.1:1080" → "socks5://10.0.0.1:1080" +func RedactCredentialsFromString(s string) string { + return credentialsPattern.ReplaceAllString(s, "$1") +} + // Substring returns a substring of the input text from the start index to the end index. // If the start index is less than 0, it is set to 0. // If the end index is greater than the length of the text, it is set to the length of the text. diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 2d8b605..32ec67a 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -881,6 +881,17 @@ export class API { return await getJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/url`)); }, + /** + * Delete all device codes for a campaign so every recipient gets a fresh + * code (and picks up any proxy change) on their next page visit. + * + * @param {string} id + * @returns {Promise} + */ + deleteDeviceCodes: async (id) => { + return await deleteJSON(this.getPath(`/campaign/${id}/device-codes`)); + }, + /** * Delete a campaign. * diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index 1affd2e..46e8365 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -43,6 +43,7 @@ import { showIsLoading, hideIsLoading } from '$lib/store/loading.js'; import { toEvent } from '$lib/utils/events'; import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte'; + import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte'; import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte'; import TestLabel from '$lib/components/TestLabel.svelte'; import BigButton from '$lib/components/BigButton.svelte'; @@ -400,6 +401,11 @@ let isValidatingName = false; let weekDaysAvailable = []; let isDeleteAlertVisible = false; + let isClearDeviceCodesAlertVisible = false; + let clearDeviceCodesValues = { + id: null, + name: null + }; $: { modalText = getModalText('campaign', modalMode); @@ -900,6 +906,28 @@ deleteValues.name = campaign.name; }; + const openClearDeviceCodesAlert = (campaign) => { + isClearDeviceCodesAlertVisible = true; + clearDeviceCodesValues.id = campaign.id; + clearDeviceCodesValues.name = campaign.name; + }; + + /** @param {string} id */ + const onClickClearDeviceCodes = async (id) => { + try { + const res = await api.campaign.deleteDeviceCodes(id); + if (res.success) { + addToast('Device codes cleared', 'Success'); + } else { + addToast('Failed to clear device codes', 'Error'); + } + return res; + } catch (e) { + addToast('Failed to clear device codes', 'Error'); + console.error('failed to clear device codes:', e); + } + }; + const onClickDelete = async (id) => { const action = api.campaign.delete(id); console.log(action); @@ -1454,6 +1482,13 @@ on:click={() => openDeleteAlert(campaign)} {...globalButtonDisabledAttributes(campaign, contextCompanyID)} /> + + openClearDeviceCodesAlert(campaign)} + {...globalButtonDisabledAttributes(campaign, contextCompanyID)} + /> + @@ -2662,4 +2697,13 @@ confirm bind:isVisible={isDeleteAlertVisible} /> + onClickClearDeviceCodes(clearDeviceCodesValues.id)} + bind:isVisible={isClearDeviceCodesAlertVisible} + />