Skip to content

[SECURITY] - Server-Side Request Forgery (SSRF) in Print Service via Unvalidated link Parameter #10864

@arexgodofwar

Description

@arexgodofwar

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

// 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

// 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

// services/print/pod-print/src/config.ts:21,29
const allowedHostnames = process.env.ALLOWED_HOSTNAMES
AllowedHostnames: allowedHostnames == null ? [] : allowedHostnames.split(','),
// 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

// 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:

// 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

GET /print?link=http%3A%2F%2F169.254.169.254%2Flatest%2Fmeta-data%2F HTTP/1.1
Host: <target>
Authorization: Bearer <valid_workspace_token>

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:

if (
  !['http:', 'https:'].includes(url.protocol) ||
  (whitelistedHostnames != null && !whitelistedHostnames.has(url.hostname))
) {
  throw new ApiError(400)
}

Proposed fix:

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-selfhostALLOWED_HOSTNAMES absent from docker-compose.yaml

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions