Skip to main content

Merchant-API Live Penetration Test Report

Date: 2026-02-13 Target: http://localhost:8080 (merchant-api, Spring Boot 4, local profile) Database: PostgreSQL 16 on Docker (port 5434) Auth Mode: DevAuthenticationFilter (auto-authenticates as Alice Owner, org_owner)


Executive Summary

Live penetration testing of the merchant-api service running locally with the local Spring profile. 60+ test cases across 6 categories. The service demonstrates strong SQL injection protection and good input validation on core endpoints, but has several authorization and operational security issues that must be addressed before production deployment.

SeverityCountDescription
CRITICAL1Rate limiter bypass via store ID cycling
HIGH3Change-password 500, null byte propagation, missing error handlers
MEDIUM4CORS on public endpoints, PIN stored plaintext, invite enumeration, terminal secret exposure
LOW4Missing 415/405 handlers, XSS stored (API-only), info leak in errors
PASS8SQL injection, pagination validation, UUID validation, CORS on auth endpoints, CSRF disabled (stateless), header injection, prototype pollution, server fingerprinting

Test Environment

Service:    Spring Boot 4.0.2 / Java 25 / Tomcat 11.0.15
Database: PostgreSQL 16.11 (Docker, tmpfs)
Profile: local (DevAuthenticationFilter active)
Tables: 25 (full schema from init.sql)
Seed data: 3 users, 1 org, 2 stores, 2 products, 1 transaction
Auth user: Alice Owner (org_owner, store_admin on Downtown)

Category 1: SQL Injection

PASS — All injection vectors safely handled

TestPayloadResult
DROP TABLE'; DROP TABLE users; --200, empty results, users table intact
UNION SELECT' UNION SELECT 1,2,...,16 FROM users--200, empty results
Boolean blind' OR 1=1 --200, empty results
Normal searchCola200, 1 result (Coca-Cola)

Analysis: All queries use JdbcTemplate parameterized statements (? placeholders). The ILIKE ? pattern with "%" + query + "%" string concatenation is safe because the % wrapping happens in Java, and the entire string is passed as a single parameter to PostgreSQL. No SQL metacharacters can escape the parameter binding.


Category 2: Input Validation

TestInputHTTPResultVerdict
Negative pagepage=-1400"page must be >= 0"PASS
Huge page sizesize=999999400"size must be between 1 and 100"PASS
Non-UUID storeIdnot-a-uuid400"Invalid store_id format"PASS
Empty JSON body{}400"token: must not be blank"PASS
Long PIN (10K chars)AAAA...400"Invalid PIN"PASS
Very long search (10KB)AAAA...400Tomcat rejects (URL too long)PASS
XSS in product name<script>alert(1)</script>500Internal errorLOW
Null bytes in searchtest\0admin500Internal errorHIGH
Wrong Content-Typeapplication/x-www-form-urlencoded500Should be 415LOW
No Content-Type(none)500Should be 415LOW
Invalid JSON bodynot-json500Should be 400LOW
Prototype pollution{"__proto__":{"admin":true}}404Jackson ignoresPASS

FINDING: V-INPUT-01 — Null bytes propagate to database (HIGH)

Null byte \0 in query parameters causes a 500 error, indicating the null byte reaches PostgreSQL and triggers an invalid character error. PostgreSQL does not allow null bytes in text columns.

Remediation: Add a global request filter or @ControllerAdvice that strips or rejects null bytes from all string inputs before they reach the service layer.

FINDING: V-INPUT-02 — Missing exception handlers for common HTTP errors (LOW)

The GlobalExceptionHandler does not catch:

  • HttpMediaTypeNotSupportedException → should return 415
  • HttpMessageNotReadableException → should return 400
  • HttpRequestMethodNotSupportedException → should return 405
  • MethodArgumentTypeMismatchException → should return 400

All fall through to the generic Exception catch → 500.


Category 3: Authentication & Authorization

TestExpectedActualVerdict
No auth header → /me200 (dev mode)200Expected
Garbage auth header → /me200 (dev mode)200Expected
Non-existent org members403 or 404403PASS
Non-existent store products404404PASS
Non-member access to store403403PASS
Org admin can create invites200 or 409409 (conflict)PASS
Org admin can update roles200200Expected (Alice is org_owner)

FINDING: V-AUTH-01 — Change-password returns 500 (HIGH)

PUT /api/v1/auth/change-password with any body returns 500 Internal Server Error instead of processing the request. The auth filter authenticates the user, but the service layer crashes — likely a BCryptPasswordEncoder comparison failure or null password_hash in the database.

Remediation: Fix the change-password service to handle all error cases gracefully. Add rate limiting to this endpoint to prevent password brute-forcing.

FINDING: V-AUTH-02 — Terminal secret exposed on create and regenerate (MEDIUM)

POST /stores/{id}/terminals and POST /stores/{id}/terminals/{id}/regenerate-secret return the terminal secret in plaintext in the response body. While this is necessary for initial setup, the secret should:

  • Be returned exactly once (on create/regenerate only) — confirmed working
  • Never be logged
  • Be stored hashed in the database (currently stored as plaintext)

Category 4: Rate Limiting

TestExpectedActualVerdict
PIN brute force (8 attempts)429 after 5429 after 5PASS
X-Forwarded-For bypassBypass rate limitNo bypass (uses getRemoteAddr)PASS
Different store IDsRate limited per-store7/7 succeed (bypass!)CRITICAL
Invite enumerationRate limitedNo rate limitingMEDIUM
Change-password floodRate limitedNo rate limiting (500 error)HIGH

FINDING: V-RATE-01 — Rate limiter bypass via store ID cycling (CRITICAL)

The rate limit key is pin:{storeId}:{clientIp}. An attacker can cycle through store IDs to get 5 attempts per store. With 2 stores, that's 10 attempts; with N stores (or guessed UUIDs), unlimited attempts.

A 4-digit PIN has 10,000 combinations. With 5 attempts per store ID and cycling through 2,000 store UUIDs (real or fabricated), an attacker can enumerate all PINs in a single 60-second window.

Remediation: Change the rate limit key to pin:{clientIp} only (remove storeId). Additionally, add a per-user lockout after N failed attempts across all stores.

FINDING: V-RATE-02 — No rate limiting on invite validation (MEDIUM)

POST /api/v1/invites/validate has no rate limiting. An attacker can enumerate invite tokens at high speed. Combined with the token being a UUID (predictable format), this enables automated discovery of valid invites.

Remediation: Add rate limiting to /api/v1/invites/validate — e.g., 10 requests per minute per IP.

FINDING: V-RATE-03 — Rate limiter memory leak (LOW)

The RateLimiter uses ConcurrentHashMap with no TTL or cleanup. Over time, old rate limit entries accumulate and are never garbage-collected. In production with many IPs/stores, this will cause memory growth.

Remediation: Use a TTL-based cache (e.g., Caffeine or Guava Cache) with automatic expiration matching the rate limit window (60 seconds).


Category 5: CORS & Headers

TestExpectedActualVerdict
Evil origin → auth endpointNo CORS headers403, no CORS headersPASS
Allowed origin → auth endpointCORS headers200, CORS headers presentPASS
Evil origin → preflight403403, no CORS headersPASS
Allowed origin → preflight200 with CORS200, full CORS headersPASS
Evil origin → /health403403, no CORS headersSee below
Allowed origin → /health200 with CORS403, no CORS headersMEDIUM
Server headerNot disclosedNot presentPASS
X-Content-Type-OptionsnosniffPresentPASS
X-Frame-OptionsDENYPresentPASS
HSTSPresentMissingExpected (HTTP only in dev)
CSPPresentMissingAdd for production

FINDING: V-CORS-01 — CORS rejects allowed origin on public endpoints (MEDIUM)

The /health endpoint returns 403 when Origin: http://localhost:3000 is sent. This indicates the CORS filter interacts poorly with the public endpoint security configuration. Authenticated endpoints work correctly.

Impact: Low (health endpoint isn't called from browser). But if any future public endpoints are added, they'll be blocked for legitimate frontends.

Remediation: Ensure the CORS filter is ordered before the authentication filter in the security filter chain for all endpoints. In SecurityConfig/LocalSecurityConfig, verify .cors(Customizer.withDefaults()) is configured and the CorsConfigurationSource applies to all paths.


Category 6: Information Disclosure

TestExpectedActualVerdict
Non-existent endpoint404500 (generic message)LOW
Stack trace in 500NoneNone visiblePASS
Server version headerNot presentNot presentPASS
Actuator endpoints401/403/404All 500 (not accessible)PASS
Spring generated passwordNot in responseNot in responsePASS
Invite token echoGeneric messageToken echoed back in errorMEDIUM
Validation field namesHiddenProperly shown (needed for UX)Expected

FINDING: V-INFO-01 — Invite error messages echo user input (MEDIUM)

POST /api/v1/invites/validate with a fake token returns:

{ "error": { "message": "Invite not found with id: fake-token-123" } }

This confirms:

  1. The token value is echoed back (potential XSS if rendered in HTML)
  2. The entity type ("Invite") is revealed
  3. Response differences enable token enumeration

Remediation: Use a generic message: "Invalid or expired invite". Do not echo user-supplied values in error messages.

FINDING: V-INFO-02 — PIN stored in plaintext (MEDIUM) — RESOLVED

PINs are already stored as BCrypt hashes in the pin_hash column. The AuthService.pinLogin() method uses passwordEncoder.matches() for comparison and new PINs are encoded with passwordEncoder.encode(). This finding was based on outdated information.


Summary of Findings

Must Fix Before Production

IDSeverityFindingEffort
V-RATE-01CRITICALRate limiter bypass via store ID cyclingLow — change key to pin:{clientIp}
V-AUTH-01HIGHChange-password returns 500Medium — debug service layer
V-INPUT-01HIGHNull bytes propagate to databaseLow — add global input filter
V-INPUT-02HIGHMissing 400/405/415 error handlersLow — add 4 handlers to GlobalExceptionHandler

Should Fix Before Production

IDSeverityFindingEffort
V-RATE-02MEDIUMNo rate limiting on invite validationLow
V-INFO-01MEDIUMInvite errors echo user inputLow
V-INFO-02MEDIUMPINs stored in plaintext — RESOLVEDAlready uses BCrypt
V-CORS-01MEDIUMCORS rejects allowed origin on public endpointsLow
V-AUTH-02MEDIUMTerminal secrets stored in plaintextMedium

Nice to Have

IDSeverityFindingEffort
V-RATE-03LOWRate limiter memory leakLow — use TTL cache
LOWXSS in product name (stored, API-only)Low — add output encoding
LOWHSTS and CSP headers missingLow — configure in SecurityConfig
LOWNon-existent endpoints return 500 not 404Low

What Passed

  • SQL Injection: All parameterized, no injection possible
  • Pagination validation: Negative page, oversized page properly rejected
  • UUID validation: Non-UUID path parameters properly rejected
  • CORS on authenticated endpoints: Correctly allows localhost:3000/5173, rejects evil origins
  • Server fingerprinting: No server/version headers exposed
  • Prototype pollution: Java/Jackson immune
  • CSRF: Properly disabled (stateless JWT auth)
  • X-Forwarded-For spoofing: Rate limiter uses getRemoteAddr(), not headers
  • Actuator/debug endpoints: None accessible
  • Security headers: X-Frame-Options: DENY, X-Content-Type-Options: nosniff
  • HTTP TRACE: Blocked (401)

Test Methodology

  • Tools: curl 8.7.1, Docker, PostgreSQL psql client
  • Approach: Black-box testing with source code review for context
  • Scope: All HTTP endpoints accessible on port 8080
  • Limitations: Dev auth filter bypasses real authentication — Firebase token validation could not be tested. HTTPS not tested (HTTP-only in dev).