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
sessionparameter 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)¶
currentTeamMemberIdderived server-side fromX-Emailheader- 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¶
- Dev-Mode Bypass (Acceptable for Development)
- When
dev-mode=trueintlinqapi.properties, authentication is bypassed -
Documented behavior; must never be enabled in production
-
Parameter Naming Clarity (Code Quality)
- The
sessionparameter name is confusing - it's a DB connection token -
Consider renaming to
dbTokenor documenting its purpose -
Infrastructure Dependency (Operational)
- Security relies on OAuth2-Proxy being properly configured
- 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()andreturnActivity()methods inMarketingFacade.java: - Only team members with 'reviewer' or 'manager' role can approve/reject activities - Users cannot approve/reject their own submissions - New error codesFORBIDDENandUNAUTHORIZEDadded toTlinqErr.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-Emailheader from OAuth2-Proxy) - AddedgetTeamMemberByEmail()method toMarketingFacadefor user lookup - Client can no longer impersonate other team members by passing fakecurrentTeamMemberIdPart 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/currentuserto check user's team member statusPart 4: Frontend Team Member Verification - Updated
mktplan.js: - AddedcheckCurrentUser()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¶
-
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); } -
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... } -
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 displayFrontend HTML Escaping - Updated
mktplan.js: - AddedescapeHtml()utility function using textContent/innerHTML pattern - FixedrenderTeamMembersList()to escape all user content in template literals - FixedrenderAudiencesList()to escape all user content - FixedpopulateTeamMemberDropdowns()andpopulateAudienceDropdown()to escape content - FixedshowTeamMemberWarning()to use safe DOM methods (createTextNode) - FixedrenderAudienceTags()to use createTextNode instead of innerHTML - Added comment documenting that Quill innerHTML is safe due to backend sanitizationSecurity 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 responsesDependency Added -
build.gradle.ktsincludes:
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¶
-
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())); // ... } -
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); }); -
Configure Content Security Policy headers:
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 deniedApplied 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 automaticallyAccess 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¶
-
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; } -
Use indirect references where appropriate:
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 setsValidation 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 formatApplied 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¶
-
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; } -
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); } // ... } -
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"); } } -
Use thread-safe date formatting:
Implementation Priority¶
Phase 1 - Critical (Immediate) - COMPLETED¶
- ~~Fix session authentication fallback~~ - REVIEWED: Not a vulnerability (see Issue #1)
- ~~Add authorization checks for approve/reject operations~~ - IMPLEMENTED
- ~~Derive currentTeamMemberId from session, not client input~~ - IMPLEMENTED
Phase 2 - High (Next Priority) - COMPLETED¶
- ~~Implement XSS sanitization for stored content~~ - IMPLEMENTED (OWASP HTML Sanitizer)
- ~~Add IDOR protection for sensitive operations~~ - IMPLEMENTED (canAccessCampaign/Activity)
- ~~Update frontend to escape all user content~~ - IMPLEMENTED (escapeHtml function)
Phase 3 - Medium - COMPLETED¶
- ~~Add comprehensive input validation~~ - IMPLEMENTED (validateRequired/MaxLength/Enum/Url)
- Fix SimpleDateFormat thread-safety - NOT APPLICABLE (no static SimpleDateFormat in MarketingFacade)
- ~~Implement CSP headers~~ - IMPLEMENTED (CORSResponseFilter.java)
- Add rate limiting to prevent abuse - DEFERRED (infrastructure-level concern)
Testing Recommendations¶
After implementing fixes, verify with:
- Authentication Tests (Verify existing infrastructure)
- Verify OAuth2-Proxy redirects unauthenticated users to login
- Verify direct API access (bypassing proxy) is blocked by network rules
-
Verify
dev-mode=falsein production configuration -
Authorization Tests - Can be tested now
- Non-reviewer attempts approval - should return 403 with role error
- Self-approval attempt - should return 403 with self-approval error
-
Non-team-member attempts write operation - should return 403 with team member error
-
XSS Tests - Can be tested now
- Store
<script>alert('XSS')</script>in activity name - should be stripped - Store
<img src=x onerror=alert('XSS')>in description - should be stripped - Verify scripts don't execute when viewing
-
Verify safe formatting (bold, italic, links) is preserved in storyText
-
IDOR Tests - Can be tested now
- Access activity belonging to different team - should return 403 FORBIDDEN
- Access campaign you didn't create and aren't involved in - should return 403 FORBIDDEN
- Verify managers/admins can access all resources
- Verify creators can access their own campaigns/activities
-
Verify assignees can access activities they're assigned to
-
Input Validation Tests - Can be tested now
- Submit activity name > 200 characters - should return INVALID_PARAMETER
- Submit invalid activity type (e.g., "INVALID") - should return error with valid values
- Submit javascript: URL as media URL - should return error about protocol
- Submit malformed URL - should return URL format error
- Submit invalid role when creating team member - should return error with valid roles
- 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-statusandbroadcast/webhook/twilio-inboundare unauthenticated (Twilio cannot present session tokens)- Twilio request signature validation should be enabled in production using
com.twilio.security.RequestValidatorwith the auth token fromtourlinq.properties - These endpoints are registered with
*role inapi-roles.propertiesto bypass normal auth
Email Unsubscribe Endpoint¶
broadcast/optout/unsubscribeis a GET endpoint (required by RFC 8058 List-Unsubscribe)- Unauthenticated by design — email clients need to call it without login
- Accepts
emailandchannelquery parameters - Risk: enumeration/abuse — should be rate-limited in production (via reverse proxy)
Send Permission Restriction¶
broadcast/sendis restricted toadminrole only (notagent)- 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_OUTstatus
Input Sanitization¶
- All broadcast text fields (messageBody, emailSubject, templateName) are sanitized via
sanitizePlainText()before storage - XSS prevention consistent with existing marketing module patterns