hcengineering / hcengineering/platform
[SECURITY] - Server-Side Request Forgery (SSRF) in Print Service via Unvalidated link Parameter
- Dominant language
- TypeScript
- Stars
- 27.7k
- Forks
- 2.2k
- PR merge metrics
- No merged PRs in 30d
Description
# Server-Side Request Forgery (SSRF) in Print Service via Unvalidated `link` Parameter
## Summary
The `pod-print` service exposes a `/print` endpoint that accepts a user-supplied URL, decodes it, performs an insufficient validation check, and then instructs a headless Chromium instance (Puppeteer) to navigate to it. Because the hostname whitelist is disabled by default in every standard deployment, any authenticated user can cause the server to issue arbitrary HTTP requests to internal infrastructure, including cloud provider metadata services, internal databases, and co-located microservices that were never intended to be reachable from the outside.
**Severity:** High (CVSS 3.1 Base Score ~7.5)
**Component:** `services/print/pod-print`
**Authentication required:** Yes, a valid workspace token
**Default configuration affected:** All deployments where `ALLOWED_HOSTNAMES` is not explicitly set (which is the default)
---
## Vulnerability Walkthrough
### Step 1 — Entry point: user-controlled input
```typescript
// services/print/pod-print/src/server.ts:199
const rawlink = req.query.link as string
const link = decodeURIComponent(rawlink)
```
The `link` query parameter is taken directly from the HTTP request and URL-decoded. There is no type coercion, length limit, or sanitization at this stage. The decoded value is then passed forward as-is.
### Step 2 — The validation that looks sufficient but isn't
```typescript
// services/print/pod-print/src/server.ts:203–208
const url = new URL(link)
if (
!['http:', 'https:'].includes(url.protocol) ||
(whitelistedHostnames != null && !whitelistedHostnames.has(url.hostname))
) {
throw new ApiError(400)
}
```
At first glance this appears to validate the URL. It checks that the protocol is `http:` or `https:`, and optionally checks the hostname against a whitelist. The critical issue is in the second condition: the hostname check only runs when `whitelistedHostnames != null`. If the whitelist is null, the entire hostname check is skipped and only the protocol restriction applies.
### Step 3 — The whitelist is null by default
```typescript
// services/print/pod-print/src/config.ts:21,29
const allowedHostnames = process.env.ALLOWED_HOSTNAMES
AllowedHostnames: allowedHostnames == null ? [] : allowedHostnames.split(','),
```
```typescript
// services/print/pod-print/src/server.ts:188
const whitelistedHostnames = allowedHostnames.length > 0 ? new Set(allowedHostnames) : null
```
`ALLOWED_HOSTNAMES` is an optional environment variable. When it is not set, which is the case in every default Docker deployment, as confirmed by the absence of this variable in `huly-selfhost/docker-compose.yaml` - `allowedHostnames` is an empty array, `whitelistedHostnames` is `null`, and the hostname validation branch is never entered.
### Step 4 — Puppeteer fetches the attacker-controlled URL
```typescript
// services/print/pod-print/src/print.ts:58
await page.goto(url, {
waitUntil: ['domcontentloaded', 'networkidle0']
})
```
Puppeteer is a full headless browser running inside the server environment. When it navigates to a URL, it issues a real HTTP request from the server's network interface not the client's. Any host reachable from the server is reachable through this call. The response content is then used to generate a PDF or screenshot, portions of which may be returned to the caller.
---
## Authentication Requirement
The endpoint does sit behind token authentication:
```typescript
// services/print/pod-print/src/server.ts:92–125
const token =
extractAuthorizationToken(headers.authorization) ??
extractCookieToken(headers.cookie) ??
extractQueryToken(req.query)
if (token === null) {
throw new ApiError(401)
}
const wsLoginInfo = await getAccountClient(token).getLoginInfoByToken()
if (!wsLoginInfo) {
throw new ApiError(401, "Couldn't find workspace with the provided token")
}
```
This is not a bypass of the SSRF, obtaining a valid token on any self-hosted instance is trivial through normal registration. On multi-tenant cloud deployments, any subscriber account is sufficient. The authentication requirement reduces the attacker pool but does not meaningfully limit the real-world risk.
---
## Impact
**Cloud-hosted deployments** are the highest-risk target. On any instance running on AWS, GCP, or Azure, the Puppeteer process can reach the cloud provider's Instance Metadata Service (IMDS):
```
GET /print?link=http://169.254.169.254/latest/meta-data/iam/security-credentials/
```
On AWS, this returns temporary IAM credentials including `AccessKeyId`, `SecretAccessKey`, and `Token`. These credentials can be used immediately to interact with any AWS service the instance role has access to S3 buckets containing user data, RDS instances, Secrets Manager, and beyond. The attacker never needs to touch the host system directly.
Beyond cloud metadata, the print service shares a Docker network with MongoDB, the transactor, the collaborator service, and every other pod in the stack. An attacker can enumerate and probe these services by supplying their internal hostnames or IP ranges as the `link` parameter. Services that trust traffic from within the Docker network and expose unauthenticated internal APIs become fully accessible.
The response surface is also meaningful: since the endpoint returns a rendered PDF or image of the navigated page, an attacker can read the response content of internal endpoints. This isn't blind SSRF, which significantly increases the data exfiltration potential.
In summary, a single compromised or malicious user account on any unpatched deployment can pivot from the application layer into the infrastructure layer, exfiltrate cloud credentials, and move laterally across internal services.
---
## Proof of Concept
```http
GET /print?link=http%3A%2F%2F169.254.169.254%2Flatest%2Fmeta-data%2F HTTP/1.1
Host:
Authorization: Bearer
```
Expected behavior on a vulnerable instance: the server returns a PDF rendering of the AWS metadata index page, confirming full SSRF without any hostname restriction.
---
## Remediation
The fix is straightforward: invert the whitelist logic so that requests are **denied by default** when no whitelist is configured, rather than allowed.
**Current logic:**
```typescript
if (
!['http:', 'https:'].includes(url.protocol) ||
(whitelistedHostnames != null && !whitelistedHostnames.has(url.hostname))
) {
throw new ApiError(400)
}
```
**Proposed fix:**
```typescript
if (!['http:', 'https:'].includes(url.protocol)) {
throw new ApiError(400)
}
if (whitelistedHostnames === null || !whitelistedHostnames.has(url.hostname)) {
throw new ApiError(400)
}
```
With this change, the `ALLOWED_HOSTNAMES` variable becomes required for the endpoint to function at all. Documentation should be updated accordingly to make clear that this variable must be scoped to the hostnames of the Huly frontend only. Additionally, blocking requests to RFC 1918 addresses and link-local ranges (`169.254.0.0/16`) at the network level provides defense-in-depth regardless of application configuration.
---
## Environment
- **Repository:** `hcengineering/platform`
- **Affected service:** `services/print/pod-print`
- **Affected files:** `src/server.ts`, `src/config.ts`, `src/print.ts`
- **Deployment config:** `huly-selfhost` — `ALLOWED_HOSTNAMES` absent from `docker-compose.yaml`
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.