Skip to content

[Bug]: Private PR media doesn't load when the org enforces SAML SSO #14166

Description

@clarissa-gunawan

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Use a private repository in a GitHub organization that enforces SAML SSO.
  2. Make sure gh on the T3 server host is logged in with an SSO-authorized token (gh api repos/<owner>/<repo> returns 200).
  3. Open a PR in T3 whose description has an uploaded image or video (https://github.com/user-attachments/assets/<id>).

Expected behavior

The image or video loads through the server's gh credential, as it does for private repositories since #11706.

Actual behavior

The media shows as unavailable. Server trace: the upstream GET https://github.com/user-attachments/assets/<id> returns 200, then /api/assets/* returns 415.

GitHub never redirects to the signed storage URL. With the gh token it returns 200 text/html, which is the organization's SSO sign-in page ("Sign in to ", with saml/initiate?return_to=…user-attachments…). githubMediaResponse then rejects the HTML as a non-media content type.

Request to github.com/user-attachments/assets/<id> Result
No token 404
Authorization: Bearer <gh token> 200 text/html (SSO sign-in page)
Authorization: token <gh token> 200 text/html (SSO sign-in page)
Public repo attachment, no token 302 to signed S3 URL (the path #11706 expects)

I reproduced this with two separate gh OAuth tokens (gho_, scopes repo, read:org, gist) on two machines. Both work for REST and GraphQL calls on the repo. The token is recognized (404 becomes 200), but GitHub seems to require an SSO browser session for this web route, which a token can't provide.

Related: #6600 (same symptom, closed as fixed by #11706; that fix works when GitHub redirects to storage, which SSO-enforcing orgs don't). The open #11374 uses the same request-and-expect-a-redirect approach for GitHub, so it would fail the same way.

Impact

Major degradation or frequent failure

Version or commit

0.0.43-nightly.20260928.2375 (d15210cd3da7), on both server and desktop

Environment

macOS desktop client connected to a remote Linux x64 T3 server (systemd user service), gh 2.x authenticated on the server

Logs or stack traces

http.client GET https://github.com/user-attachments/assets/<id>  -> 200 (text/html, SSO sign-in page)
http.server GET /api/assets/*                                     -> 415
GitHubMediaFetch.githubToken / fetchFollowingRedirects / githubMediaResponse -> exit Success

Screenshots, recordings, or supporting files

No response

Workaround

Open the PR on github.com in a browser with an active SSO session.

Possible fix: GitHub's markdown render API returns a signed URL for an attachment when given the repo as context, and that URL loads without a credential, under SSO too:

jq -n '{text:"![](https://github.com/user-attachments/assets/<id>)", mode:"gfm", context:"<owner>/<repo>"}' \
  | gh api markdown --input - | grep -oE 'https://private-user-images[^"]+'
# fetching that URL (unescape &amp;) -> 200 image/png, or 200 video/mp4 for video uploads

GraphQL bodyHTML on the PR also contains these signed URLs. The signatures expire after about 5 minutes (exp - nbf = 300).

In GitHubMediaFetch.githubMediaResponse, when a user-attachments request returns 200 text/html, the server could render the URL through /markdown, fetch the signed private-user-images URL without the token, and stream it. Caching the signed URL for about 4 minutes would keep video range requests from re-rendering each time.

Investigated with Claude Code (Claude Opus 5.5).

Activity

  1. changed the title [-][Bug]: Private PR media doesn't load when the org enforces SAML SSO[/-] [+]Private PR media doesn't load when the org enforces SAML SSO[/+] on Sep 28, 2026
  2. changed the title [-]Private PR media doesn't load when the org enforces SAML SSO[/-] [+][Bug]: Private PR media doesn't load when the org enforces SAML SSO[/+] on Sep 28, 2026
  3. juliusmarminge commented on Sep 28, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed, and still present on main. GitHubMediaFetch has not changed since d15210cd (the nightly in the report).

    githubMediaResponse sends the gh token only to GitHub hosts and treats a 2xx as the media itself. An SSO sign-in page is 200 text/html, so it never follows a redirect. user-attachments URLs are extensionless, the MIME fallback from the filename is empty, and the route returns 415 (apps/server/src/assets/GitHubMediaFetch.ts). That Effect succeeds, which is why the trace shows GitHubMediaFetch.githubMediaResponse exiting Success. The client falls back to the original URL only when signing the asset URL fails, not when the fetch returns 415, so the image or video stays broken. The same route serves PR descriptions, timeline comments, and review comments on web and desktop.

    This is the case #11706 did not cover. That change (which closed #6600) works when GitHub answers the token with a 302 to a signed object URL. An org that enforces SAML SSO does not do that for this web route, even when the same token is authorized for REST and GraphQL. Bearer versus token does not matter; the server already sends Bearer, and both return the sign-in page. #11374 still assumes the redirect, so it would miss this too. Not a duplicate, and no newer release changes it.

    Rendering the attachment through POST /markdown with the repo as context, then fetching the signed private-user-images URL, is the right fix. A few constraints:

    • Do this only when a user-attachments or legacy github.com/<owner>/<repo>/assets/… fetch comes back 200 with text/html. Raw and LFS URLs should stay on the current path.
    • The asset claim is only { url, cwd, expiresAt }. A user-attachments URL does not name a repository, so context has to be resolved separately. gh repo view from cwd is the wrong repo when the checkout is a fork and the upload lives on the upstream PR. The PR's owner/repo is known when the body is fetched and is not passed into the asset resource today.
    • Accept only https://private-user-images.githubusercontent.com/… from the rendered HTML, unescape &amp;, and fetch it without the gh token. That host is already outside CREDENTIALED_HOSTS.
    • Those signatures last about 5 minutes. Bytes we have already streamed can keep the asset token's cache lifetime (60 minutes). Video range requests re-enter this route, so cache the signed URL for slightly less than 5 minutes instead of re-rendering on every seek. Do not log the signed URL.

    Opening the PR on github.com in a browser that already has an SSO session remains the workaround.

  4. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Sep 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions