Skip to content

Marketing Planning Module - Security Analysis and Recommendations

Date: December 15, 2025 Scope: Backend business logic, REST APIs, and frontend code for Marketing Planning module Files Analyzed: - tqapi/src/main/java/com/perun/tlinq/api/MarketingApi.java - tqapp/src/main/java/com/perun/tlinq/entity/marketing/MarketingFacade.java - tqweb-adm/js/modules/mktplan.js


Executive Summary

The Marketing Planning module implementation contains several security vulnerabilities that should be addressed before production deployment. This document identifies the Top 5 security issues ranked by severity and provides actionable recommendations for remediation.

Priority Issue Severity Effort Status
1 ~~Weak Session Authentication~~ ~~Critical~~ Low N/A REVIEWED - Not a vulnerability
2 Missing Authorization Controls Critical Medium IMPLEMENTED
3 Cross-Site Scripting (XSS) Vulnerabilities High Medium IMPLEMENTED
4 Insecure Direct Object References (IDOR) High Medium IMPLEMENTED
5 Input Validation Gaps Medium Low IMPLEMENTED

Issue #1: ~~Weak Session Authentication~~ (Reclassified)

STATUS: REVIEWED (December 15, 2025)

Original Severity: Critical → Revised Severity: Low/Informational

After detailed code review, the original analysis was based on a misunderstanding of the authentication architecture. The session parameter is a database connection identifier, NOT the user authentication mechanism. No code changes required.

Original Concern

The API layer accepts an empty session token and falls back to a system session, which appeared to bypass authentication.

Actual Authentication Architecture

The system uses a multi-layer authentication model:

Layer 1: OAuth2-Proxy (Network Level)

  • Sits in front of the API server as a reverse proxy
  • Validates OAuth2 tokens with Keycloak before requests reach the API
  • Unauthenticated users are redirected to Keycloak login
  • Injects trusted headers that cannot be spoofed by clients: X-User, X-Email, X-Roles, X-Name

Layer 2: AuthenticationFilter.java (API Level)

// Reads identity from OAuth2-Proxy headers (trusted, set by proxy)
String userId = requestContext.getHeaderString("X-User");
String roles = requestContext.getHeaderString("X-Roles");
String userEmail = requestContext.getHeaderString("X-Email");

// If headers missing (misconfiguration), defaults to guest - NOT authenticated user
if(userId == null || roles == null) {
    userId = "guest";
    roles = "guest";
}

// Checks API authorization - returns 403 if role not permitted
if(!apiRoleManager.isUserAuthorized(apiPath, userRoles)) {
    requestContext.abortWith(Response.status(Response.Status.FORBIDDEN)...);
}

Layer 3: API Role Configuration (api-roles.properties)

# All marketing APIs require agent or admin role
# guest role CANNOT access any marketing endpoints
marketing/currentuser=agent,admin
marketing/campaign/list=agent,admin
marketing/campaign/write=agent,admin
marketing/activity/write=agent,admin
# ... etc

Layer 4: Team Member Validation (Business Level - Added in Issue #2 fix)

  • currentTeamMemberId derived server-side from X-Email header
  • Write operations require valid team member identity
  • Cannot be spoofed by client

Why Original Analysis Was Incorrect

Original Concern Actual Status
"Empty session bypasses authentication" FALSE - session is DB connection token, not user auth; auth is via OAuth2-Proxy
"Any unauthenticated user can access marketing data" FALSE - OAuth2-Proxy blocks unauthenticated; guest role returns 403 on marketing APIs
"Audit trail is meaningless" FIXED - Team member identity now derived server-side from authenticated email (Issue #2)

Remaining Low-Priority Concerns

  1. Dev-Mode Bypass (Acceptable for Development)
  2. When dev-mode=true in tlinqapi.properties, authentication is bypassed
  3. Documented behavior; must never be enabled in production

  4. Parameter Naming Clarity (Code Quality)

  5. The session parameter name is confusing - it's a DB connection token
  6. Consider renaming to dbToken or documenting its purpose

  7. Infrastructure Dependency (Operational)

  8. Security relies on OAuth2-Proxy being properly configured
  9. Network must prevent direct API access bypassing the proxy

Recommendation

No code changes required. The authentication is secure when deployed correctly.

Operational checklist for production: - [ ] Verify dev-mode=false in tlinqapi.properties - [ ] Confirm OAuth2-Proxy is configured and routing all traffic - [ ] Verify network rules prevent direct API access (bypass of OAuth2-Proxy) - [ ] Review api-roles.properties to ensure appropriate role restrictions


Issue #2: Missing Authorization Controls

STATUS: IMPLEMENTED (December 15, 2025)

Part 1: Authorization Controls - Added to approveActivity() and returnActivity() methods in MarketingFacade.java: - Only team members with 'reviewer' or 'manager' role can approve/reject activities - Users cannot approve/reject their own submissions - New error codes FORBIDDEN and UNAUTHORIZED added to TlinqErr.java - New helper methods: hasApprovalRole(), isActivitySubmitter(), validateApprovalAuthorization()

Part 2: Server-Side Team Member Identification - Updated MarketingApi.createFacade(): - Team member identity now derived from authenticated user's email (X-Email header from OAuth2-Proxy) - Added getTeamMemberByEmail() method to MarketingFacade for user lookup - Client can no longer impersonate other team members by passing fake currentTeamMemberId

Part 3: Backend Write Operation Validation - Added validateTeamMemberIdentity() to MarketingFacade: - Write operations (create/update campaign, create/update activity, send for review, assign team members) now require a valid team member identity - Returns clear error message when user is not registered as a team member - Added new API endpoint /marketing/currentuser to check user's team member status

Part 4: Frontend Team Member Verification - Updated mktplan.js: - Added checkCurrentUser() function called on page initialization - If user is not a registered team member, shows warning banner and disables all editing buttons - Defense-in-depth: all write functions (newCampaign, saveCampaign, newActivity, saveActivity, etc.) also check team member status - Provides clear user guidance to contact administrator to be added to the marketing team

Description

The API performs no role-based access control. Any authenticated user can perform any operation including approving their own work, deleting campaigns, and modifying activities assigned to others.

Affected Code

MarketingApi.java - All endpoints lack authorization checks:

@POST
@Path("/activity/approve")
public Response approveActivity(Map reqData) {
    // No check if user has approval authority
    // No check if user is the designated reviewer
    MarketingFacade facade = createFacade(sessionToken, reqData);
    CActivity activity = facade.approveActivity(activityId);
    // ...
}

MarketingFacade.java - Business logic trusts currentTeamMemberId from client:

public MarketingFacade(String token, Integer currentTeamMemberId) throws TlinqClientException {
    // currentTeamMemberId is passed from client without verification
    this.currentTeamMemberId = currentTeamMemberId;
}

Risk

  • Users can approve their own activities (separation of duties violation)
  • Users can impersonate other team members by passing different currentTeamMemberId
  • Content creators can delete campaigns or modify manager-level settings
  • No audit integrity - actions attributed to wrong users

Recommendation

  1. Derive team member from authenticated session, not client input:

    public MarketingFacade(String token) throws TlinqClientException {
        super(token);
        // Look up team member from authenticated user
        Session session = SessionManager.get(token);
        this.currentTeamMemberId = lookupTeamMemberByUserId(session.getUserId());
        this.currentTeamMemberRole = getTeamMemberRole(this.currentTeamMemberId);
    }
    

  2. Add role-based authorization to sensitive operations:

    public CActivity approveActivity(Integer activityId) throws TlinqClientException {
        CActivity activity = getActivity(activityId);
    
        // Check if current user is the designated reviewer
        if (!currentTeamMemberId.equals(activity.getReviewerId())) {
            throw new TlinqClientException(TlinqErr.FORBIDDEN,
                "Only the assigned reviewer can approve this activity");
        }
    
        // Prevent self-approval
        if (currentTeamMemberId.equals(activity.getModifiedBy())) {
            throw new TlinqClientException(TlinqErr.FORBIDDEN,
                "Cannot approve your own submission");
        }
    
        // Continue with approval...
    }
    

  3. Define role permissions for operations:

Operation CONTENT_CREATOR DESIGNER REVIEWER MANAGER
Create Activity Yes Yes Yes Yes
Edit Own Activity Yes Yes Yes Yes
Edit Any Activity No No No Yes
Approve Activity No No Yes Yes
Delete Campaign No No No Yes
Manage Team Members No No No Yes

Issue #3: Cross-Site Scripting (XSS) Vulnerabilities

STATUS: IMPLEMENTED (December 15, 2025)

Server-Side Sanitization - Added OWASP HTML Sanitizer to MarketingFacade.java: - RICH_TEXT_POLICY: Allows safe formatting tags from Quill editor (p, br, strong, em, ul, ol, li, a, etc.) while stripping scripts - PLAIN_TEXT_POLICY: Strips ALL HTML, keeping only text content for plain text fields - sanitizePlainText(): Applied to activity names, descriptions, team member names/emails, audience names, etc. - sanitizeRichText(): Applied to storyText content from Quill editor - decodeHtmlEntities(): Decodes &, <, etc. after sanitization for proper display

Frontend HTML Escaping - Updated mktplan.js: - Added escapeHtml() utility function using textContent/innerHTML pattern - Fixed renderTeamMembersList() to escape all user content in template literals - Fixed renderAudiencesList() to escape all user content - Fixed populateTeamMemberDropdowns() and populateAudienceDropdown() to escape content - Fixed showTeamMemberWarning() to use safe DOM methods (createTextNode) - Fixed renderAudienceTags() to use createTextNode instead of innerHTML - Added comment documenting that Quill innerHTML is safe due to backend sanitization

Security Headers - Added to CORSResponseFilter.java: - X-Content-Type-Options: nosniff - Prevents MIME type sniffing - X-Frame-Options: DENY - Prevents clickjacking - Referrer-Policy: strict-origin-when-cross-origin - Controls referrer leakage - Content-Security-Policy: default-src 'none'; frame-ancestors 'none' - Strict CSP for API responses

Dependency Added - build.gradle.kts includes:

implementation("com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1")

Description

User-supplied content is rendered without proper sanitization, allowing injection of malicious scripts that execute in other users' browsers.

Affected Code

mktplan.js - Quill editor content (line ~580):

// Story text from database rendered as HTML
if (selectedActivity.storyText) {
    quillEditor.root.innerHTML = selectedActivity.storyText;
}

mktplan.js - Template literals (renderTeamMembersList, renderAudiencesList):

teamMembers.forEach(tm => {
    tbody.append(`
        <tr data-tm-id="${tm.teamMemberId}">
            <td>${tm.displayName || ''}</td>  // Unsanitized
            <td>${tm.email || ''}</td>        // Unsanitized
            <td>${tm.role || ''}</td>
        </tr>
    `);
});

mktplan.js - Activity tree rendering:

activityItem.innerHTML = `
    <span class="activity-name">${activity.activityName}</span>
    <span class="activity-status status-${statusClass}">${activity.status}</span>
`;

mktplan.js - Rejection banner:

$('#rejection_banner').find('.rejection-text').text(selectedActivity.rejectionReason);
// Using .text() here is safe - but other places use innerHTML

Risk

  • Stored XSS: Malicious script in activity name, description, or story text executes when other users view
  • Session hijacking via document.cookie theft
  • Keylogging and credential theft
  • Defacement of admin interface

Recommendation

  1. Sanitize all user input on the server before storing:

    import org.owasp.html.PolicyFactory;
    import org.owasp.html.Sanitizers;
    
    private static final PolicyFactory SANITIZER = Sanitizers.FORMATTING
        .and(Sanitizers.LINKS)
        .and(Sanitizers.BLOCKS);
    
    public CActivity saveActivity(CActivity activity) {
        // Sanitize rich text content
        if (activity.getStoryText() != null) {
            activity.setStoryText(SANITIZER.sanitize(activity.getStoryText()));
        }
        // Escape plain text fields
        activity.setActivityName(StringEscapeUtils.escapeHtml4(activity.getActivityName()));
        activity.setDescription(StringEscapeUtils.escapeHtml4(activity.getDescription()));
        // ...
    }
    

  2. Use safe DOM methods on the frontend instead of innerHTML:

    // Instead of template literals with innerHTML
    function escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }
    
    teamMembers.forEach(tm => {
        const row = document.createElement('tr');
        row.dataset.tmId = tm.teamMemberId;
        row.innerHTML = `
            <td>${escapeHtml(tm.displayName || '')}</td>
            <td>${escapeHtml(tm.email || '')}</td>
            <td>${escapeHtml(tm.role || '')}</td>
        `;
        tbody.append(row);
    });
    

  3. Configure Content Security Policy headers:

    response.setHeader("Content-Security-Policy",
        "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
    


Issue #4: Insecure Direct Object References (IDOR)

STATUS: IMPLEMENTED (December 15, 2025)

IDOR Protection Helper Methods - Added to MarketingFacade.java: - isManager(): Checks if current team member has 'manager' or 'admin' role - canAccessCampaign(): Validates user can access campaign (creator, manager, or involved in activities) - canAccessActivity(): Validates user can access activity (creator, assignee, reviewer, or manager) - validateCampaignAccess(): Throws FORBIDDEN exception if access denied - validateActivityAccess(): Throws FORBIDDEN exception if access denied

Applied IDOR Checks: - getCampaign(): Now validates user can access the campaign before returning data - getActivity(): Now validates user can access the activity before returning data - All methods that call getCampaign/getActivity inherit the protection automatically

Access Rules: - Managers/admins can access all resources - Users can access campaigns they created - Users can access activities they created, are assigned to, or are reviewing - Users can access campaigns if they have access to any activity within it

Description

API endpoints accept entity IDs directly from user input without verifying the user has permission to access or modify those specific resources.

Affected Code

MarketingApi.java - All entity operations:

@POST
@Path("/activity/read")
public Response getActivity(Map reqData) {
    Integer id = getIntegerParam(reqData, "activityId");
    // No check if user can access this activity
    CActivity activity = facade.getActivity(id);
    return buildResponse(activity);
}

@POST
@Path("/campaign/delete")
public Response deleteCampaign(Map reqData) {
    Integer id = getIntegerParam(reqData, "campaignId");
    // No check if user owns or can manage this campaign
    facade.deleteCampaign(id);
    return buildSuccessResponse();
}

Risk

  • Users can access activities from other teams or organizations
  • Enumeration attacks to discover valid IDs
  • Unauthorized modification or deletion of any entity
  • Data leakage across tenant boundaries (if multi-tenant)

Recommendation

  1. Add ownership/permission checks in the facade layer:

    public CActivity getActivity(Integer activityId) throws TlinqClientException {
        CActivity activity = loadActivity(activityId);
    
        // Check if user can access this activity
        if (!canAccessActivity(currentTeamMemberId, activity)) {
            throw new TlinqClientException(TlinqErr.FORBIDDEN,
                "Access denied to activity: " + activityId);
        }
    
        return activity;
    }
    
    private boolean canAccessActivity(Integer teamMemberId, CActivity activity) {
        // User created it
        if (teamMemberId.equals(activity.getCreatedBy())) return true;
    
        // User is assigned to it
        if (activity.getAssignments() != null) {
            for (CActivityAssignment a : activity.getAssignments()) {
                if (teamMemberId.equals(a.getTeamMemberId())) return true;
            }
        }
    
        // User is the reviewer
        if (teamMemberId.equals(activity.getReviewerId())) return true;
    
        // User is a manager (can access all)
        if (isManager(teamMemberId)) return true;
    
        return false;
    }
    

  2. Use indirect references where appropriate:

    // Instead of exposing database IDs, use UUIDs or hashed references
    public String generateActivityReference(Integer activityId) {
        return HashUtils.hmac(activityId.toString(), secretKey);
    }
    


Issue #5: Input Validation Gaps

STATUS: IMPLEMENTED (December 15, 2025)

Validation Constants - Added to MarketingFacade.java: - MAX_NAME_LENGTH = 200: For names, titles, filenames - MAX_DESCRIPTION_LENGTH = 2000: For description fields - MAX_STORY_TEXT_LENGTH = 50000: For rich text content - MAX_EMAIL_LENGTH = 254: For email addresses - MAX_PHONE_LENGTH = 20: For phone numbers - MAX_URL_LENGTH = 2048: For media URLs - MAX_REJECTION_REASON_LENGTH = 1000: For rejection reasons - VALID_ACTIVITY_TYPES, VALID_MEDIA_TYPES, VALID_ROLES: Enum validation sets - VALID_CAMPAIGN_STATUSES, VALID_ACTIVITY_STATUSES: Status validation sets

Validation Helper Methods: - validateRequired(): Checks required fields are not null/empty - validateMaxLength(): Validates string length limits - validateEnum(): Validates value is in allowed set - validateUrl(): Validates URL format and protocol (http/https only) - validateEmail(): Validates email format

Applied Validation to All Write Methods: - createTeamMember(), updateTeamMember(): Name, email, phone, role validation - createAudienceSegment(), updateAudienceSegment(): Name, description validation - createCampaign(), updateCampaign(): Name, description, goals validation - createActivity(), updateActivity(): Name, type, description, story text validation - updateActivityStoryText(): Story text length validation - returnActivity(): Rejection reason length validation - addMedia(): Media type, URL, filename, description validation

Description

User input is accepted without validation for length, format, or content type, potentially causing data integrity issues, injection attacks, or denial of service.

Affected Code

MarketingApi.java - No validation on string inputs:

@POST
@Path("/activity/save")
public Response saveActivity(Map reqData) {
    CActivity activity = new CActivity();
    activity.setActivityName((String) reqData.get("activityName")); // No length check
    activity.setDescription((String) reqData.get("description"));   // No length check
    activity.setStoryText((String) reqData.get("storyText"));       // Could be huge
    activity.setActivityType((String) reqData.get("activityType")); // No enum validation
    // ...
}

MarketingFacade.java - Media URL accepted without validation:

public CActivityMedia saveMedia(CActivityMedia media) throws TlinqClientException {
    // mediaUrl could be any string - no URL validation
    // Could be javascript:, data:, or malicious external URL
    return (CActivityMedia) write(media);
}

MarketingFacade.java - Date parsing:

private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
// SimpleDateFormat is not thread-safe - concurrent access causes data corruption

Risk

  • Database overflow errors from excessively long strings
  • Invalid enum values causing processing errors
  • Malicious URLs stored and rendered
  • Thread-safety bugs causing incorrect date parsing
  • Resource exhaustion from large payloads

Recommendation

  1. Add validation annotations to entity classes:

    import jakarta.validation.constraints.*;
    
    public class CActivity extends TlinqEntity {
        @NotBlank(message = "Activity name is required")
        @Size(max = 200, message = "Activity name must be under 200 characters")
        private String activityName;
    
        @Size(max = 2000, message = "Description must be under 2000 characters")
        private String description;
    
        @Size(max = 50000, message = "Story text must be under 50000 characters")
        private String storyText;
    
        @Pattern(regexp = "SOCIAL_MEDIA|EMAIL|BLOG|PRINT|EVENT|OTHER",
                 message = "Invalid activity type")
        private String activityType;
    }
    

  2. Validate in the API layer:

    @POST
    @Path("/activity/save")
    public Response saveActivity(Map reqData) {
        // Validate required fields
        String name = (String) reqData.get("activityName");
        if (name == null || name.trim().isEmpty()) {
            return buildErrorResponse("Activity name is required");
        }
        if (name.length() > 200) {
            return buildErrorResponse("Activity name exceeds maximum length");
        }
    
        // Validate activity type against allowed values
        String type = (String) reqData.get("activityType");
        if (!VALID_ACTIVITY_TYPES.contains(type)) {
            return buildErrorResponse("Invalid activity type: " + type);
        }
    
        // ...
    }
    

  3. Validate URLs before storing:

    private void validateMediaUrl(String url) throws TlinqClientException {
        if (url == null) return;
    
        try {
            URL parsed = new URL(url);
            String protocol = parsed.getProtocol().toLowerCase();
            if (!protocol.equals("https") && !protocol.equals("http")) {
                throw new TlinqClientException(TlinqErr.BADPARAM,
                    "Only HTTP/HTTPS URLs allowed");
            }
            // Optionally validate against allowed domains
        } catch (MalformedURLException e) {
            throw new TlinqClientException(TlinqErr.BADPARAM, "Invalid URL format");
        }
    }
    

  4. Use thread-safe date formatting:

    // Instead of static SimpleDateFormat
    private static final DateTimeFormatter DATE_FORMAT =
        DateTimeFormatter.ofPattern("yyyy-MM-dd");
    
    // Or use ThreadLocal
    private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
    


Implementation Priority

Phase 1 - Critical (Immediate) - COMPLETED

  1. ~~Fix session authentication fallback~~ - REVIEWED: Not a vulnerability (see Issue #1)
  2. ~~Add authorization checks for approve/reject operations~~ - IMPLEMENTED
  3. ~~Derive currentTeamMemberId from session, not client input~~ - IMPLEMENTED

Phase 2 - High (Next Priority) - COMPLETED

  1. ~~Implement XSS sanitization for stored content~~ - IMPLEMENTED (OWASP HTML Sanitizer)
  2. ~~Add IDOR protection for sensitive operations~~ - IMPLEMENTED (canAccessCampaign/Activity)
  3. ~~Update frontend to escape all user content~~ - IMPLEMENTED (escapeHtml function)

Phase 3 - Medium - COMPLETED

  1. ~~Add comprehensive input validation~~ - IMPLEMENTED (validateRequired/MaxLength/Enum/Url)
  2. Fix SimpleDateFormat thread-safety - NOT APPLICABLE (no static SimpleDateFormat in MarketingFacade)
  3. ~~Implement CSP headers~~ - IMPLEMENTED (CORSResponseFilter.java)
  4. Add rate limiting to prevent abuse - DEFERRED (infrastructure-level concern)

Testing Recommendations

After implementing fixes, verify with:

  1. Authentication Tests (Verify existing infrastructure)
  2. Verify OAuth2-Proxy redirects unauthenticated users to login
  3. Verify direct API access (bypassing proxy) is blocked by network rules
  4. Verify dev-mode=false in production configuration

  5. Authorization Tests - Can be tested now

  6. Non-reviewer attempts approval - should return 403 with role error
  7. Self-approval attempt - should return 403 with self-approval error
  8. Non-team-member attempts write operation - should return 403 with team member error

  9. XSS Tests - Can be tested now

  10. Store <script>alert('XSS')</script> in activity name - should be stripped
  11. Store <img src=x onerror=alert('XSS')> in description - should be stripped
  12. Verify scripts don't execute when viewing
  13. Verify safe formatting (bold, italic, links) is preserved in storyText

  14. IDOR Tests - Can be tested now

  15. Access activity belonging to different team - should return 403 FORBIDDEN
  16. Access campaign you didn't create and aren't involved in - should return 403 FORBIDDEN
  17. Verify managers/admins can access all resources
  18. Verify creators can access their own campaigns/activities
  19. Verify assignees can access activities they're assigned to

  20. Input Validation Tests - Can be tested now

  21. Submit activity name > 200 characters - should return INVALID_PARAMETER
  22. Submit invalid activity type (e.g., "INVALID") - should return error with valid values
  23. Submit javascript: URL as media URL - should return error about protocol
  24. Submit malformed URL - should return URL format error
  25. Submit invalid role when creating team member - should return error with valid roles
  26. Submit empty required field - should return "required" error message

Second Pass Security Improvements (December 15, 2025)

A second security analysis pass identified additional hardening opportunities. All issues have been implemented.

Priority Issue Severity Status
1 Thread-unsafe SimpleDateFormat in MarketingApi High IMPLEMENTED
2 IDOR checks missing on list operations Medium IMPLEMENTED
3 Role-based authorization for admin operations Medium IMPLEMENTED
4 Error messages expose internal details Low IMPLEMENTED
5 Session tokens logged in plain text Low IMPLEMENTED

Issue #1: Thread-unsafe SimpleDateFormat (High)

Problem: MarketingApi.java used a static SimpleDateFormat instance which is not thread-safe. Concurrent requests could produce corrupted date values.

Fix: Replaced with java.time.DateTimeFormatter which is thread-safe:

// Before (thread-unsafe)
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");

// After (thread-safe)
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");

Issue #2: IDOR checks on list operations (Medium)

Problem: List operations (listCampaigns, listCampaignsByStatus, getActivitiesForCampaign, getAssignmentsForActivity, getMediaForActivity, getAudienceSegmentsForActivity, getChangeLog) returned all records without filtering by user access.

Fix: Added user-scoped filtering to MarketingFacade.java: - Non-managers only see campaigns/activities they have access to - Managers/admins can see all records - Added internal helper methods to avoid circular dependencies: - canAccessCampaignInternal() - checks access without triggering validation - canAccessActivityInternal() - checks access without triggering validation - getActivitiesForCampaignInternal() - retrieves activities without IDOR check (for internal use) - getAssignmentsForActivityInternal() - retrieves assignments without IDOR check (for internal use) - getChangeLog() now validates entity access based on entity type (campaign vs activity)

Issue #3: Role-based authorization for admin operations (Medium)

Problem: Sensitive operations like creating/updating team members and audience segments lacked role-based checks.

Fix: Added authorization checks to MarketingFacade.java: - Added requireManagerRole() helper that throws TlinqErr.FORBIDDEN if not manager/admin - Applied to: createTeamMember(), updateTeamMember(), createAudienceSegment(), updateAudienceSegment()

private void requireManagerRole() throws TlinqClientException {
    if (!isManager()) {
        throw new TlinqClientException(TlinqErr.FORBIDDEN,
                "Access denied: This operation requires manager or admin privileges.");
    }
}

Issue #4: Error messages expose internal details (Low)

Problem: Error responses included ex.getMessage() which could leak internal implementation details, stack traces, or sensitive information to clients.

Fix: Sanitized all error responses in MarketingApi.java:

// Before
response = Response.status(Response.Status.OK)
        .entity(new TlinqApiResponse("MKT0001", "Error listing team members: " + ex.getMessage())).build();

// After
response = Response.status(Response.Status.OK)
        .entity(new TlinqApiResponse("MKT0001", "Error listing team members")).build();

Also removed entity IDs from "not found" messages to prevent enumeration attacks:

// Before
new TlinqApiResponse("MKT0014", "Activity not found: " + activityId)

// After
new TlinqApiResponse("MKT0014", "Activity not found")

Issue #5: Session tokens logged in plain text (Low)

Problem: API endpoints logged session tokens in INFO-level logs, which could expose sensitive credentials in log files.

Fix: Removed session token from all log statements in MarketingApi.java:

// Before
logger.log(Level.INFO, "BEGIN listTeamMembers for {0}", sessionToken);
logger.log(Level.INFO, "END listTeamMembers for {0}", sessionToken);

// After
logger.log(Level.INFO, "BEGIN listTeamMembers");
logger.log(Level.INFO, "END listTeamMembers");

Also improved error logging to properly capture stack traces:

// Before
logger.severe(ex.getMessage());

// After
logger.log(Level.SEVERE, "Error listing team members", ex);


Broadcast Security Considerations (TQ-107)

Webhook Authentication

  • broadcast/webhook/twilio-status and broadcast/webhook/twilio-inbound are unauthenticated (Twilio cannot present session tokens)
  • Twilio request signature validation should be enabled in production using com.twilio.security.RequestValidator with the auth token from tourlinq.properties
  • These endpoints are registered with * role in api-roles.properties to bypass normal auth

Email Unsubscribe Endpoint

  • broadcast/optout/unsubscribe is a GET endpoint (required by RFC 8058 List-Unsubscribe)
  • Unauthenticated by design — email clients need to call it without login
  • Accepts email and channel query parameters
  • Risk: enumeration/abuse — should be rate-limited in production (via reverse proxy)

Send Permission Restriction

  • broadcast/send is restricted to admin role only (not agent)
  • Prevents unauthorized mass messaging to customers

Opt-Out Compliance

  • WhatsApp: Custom STOP handler processes inbound STOP/UNSUBSCRIBE/OPTOUT keywords
  • Email: RFC 8058 List-Unsubscribe header with one-click unsubscribe link
  • Opt-out register checked before every send; opted-out contacts are skipped with OPTED_OUT status

Input Sanitization

  • All broadcast text fields (messageBody, emailSubject, templateName) are sanitized via sanitizePlainText() before storage
  • XSS prevention consistent with existing marketing module patterns

References