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

17 confirmed vulnerabilities found through live exploitation. 5 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
Critical5
High5
Medium5
Low2
Total17

CRITICAL (5)

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.


C5. PIN Login: No Rate Limiting, Plaintext Storage

Confirmed live: Yes (no rate limit)

The /api/v1/stores/{storeId}/auth/pin-login endpoint is fully public (permitAll()) with zero rate limiting. PINs are stored as plaintext in the users.pin TEXT column.

# 8 rapid-fire guesses with zero throttling:
PIN 0000 → Invalid PIN
PIN 1111 → Invalid PIN
PIN 1234 → Invalid PIN
# ... no lockout, no delay, no CAPTCHA

Root cause: SecurityConfig line 62 marks this endpoint as permitAll(). No rate limiter filter. UserRepository.findByPin() does plaintext comparison (WHERE u.pin = ?).

Impact: Automated brute-force of 4-digit PINs (10,000 combinations) takes seconds. A valid PIN returns a Firebase custom token + full user data including email.

Fix: Hash PINs with BCrypt, add rate limiting (e.g., Bucket4j or Resilience4j), implement account lockout after N failed attempts.


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 addsProtectedStoreMemberService.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 rate limiting to PIN login + invite endpointsCRITICALSecurityConfig + new filter
6Hash PINs with BCryptCRITICALUserRepository, schema
7Add last-admin check for store membersHIGHStoreMemberService.java:78
8Add @Size(min=8) to password changeHIGHChangePasswordRequest.java
9Sanitize error messagesHIGHGlobalExceptionHandler.java
10Cap request body sizeHIGHapplication.yml
11Validate pagination bounds (page >= 0, 1 <= size <= 100)MEDIUMAll list controllers