Add proxy support in device codes

Add clear device codes for campaign
This commit is contained in:
Ronni Skansing
2026-03-26 16:03:19 +01:00
parent 7852659520
commit 73b3287137
12 changed files with 282 additions and 23 deletions
+2
View File
@@ -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).
+1 -1
View File
@@ -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()
+17
View File
@@ -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)
+6
View File
@@ -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
}
+5
View File
@@ -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"`
}
@@ -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,
}
+30
View File
@@ -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,
+148 -20
View File
@@ -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",
+4 -2
View File
@@ -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
+12
View File
@@ -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.
+11
View File
@@ -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<ApiResponse>}
*/
deleteDeviceCodes: async (id) => {
return await deleteJSON(this.getPath(`/campaign/${id}/device-codes`));
},
/**
* Delete a campaign.
*
+44
View File
@@ -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)}
/>
<ConditionalDisplay show="blackbox">
<TableDropDownButton
name="Clear device codes"
on:click={() => openClearDeviceCodesAlert(campaign)}
{...globalButtonDisabledAttributes(campaign, contextCompanyID)}
/>
</ConditionalDisplay>
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
@@ -2662,4 +2697,13 @@
confirm
bind:isVisible={isDeleteAlertVisible}
/>
<DeleteAlert
title="Clear device codes"
list={['All active device codes for this campaign will be deleted']}
name={clearDeviceCodesValues.name}
actionMessage="Clear all device codes for"
permanent={false}
onClick={() => onClickClearDeviceCodes(clearDeviceCodesValues.id)}
bind:isVisible={isClearDeviceCodesAlertVisible}
/>
</main>