Skip to main content

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

Date: 2026-02-12 Audit Reference: Security Audit 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

14 of 17 vulnerabilities remediated in a single pass. 3 findings deferred to future work (require schema migration or database-level constraints). The primary fix is a new centralized AuthorizationService that enforces org/store membership checks on every authenticated endpoint.

SeverityFoundFixedDeferred
Critical541 (partial)
High541 (implicit)
Medium541
Low220
Total17143

Changes Overview

New Files (3)

FilePurpose
service/AuthorizationService.javaCentral authorization — resolves user identity, enforces org/store membership and role requirements
service/RateLimiter.javaIn-memory fixed-window rate limiter (5 attempts per 60s window)
exception/TooManyRequestsException.java429 Too Many Requests exception

Modified Files (17)

FileFindings Addressed
dto/CreateInviteRequest.javaC2
dto/ChangePasswordRequest.javaH3
dto/AcceptInviteRequest.javaH3
controller/OrgMemberController.javaC1, M1, M2
controller/StoreMemberController.javaC1, M1, M2
controller/OrgInviteController.javaC1, C4, M1, M2
controller/StoreInviteController.javaC1, C4, M1, M2
controller/PinAuthController.javaC5 (rate limiting)
service/InviteService.javaC3, C4, M5
service/StoreMemberService.javaH1
repository/StoreMembershipRepository.javaH1 (new query)
repository/InviteRepository.javaM5 (new query)
exception/GlobalExceptionHandler.javaH4, C5 (429 handler)
application.ymlH5
security/SecurityConfig.javaL2
security/LocalSecurityConfig.javaL2
Tests: InviteServiceTest.java, StoreMemberServiceTest.javaTest coverage for new behavior

Remediation Details

C1. Complete Authorization Bypass — FIXED

Solution: New AuthorizationService with four enforcement methods:

requireOrgMember(orgId, authUser)   — any org membership (read ops)
requireOrgAdmin(orgId, authUser) — org_owner or org_admin (write ops)
requireStoreAccess(storeId, authUser) — store member OR org_admin (read ops)
requireStoreAdmin(storeId, authUser) — store_admin OR org_admin (write ops)

All four authenticated controllers (OrgMemberController, StoreMemberController, OrgInviteController, StoreInviteController) now inject AuthorizationService and call the appropriate check before processing.

Authorization rules per controller:

ControllerRead (GET list)Write (POST/PUT/DELETE)
OrgMemberControllerrequireOrgMemberrequireOrgAdmin
OrgInviteControllerrequireOrgMemberrequireOrgAdmin
StoreMemberControllerrequireStoreAccessrequireStoreAdmin
StoreInviteControllerrequireStoreAccessrequireStoreAdmin

AuthController operates on the caller's own data — no change needed. PinAuthController is a public endpoint — rate limiting applied instead.

Also fixes H2 (self-removal doesn't revoke access): after a user removes their own org membership, subsequent requests to that org's endpoints now fail the requireOrgMember check with 403.

Verification:

GET /api/v1/orgs/11111111-1111-1111-1111-111111111111/members
# Before: 200 OK
# After: 403 Forbidden {"code":"FORBIDDEN","message":"Access denied"}

C2. Invite Role Injection — FIXED

Solution: Added @Pattern validation to CreateInviteRequest:

@Pattern(regexp = "org_owner|org_admin|org_member") String orgRole
@Pattern(regexp = "store_admin|manager|cashier|stocker") String storeRole

Both fields are nullable — @Pattern only fires when the value is non-null, so omitting the field defaults to "member" in the service layer.

Verification:

POST /api/v1/orgs/.../invites {"email":"x@x.com","orgRole":"superadmin"}
# Before: 200 OK
# After: 400 VALIDATION_ERROR "orgRole: orgRole must be one of: org_owner, org_admin, org_member"

C3. Invite Accept Email Mismatch — FIXED

Solution: Added email validation in InviteService.acceptInvite() immediately after the expiration check:

if (!invite.getEmail().equalsIgnoreCase(request.email())) {
throw new BadRequestException("Email does not match invite");
}

Case-insensitive comparison prevents bypasses via mixed-case emails.


C4. Invite Revoke/Delete Ignores Org Boundary — FIXED

Solution: Changed method signatures to require an org boundary:

// Before
revokeInvite(UUID inviteId)
deleteInvite(UUID inviteId)

// After
revokeInvite(UUID inviteId, UUID expectedOrgId)
deleteInvite(UUID inviteId, UUID expectedOrgId)

After looking up the invite, the service verifies invite.getOrgId().equals(expectedOrgId) and throws ForbiddenException if mismatched.

Controller changes:

  • OrgInviteController passes the orgId path variable directly
  • StoreInviteController resolves the store's parent org via AuthorizationService.resolveOrgIdForStore(storeId) and passes that

Verification:

POST /api/v1/orgs/11111111-.../invites/35bd96bf-.../revoke
# Before: 200 OK
# After: 403 Forbidden

C5. PIN Login Rate Limiting — PARTIALLY FIXED

What's fixed: In-memory rate limiter added to PinAuthController:

rateLimiter.checkRateLimit("pin:" + storeId + ":" + clientIp);
  • Key format: pin:{storeId}:{clientIp}
  • Max 5 attempts per 60-second fixed window
  • Exceeding limit throws TooManyRequestsException (429)

What's deferred: PIN hashing (BCrypt) requires a data migration for existing plaintext PINs. Tracked separately.


H1. No Last-Admin Check on Store Members — FIXED

Solution: Added countByStoreIdAndRole query to StoreMembershipRepository and enforcement in StoreMemberService:

  • updateRole(): if current role is store_admin and new role isn't, checks countByStoreIdAndRole(storeId, "store_admin") <= 1
  • removeMember(): if current role is store_admin, same check

Throws BadRequestException("Cannot demote the last store admin") or BadRequestException("Cannot remove the last store admin").

Mirrors the existing OrgMemberService pattern for org_owner protection.


H3. Weak Password Validation — FIXED

Solution: Added @Size(min = 8) to both password fields:

  • ChangePasswordRequest.newPassword: @NotBlank @Size(min = 8, message = "Password must be at least 8 characters")
  • AcceptInviteRequest.password: @NotBlank @Size(min = 8, message = "Password must be at least 8 characters")

Verification:

POST /api/v1/auth/change-password {"newPassword":"a"}
# Before: 200 OK
# After: 400 VALIDATION_ERROR "newPassword: Password must be at least 8 characters"

H4. Error Message Leakage — FIXED

Solution: Sanitized GlobalExceptionHandler responses:

ExceptionBeforeAfter
ResourceNotFoundException"User not found with id: 99999...""Resource not found"
ConflictException"User is already a member...""Conflict"
ForbiddenException(dynamic message)"Access denied"
BadRequestException(original message)(original message — these are validation messages, not internal state)
Generic Exception"An unexpected error occurred"(unchanged)

Internal details are no longer exposed to clients. Attackers cannot use error messages to enumerate users, confirm membership status, or discover internal structure.


H5. No Request Body Size Limit — FIXED

Solution: Added to application.yml:

server:
tomcat:
max-http-post-size: 65536

Limits request bodies to 64KB, which is more than sufficient for all merchant-api JSON payloads.


M1. Division by Zero in Pagination — FIXED

Solution: All 4 list endpoints now validate pagination bounds at the top of the method:

if (size < 1 || size > 100) throw new BadRequestException("size must be between 1 and 100");

Verification:

GET /api/v1/orgs/..0001/members?page=0&size=0
# Before: 200 OK, totalPages: 2147483647
# After: 400 BAD_REQUEST "size must be between 1 and 100"

M2. Negative Pagination Values — FIXED

Solution: Validated alongside M1:

if (page < 0) throw new BadRequestException("page must be >= 0");

Applied to: OrgMemberController, StoreMemberController, OrgInviteController, StoreInviteController.


M5. No Invite Deduplication — FIXED

Solution: Added findPendingByOrgAndEmail(UUID orgId, String email) to InviteRepository. Both createOrgInvite() and createStoreInvite() now check for existing pending invites:

if (inviteRepository.findPendingByOrgAndEmail(orgId, request.email()).isPresent()) {
throw new ConflictException("A pending invite already exists for this email");
}

L2. CORS allowedHeaders: * — FIXED

Solution: Restricted CORS headers in both SecurityConfig and LocalSecurityConfig:

// Before
config.setAllowedHeaders(List.of("*"));

// After
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept"));

Deferred Findings (3)

C5 (partial). PIN Hashing with BCrypt — RESOLVED

Status: Already implemented in code

PINs are stored as BCrypt hashes in the pin_hash column (MerchantUser.sq:7). The code:

  • Uses passwordEncoder.matches() for PIN comparison (AuthService.kt:119)
  • Encodes new PINs with passwordEncoder.encode() (UserPinService.kt:44)
  • The original pin column no longer exists; only pin_hash is used

The remediation doc was outdated — this was already resolved before the pentest.


M3. Race Condition on Last-Owner Check

Status: Deferred — requires SELECT ... FOR UPDATE or DB constraint

The count-then-act pattern in OrgMemberService.updateRole() and StoreMemberService.updateRole() is vulnerable to TOCTOU races. Fixing requires:

  • PostgreSQL: SELECT ... FOR UPDATE in the count query
  • Or: database-level constraint ensuring at least one owner/admin exists

Risk: Low probability in practice (requires exact timing of concurrent demotions), but theoretically exploitable.


L1. Invite Token Stored in Plaintext

Status: Deferred — requires schema change

Fixing requires:

  1. Schema change: add token_hash column, drop token column
  2. Code change: hash tokens with SHA-256 before storage, hash incoming tokens before lookup
  3. Migration: existing tokens cannot be migrated (one-way hash), must expire naturally

Risk: Only exploitable if database is compromised directly.


Test Coverage

Updated test files with new assertions for security behavior:

InviteServiceTest.java — 21 tests (added 4):

  • acceptInviteEmailMismatch — verifies C3 email validation
  • createOrgInviteDuplicate — verifies M5 dedup
  • revokeInviteWrongOrg — verifies C4 org boundary
  • deleteInviteWrongOrg — verifies C4 org boundary

StoreMemberServiceTest.java — 9 tests (added 2):

  • updateRoleLastStoreAdminBlocked — verifies H1 on demotion
  • removeLastStoreAdminBlocked — verifies H1 on removal

Build & test results:

bazel build //apps/microservices/merchant-api:core              — PASSED
bazel test //apps/microservices/merchant-api:auth_service_test — PASSED
bazel test //apps/microservices/merchant-api:org_member_service_test — PASSED
bazel test //apps/microservices/merchant-api:invite_service_test — PASSED
bazel test //apps/microservices/merchant-api:store_member_service_test — PASSED

Residual Risk Assessment

AreaStatusNotes
AuthorizationMitigatedAll endpoints enforce org/store membership
PIN brute-forceMitigatedRate limited + BCrypt hashing in place
TOCTOU on last-ownerAcceptedLow probability, needs DB-level fix
Invite tokensAcceptedLow risk unless DB compromised
M4 (CSRF disabled)AcceptedAPI uses Bearer tokens, not cookies