HexBrain

Basecamp · Report #3972760

Open redirect on app.hey.com bypasses HEY desktop's internal-origin allowlist and loads attacker content inside a HEY BrowserWindow

Validstatus
mediumseverity
33upvotes
15 Oct 2026disclosed

How it was found

Summary

app.hey.com/proxy_image compares the URL scheme case-sensitively. https:// gets signed and proxied correctly. HTTPS:// isn't recognised, so instead of proxying it, the endpoint 302s the visitor to the raw URL. No authentication needed.

That alone is an open redirect. It matters because the HEY desktop app uses "is this URL on app.hey.com?" as its test for "is this HEY's own content, safe to load in an app window."

A URL that genuinely is on app.hey.com passes that test, then redirects somewhere else.

Proof of concept

1. The redirect:

curl -sI "https://app.hey.com/proxy_image/HTTPS%3A%2F%2Fexample.com%2F"

HTTP/2 302
location: HTTPS://example.com/

Same URL in lowercase behaves correctly:

curl -sI "https://app.hey.com/proxy_image/https%3A%2F%2Fexample.com%2F"

HTTP/2 302
location: https://gopher.hey.com/sNnTdUWAvh27xF-f273gMtyxvGbOnLHSflbedosO23Ns=/https://example.com/

One capital letter apart. javascript: is still correctly rejected (403), so this is the scheme comparison, not a missing filter. Schemes are case-insensitive per RFC 3986 section 3.1, so HTTPS:// is a valid URL, not a malformed edge case.

2. The full attack:

Email a HEY user this link:

https://app.hey.com/proxy_image/HTTPS%3A%2F%2Fexample.com%2F?x=/print

Open the message in the HEY desktop app and click it. A HEY window opens showing example.com, a site with nothing to do with HEY. Swap example.com for any host you control and it renders that instead.

Two details, both required:

  • Percent-encode the inner URL. Unencoded, Rails collapses the // and returns Location: HTTPS:/example.com/, which browsers resolve relative to app.hey.com.
  • The URL must end in /print or .text, which is what the desktop window allowlist checks. ?x=/print satisfies it without touching the destination.

Confirmed on HEY for Linux 1.3.6 (Electron 40.9.3): the attacker page renders inside a HEY application window, and the destination server logged the fetch from HEY/1.3.6 Chrome/144.0.7559.236 Electron/40.9.3, so the app itself loaded it. macOS and Windows share this code path. HEY for Android 1.31.0 hands the link to the external browser and is not affected. A plain external link in the same message correctly opened in the external browser, which is what proves the app does route external links outward and that the redirect is what makes an attacker destination look internal.

Why this isn't just an open redirect

Three checks run, each correct on its own, and the URL passes all three because it really is on app.hey.com.

1. The mail sanitizer keeps the link. It's an http-family URL, nothing to strip.

2. HEY's client-side external-link proxy skips it. hey-message-content only intercepts links whose origin differs from the page:

if (element && element.origin != location.origin && element.protocol.startsWith("http")) {
    return element;   // routed out to the OS browser
}

The link's origin is app.hey.com, so it's treated as internal and handed to the app.

3. Electron's window allowlist accepts it (dist-js/index.js:72-74):

else if (Stage.current.locationIsInternal(url) && (url.endsWith(".text") || url.endsWith("/print"))) {
    createWindow(url);          // new HEY BrowserWindow, window.loadURL(url)
}

Same origin passes, ends in /print passes, HEY opens a window, app.hey.com 302s, and the window is now on my origin.

A second path reaches the same place. Stage.isInternalAttachmentLink (dist-js/stage.js:47-52) lets same-origin URLs carrying ?disposition=attachment through with no external-link guard at all. Your own comment on that method reads:

"We must match the query parameter on an internal URL, not the raw substring anywhere in an arbitrary URL — otherwise any external link that merely contains disposition=attachment (in its query, fragment, or path) would skip the external-link guard below and load an attacker page inside the chromeless app window."

That is exactly the outcome here, reached through a URL that genuinely is internal, which is the case the check wasn't written to catch.

Same root cause, second location (no impact found)

The identical case-sensitive scheme comparison exists in the inbound mail image rewriter. An <img src="HTTPS://host/pixel.png"> in an email is not rewritten to gopher.hey.com. The stored message keeps the sender's raw URL, so the image proxy is silently skipped for that class of URL. Ten variants were tested and every other unusual form (//host, https:/host, https:host, :443, leading whitespace, query plus fragment) is proxied correctly. Only the scheme case escapes.

I could not turn this into a leak. CSP img-src blocks the load, verified on the web app, the Linux desktop app, and by opening such a message on an Android phone where the unproxied images fetched zero times while the lowercase control in the same message was fetched by HEY's proxy. Reported only as evidence that the scheme comparison is systemic rather than a single endpoint bug.

Fix

Parse the URL and compare the normalised scheme before deciding whether to proxy, in /proxy_image and in the inbound mail image rewriter. Fail closed: if a value isn't a recognisable proxyable http(s) URL, return an error rather than redirecting the client to it.

Separately, stop treating "same origin" as "safe to load in an app window." Any open redirect on app.hey.com re-opens both desktop guards.

Impact

A remote attacker who knows nothing but the target's email address can send an ordinary message containing a link that points to https://app.hey.com, the user's own mail provider. One click renders the attacker's page inside a HEY application window. That window has no address bar and no origin indicator, so once the redirect fires there is nothing on screen that distinguishes the attacker's content from HEY's own interface. This inverts the one defence users are actually taught. "Check the URL before you type your password" is not just unavailable here, it was already satisfied, because the link genuinely was HEY's. The natural payload is a HEY sign-in or re-authentication prompt, which is especially credible because HEY already shows a password prompt inside the app before sensitive actions like mailbox export (/accounts/:id/exports). A user who has seen that flow has been trained to expect exactly this screen. Successful credential capture yields the victim's full mailbox. Severity is scoped as Changed because the defect lives in the web asset (app.hey.com) while the control it defeats lives in a separate asset, the desktop app's rule that same-origin URLs are safe to load in an app window.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original