Skip to main content

Security Audit Report: merchant-api (Phases 1 & 2)

Date: 2026-02-12 Target: apps/microservices/merchant-api (localhost:8080) Stack: Spring Boot 4 / Java 25 / PostgreSQL 16 Scope: Auth, Invites, Org Membership, Store Membership (Phases 1 & 2)

Executive Summary​

16 confirmed vulnerabilities found through live exploitation. 4 critical, 5 high, 5 medium, 2 low. The overarching theme is complete absence of authorization — authentication exists but no access control is enforced. Any authenticated user can read, modify, and delete data across all orgs and stores.

SeverityCount
Critical4
High5
Medium5
Low2
Total16

CRITICAL (4)​

C1. Complete Authorization Bypass (IDOR on Every Endpoint)​

Confirmed live: Yes

No endpoint checks whether the authenticated user belongs to the org/store they're accessing. Any authenticated user can query, modify, and delete data for any org UUID.

# Alice reads members from a completely fabricated org — 200 OK, empty data
GET /api/v1/orgs/11111111-1111-1111-1111-111111111111/members → 200 OK

# Alice reads Airport store members (she's not a member) → returns data
GET /api/v1/stores/..0021/members → 200 OK, shows Bob's membership

Root cause: OrgMemberController, StoreMemberController, OrgInviteController, StoreInviteController accept @PathVariable UUID orgId/storeId but never verify the caller's relationship to that entity. OrgContext/StoreContext filters exist but are not used for enforcement.

Impact: Complete horizontal privilege escalation. Any authenticated user can enumerate all orgs, read all memberships, create/delete invites, and modify roles across every org and store in the system.

Fix: Add authorization checks to all controllers — verify the caller belongs to the org/store via OrgMembershipRepository.findByOrgAndUser() before processing requests.


C2. Invite Role Injection — Arbitrary Privilege Assignment​

Confirmed live: Yes

CreateInviteRequest.orgRole and storeRole have no @Pattern validation. Any string is accepted and persisted.

POST /api/v1/orgs/..0001/invites
{"email": "x@evil.com", "orgRole": "org_owner"} → 200 OK, orgRole: "org_owner"
{"email": "y@evil.com", "orgRole": "superadmin_god_mode"} → 200 OK, orgRole: "superadmin_god_mode"

Root cause: CreateInviteRequest record (line 12) defines String orgRole with no @Pattern annotation — unlike AddOrgMemberRequest which has @Pattern("org_owner|org_admin|org_member").

Impact: Any user creates an invite with orgRole: "org_owner", sends the token to a confederate, who accepts it and becomes org owner. Or inject arbitrary role strings that may bypass future IAM checks.

Fix: Add @Pattern("org_owner|org_admin|org_member") to orgRole and @Pattern("store_admin|manager|cashier|stocker") to storeRole in CreateInviteRequest.


C3. Invite Accept Email Mismatch — Account Hijacking​

Code-confirmed: Yes (not exploitable without a valid token in hand)

InviteService.acceptInvite() (line 70-143) looks up the user by request.email() but never validates that request.email() matches invite.getEmail(). Anyone with a valid token can claim any email.

// InviteService.java line 85: Uses request email, NOT invite email
var existingUser = userRepository.findByEmail(request.email());

Impact: If an attacker obtains any valid invite token, they can accept it using a different email than what was invited, creating an org membership for an unintended account.

Fix: Add validation: if (!invite.getEmail().equalsIgnoreCase(request.email())) throw new BadRequestException("Email does not match invite");


C4. Invite Revoke/Delete Ignores Org Boundary​

Confirmed live: Yes

revokeInvite() and deleteInvite() only check if the invite exists — they don't verify the caller has access to the invite's org.

# Using a COMPLETELY WRONG org in the path — still works
POST /api/v1/orgs/11111111-.../invites/35bd96bf-.../revoke → 200 OK (revoked!)
DELETE /api/v1/orgs/11111111-.../invites/d7165114-... → 200 OK (deleted!)

Root cause: OrgInviteController.revoke() (line 62) passes only the invite id to inviteService.revokeInvite(id), which does findById(inviteId) — the orgId path variable is completely ignored.

Impact: Any authenticated user who can guess or enumerate invite IDs can revoke or delete invites across all organizations.

Fix: Verify invite.getOrgId().equals(orgId) in revokeInvite() and deleteInvite() before proceeding.


HIGH (5)​

H1. No Last-Admin Check on Store Members — Store Orphaning​

Confirmed live: Yes

StoreMemberService.updateRole() and removeMember() have no "last store_admin" protection (unlike org members).

PUT /api/v1/stores/..0020/members/..0010 {"role":"cashier"} → 200 OK
# Result: Downtown store now has 0 store_admins — nobody can manage it

Root cause: StoreMemberService.updateRole() (line 78-83) blindly updates the role. Compare with OrgMemberService.updateRole() which checks countByOrgIdAndRole(orgId, "org_owner").

Fix: Add countByStoreIdAndRole(storeId, "store_admin") check before demotion/removal.


H2. Self-Removal Doesn't Revoke Access​

Confirmed live: Yes

After Alice removes herself from the org, she can still access all org data because no authz checks run on subsequent requests.

DELETE /api/v1/orgs/..0001/members/..0010 → 200 OK
GET /api/v1/orgs/..0001/members → 200 OK (still returns data!)
GET /api/v1/auth/me → 200 OK (still authenticated)

Root cause: Authentication (Firebase token) and authorization (org membership) are independent. Removing org membership doesn't invalidate the Firebase session, and no authz check runs on subsequent requests.

Fix: Implementing C1 (authorization checks) resolves this — removed users would fail the membership check.


H3. Password Changed to Single Character​

Confirmed live: Yes

POST /api/v1/auth/change-password {"newPassword":"a"} → 200 OK

Root cause: ChangePasswordRequest has only @NotBlank — no @Size(min=8) or complexity check. Spec requires minimum 8 characters.

Fix: Add @Size(min = 8, message = "Password must be at least 8 characters") to ChangePasswordRequest.newPassword.


H4. Error Messages Leak Internal State​

Confirmed live: Yes

"Invite not found with id: f6da9cea-..."  → leaks that token is a UUID
"User not found with id: 99999999-..." → confirms user doesn't exist
"User is already a member of this org" → confirms membership
"User must be a member of the org..." → confirms non-membership
"Failed to update password: [exception]" → leaks Firebase error details

Root cause: ResourceNotFoundException, BadRequestException, and ConflictException messages are passed through verbatim to the client via GlobalExceptionHandler.

Fix: Return generic messages to clients (e.g., "Resource not found"). Log full details server-side only.


H5. 1MB Request Body Accepted — No Size Limit​

Confirmed live: Yes

# 1MB payload with 20 extra fields of 50KB each
POST /api/v1/orgs/..0001/invites (1,000,479 bytes) → 200 OK, invite created

Root cause: No server.tomcat.max-http-form-post-size or request body size filter configured. Spring Boot default is unlimited for JSON.

Fix: Set server.tomcat.max-http-post-size: 65536 in application.yml and/or add a request body size filter.


MEDIUM (5)​

M1. Division by Zero in Pagination​

Confirmed live: Yes

GET /api/v1/orgs/..0001/members?page=0&size=0
→ {"totalPages": 2147483647, "limit": 0, "total": 3}

Root cause: OrgInviteController line 44: (int) Math.ceil((double) total / size) — when size=0, total/0.0 = Infinity, cast to int = Integer.MAX_VALUE.

Fix: Validate size >= 1 and size <= 100 in all list endpoints.


M2. Negative/Invalid Pagination Values Crash​

Confirmed live: Yes

GET ...?page=-1 → INTERNAL_ERROR (unhandled exception, negative SQL OFFSET)
GET ...?size=-1 → INTERNAL_ERROR (unhandled exception)

Fix: Validate page >= 0 in all list endpoints.


M3. Race Condition on Last-Owner Check​

Code-confirmed: Yes

OrgMemberService.updateRole() (line 81-88) uses a count-then-act pattern without SELECT ... FOR UPDATE. Two concurrent demotions can both pass the ownerCount > 1 check.

long ownerCount = orgMembershipRepository.countByOrgIdAndRole(orgId, "org_owner");
if (ownerCount <= 1) { throw ... }
// Race window: another thread demotes the other owner between count and update
orgMembershipRepository.updateRole(orgId, userId, request.role());

Fix: Use SELECT ... FOR UPDATE or a database-level constraint ensuring at least one org_owner always exists.


M4. CSRF Disabled Without Alternative​

Code-confirmed: Yes

SecurityConfig line 39: .csrf(csrf -> csrf.disable()). For a cookie/session-based frontend at localhost:3000, this enables cross-site request forgery.

Fix: Since the API uses Bearer tokens (not cookies), CSRF is less critical — but document this assumption. If cookies are ever used, re-enable CSRF or use SameSite=Strict.


M5. No Invite Deduplication​

Confirmed live: Yes

Can create unlimited invites for the same email in the same org. No UNIQUE(org_id, email, status) constraint.

# Called 5 times with same email — 5 separate invites created
POST /api/v1/orgs/..0001/invites {"email":"test@test.com"} → 200 OK (each time)

Fix: Check for existing pending invite before creating a new one, or add a unique constraint.


LOW (2)​

L1. Invite Token Stored in Plaintext​

Invite tokens stored as plain UUID strings in invites.token. If the database is compromised, all pending invite tokens are immediately usable.

Fix: Store SHA-256(token) in the DB. When validating, hash the incoming token and compare.


L2. CORS allowedHeaders: * with Credentials​

SecurityConfig line 100: config.setAllowedHeaders(List.of("*")) with setAllowCredentials(true). Overly permissive header allowance.

Fix: Restrict to specific headers: Authorization, Content-Type, Accept.


Positive Findings (What's NOT Vulnerable)​

AreaStatus
SQL InjectionProtected — all queries use JdbcTemplate ? parameterized queries
XSS in inputsProtected — @Email validation rejects <script> tags
Last org_owner removalProtected — count check prevents removing the last owner
Org membership gate for store addsProtected — StoreMemberService.addMember() checks org membership
Role validation on member CRUDProtected — @Pattern on AddOrgMemberRequest.role and AddStoreMemberRequest.role
Stack traces in generic errorsProtected — catch-all handler returns "An unexpected error occurred"
TRACE HTTP methodProtected — returns 401 Unauthorized
Path traversalProtected — UUID parsing rejects non-UUID path segments

Priority Fix List​

#FixSeverityAffected Files
1Add authorization checks to all controllersCRITICALAll controllers
2Add @Pattern on invite role fieldsCRITICALCreateInviteRequest.java
3Validate email matches invite on acceptCRITICALInviteService.java:85
4Scope invite revoke/delete to org boundaryCRITICALInviteService.java:209-218
5Add last-admin check for store membersHIGHStoreMemberService.java:78
6Add @Size(min=8) to password changeHIGHChangePasswordRequest.java
7Sanitize error messagesHIGHGlobalExceptionHandler.java
8Cap request body sizeHIGHapplication.yml
9Validate pagination bounds (page >= 0, 1 <= size <= 100)MEDIUMAll list controllers