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.
| Severity | Count | Description |
|---|---|---|
| CRITICAL | 1 | Rate limiter bypass via store ID cycling |
| HIGH | 3 | Change-password 500, null byte propagation, missing error handlers |
| MEDIUM | 4 | CORS on public endpoints, PIN stored plaintext, invite enumeration, terminal secret exposure |
| LOW | 4 | Missing 415/405 handlers, XSS stored (API-only), info leak in errors |
| PASS | 8 | SQL 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
| Test | Payload | Result |
|---|---|---|
| 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 search | Cola | 200, 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
| Test | Input | HTTP | Result | Verdict |
|---|---|---|---|---|
| Negative page | page=-1 | 400 | "page must be >= 0" | PASS |
| Huge page size | size=999999 | 400 | "size must be between 1 and 100" | PASS |
| Non-UUID storeId | not-a-uuid | 400 | "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... | 400 | Tomcat rejects (URL too long) | PASS |
| XSS in product name | <script>alert(1)</script> | 500 | Internal error | LOW |
| Null bytes in search | test\0admin | 500 | Internal error | HIGH |
| Wrong Content-Type | application/x-www-form-urlencoded | 500 | Should be 415 | LOW |
| No Content-Type | (none) | 500 | Should be 415 | LOW |
| Invalid JSON body | not-json | 500 | Should be 400 | LOW |
| Prototype pollution | {"__proto__":{"admin":true}} | 404 | Jackson ignores | PASS |
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 415HttpMessageNotReadableException→ should return 400HttpRequestMethodNotSupportedException→ should return 405MethodArgumentTypeMismatchException→ should return 400
All fall through to the generic Exception catch → 500.
Category 3: Authentication & Authorization
| Test | Expected | Actual | Verdict |
|---|---|---|---|
| No auth header → /me | 200 (dev mode) | 200 | Expected |
| Garbage auth header → /me | 200 (dev mode) | 200 | Expected |
| Non-existent org members | 403 or 404 | 403 | PASS |
| Non-existent store products | 404 | 404 | PASS |
| Non-member access to store | 403 | 403 | PASS |
| Org admin can create invites | 200 or 409 | 409 (conflict) | PASS |
| Org admin can update roles | 200 | 200 | Expected (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
| Test | Expected | Actual | Verdict |
|---|---|---|---|
| PIN brute force (8 attempts) | 429 after 5 | 429 after 5 | PASS |
| X-Forwarded-For bypass | Bypass rate limit | No bypass (uses getRemoteAddr) | PASS |
| Different store IDs | Rate limited per-store | 7/7 succeed (bypass!) | CRITICAL |
| Invite enumeration | Rate limited | No rate limiting | MEDIUM |
| Change-password flood | Rate limited | No 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
| Test | Expected | Actual | Verdict |
|---|---|---|---|
| Evil origin → auth endpoint | No CORS headers | 403, no CORS headers | PASS |
| Allowed origin → auth endpoint | CORS headers | 200, CORS headers present | PASS |
| Evil origin → preflight | 403 | 403, no CORS headers | PASS |
| Allowed origin → preflight | 200 with CORS | 200, full CORS headers | PASS |
| Evil origin → /health | 403 | 403, no CORS headers | See below |
| Allowed origin → /health | 200 with CORS | 403, no CORS headers | MEDIUM |
| Server header | Not disclosed | Not present | PASS |
| X-Content-Type-Options | nosniff | Present | PASS |
| X-Frame-Options | DENY | Present | PASS |
| HSTS | Present | Missing | Expected (HTTP only in dev) |
| CSP | Present | Missing | Add 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
| Test | Expected | Actual | Verdict |
|---|---|---|---|
| Non-existent endpoint | 404 | 500 (generic message) | LOW |
| Stack trace in 500 | None | None visible | PASS |
| Server version header | Not present | Not present | PASS |
| Actuator endpoints | 401/403/404 | All 500 (not accessible) | PASS |
| Spring generated password | Not in response | Not in response | PASS |
| Invite token echo | Generic message | Token echoed back in error | MEDIUM |
| Validation field names | Hidden | Properly 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:
- The token value is echoed back (potential XSS if rendered in HTML)
- The entity type ("Invite") is revealed
- 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
| ID | Severity | Finding | Effort |
|---|---|---|---|
| V-RATE-01 | CRITICAL | Rate limiter bypass via store ID cycling | Low — change key to pin:{clientIp} |
| V-AUTH-01 | HIGH | Change-password returns 500 | Medium — debug service layer |
| V-INPUT-01 | HIGH | Null bytes propagate to database | Low — add global input filter |
| V-INPUT-02 | HIGH | Missing 400/405/415 error handlers | Low — add 4 handlers to GlobalExceptionHandler |
Should Fix Before Production
| ID | Severity | Finding | Effort |
|---|---|---|---|
| V-RATE-02 | MEDIUM | No rate limiting on invite validation | Low |
| V-INFO-01 | MEDIUM | Invite errors echo user input | Low |
| V-INFO-02 | MEDIUM | Already uses BCrypt | |
| V-CORS-01 | MEDIUM | CORS rejects allowed origin on public endpoints | Low |
| V-AUTH-02 | MEDIUM | Terminal secrets stored in plaintext | Medium |
Nice to Have
| ID | Severity | Finding | Effort |
|---|---|---|---|
| V-RATE-03 | LOW | Rate limiter memory leak | Low — use TTL cache |
| — | LOW | XSS in product name (stored, API-only) | Low — add output encoding |
| — | LOW | HSTS and CSP headers missing | Low — configure in SecurityConfig |
| — | LOW | Non-existent endpoints return 500 not 404 | Low |
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).