Skip to content

Commit c4f4266

Browse files
authored
Detect SAML SSO interstitial and give an actionable error (#16)
Detect the SAML SSO 'Sign in to <owner>' interstitial when the uploadToken is absent, and return an actionable error pointing at /orgs/<owner>/sso instead of a misleading write-access message. Closes #17
1 parent 4a1a1a5 commit c4f4266

2 files changed

Lines changed: 200 additions & 1 deletion

File tree

internal/upload/token.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,32 @@ import (
1111

1212
var uploadTokenRe = regexp.MustCompile(`"uploadToken":"([^"]+)"`)
1313

14+
// isSAMLProtected reports whether the repo page is a SAML SSO "Sign in to
15+
// <owner>" interstitial rather than the real repo page. When an organization
16+
// enforces SAML SSO and the browser session is authenticated but not
17+
// SSO-authorized for that org, GitHub serves this interstitial with HTTP 200 —
18+
// so the uploadToken is absent even though the user has write access.
19+
//
20+
// The SSO authorization is server-side state (it lasts ~24h and is granted only
21+
// by completing the identity-provider handshake in a browser), so it is NOT a
22+
// cookie that can be copied; the fix is to re-authorize at /orgs/<owner>/sso.
23+
//
24+
// We require signals SPECIFIC to the interstitial and scoped to THIS owner. We
25+
// deliberately do NOT match the words "SAML"/"single sign-on" anywhere on the
26+
// page: those appear in GitHub's site chrome/help links on virtually every page
27+
// (and in any repo that is simply about SAML), which would be a false positive.
28+
func isSAMLProtected(body []byte, owner string) bool {
29+
if owner == "" {
30+
return false
31+
}
32+
// Owners are case-insensitive on GitHub, and the page may render a different
33+
// case than the user typed, so both checks are case-insensitive.
34+
o := regexp.QuoteMeta(owner)
35+
orgSSOLink := regexp.MustCompile(`(?i)/orgs/` + o + `/sso`).Match(body)
36+
ssoTitle := regexp.MustCompile(`(?i)<title>\s*Sign in to ` + o + `\b`).Match(body)
37+
return orgSSOLink || ssoTitle
38+
}
39+
1440
// GetUploadToken fetches the repo page and extracts the uploadToken
1541
// from the JS payload. Requires authenticated cookies in the client.
1642
func GetUploadToken(client *http.Client, owner, repo string) (string, error) {
@@ -39,7 +65,16 @@ func GetUploadToken(client *http.Client, owner, repo string) (string, error) {
3965

4066
match := uploadTokenRe.FindSubmatch(body)
4167
if match == nil {
42-
return "", fmt.Errorf("uploadToken not found on repo page — do you have write access to %s/%s?", owner, repo)
68+
// Distinguish the common SAML-SSO case from a genuine lack of access, so
69+
// the user isn't wrongly told to check their permissions.
70+
if isSAMLProtected(body, owner) {
71+
return "", fmt.Errorf("%s enforces SAML SSO and your session is not authorized for it — "+
72+
"authorize in a browser at https://github.com/orgs/%s/sso (lasts ~24h), then retry. "+
73+
"Write access alone is not enough", owner, owner)
74+
}
75+
return "", fmt.Errorf("uploadToken not found on repo page — do you have write access to %s/%s? "+
76+
"(or, if %s enforces SAML SSO, authorize at https://github.com/orgs/%s/sso)",
77+
owner, repo, owner, owner)
4378
}
4479

4580
return string(match[1]), nil

internal/upload/token_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package upload
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestIsSAMLProtected(t *testing.T) {
11+
cases := []struct {
12+
name string
13+
owner string
14+
body string
15+
want bool
16+
}{
17+
{
18+
name: "SSO interstitial title",
19+
owner: "GymPod",
20+
body: `<title>Sign in to GymPod</title>`,
21+
want: true,
22+
},
23+
{
24+
name: "owner-scoped /orgs/<owner>/sso link",
25+
owner: "GymPod",
26+
body: `<a href="/orgs/GymPod/sso?return_to=%2FGymPod%2Frepo">Single sign-on</a>`,
27+
want: true,
28+
},
29+
{
30+
// The key false positive a naive "contains SAML" check would hit:
31+
// site chrome / help links mention SSO on essentially every page.
32+
name: "normal repo page with SSO words in chrome must NOT match",
33+
owner: "GymPod",
34+
body: `<title>GymPod/realtime-core</title><footer><a href="/help/saml">single sign-on docs</a></footer>`,
35+
want: false,
36+
},
37+
{
38+
name: "a repo that is ABOUT saml must NOT match",
39+
owner: "crewjam",
40+
body: `<title>GitHub - crewjam/saml: SAML library for go</title> ... single sign-on ...`,
41+
want: false,
42+
},
43+
{
44+
name: "another org's sso link must NOT match this owner",
45+
owner: "GymPod",
46+
body: `<a href="/orgs/SomeOtherOrg/sso">x</a>`,
47+
want: false,
48+
},
49+
{
50+
name: "owner with regex metacharacters is matched literally",
51+
owner: "a.b",
52+
body: `<title>Sign in to axb</title>`, // '.' must NOT act as a wildcard
53+
want: false,
54+
},
55+
{
56+
// GitHub owners are case-insensitive and the page may render a
57+
// different case than the user typed; the link must still match.
58+
name: "lowercase owner matches a canonical-case sso link",
59+
owner: "gympod",
60+
body: `<a href="/orgs/GymPod/sso">Single sign-on</a>`,
61+
want: true,
62+
},
63+
{
64+
// Substring of another org's name must not match.
65+
name: "owner that is a substring of another org must NOT match",
66+
owner: "pod",
67+
body: `<a href="/orgs/GymPod/sso">x</a>`,
68+
want: false,
69+
},
70+
{
71+
// Defensive: an empty owner must never match (and must not panic).
72+
name: "empty owner never matches",
73+
owner: "",
74+
body: `<a href="/orgs/Anything/sso"><title>Sign in to </title>`,
75+
want: false,
76+
},
77+
}
78+
for _, tc := range cases {
79+
t.Run(tc.name, func(t *testing.T) {
80+
if got := isSAMLProtected([]byte(tc.body), tc.owner); got != tc.want {
81+
t.Errorf("isSAMLProtected(%q owner=%q) = %v, want %v", tc.body, tc.owner, got, tc.want)
82+
}
83+
})
84+
}
85+
}
86+
87+
func TestGetUploadToken(t *testing.T) {
88+
cases := []struct {
89+
name string
90+
owner string
91+
body string
92+
wantToken string // non-empty => expect success
93+
errContains []string // substrings the error must include
94+
errExcludes []string // substrings the error must NOT include
95+
}{
96+
{
97+
name: "success extracts the token",
98+
owner: "octocat",
99+
body: `window.x={"uploadToken":"TKN123"};`,
100+
wantToken: "TKN123",
101+
},
102+
{
103+
name: "SAML interstitial gives an actionable SSO error, not write-access",
104+
owner: "GymPod",
105+
body: `<title>Sign in to GymPod</title><a href="/orgs/GymPod/sso">Single sign-on</a>`,
106+
errContains: []string{"SAML SSO", "/orgs/GymPod/sso", "Write access alone is not enough"},
107+
errExcludes: []string{"do you have write access to GymPod"},
108+
},
109+
{
110+
name: "no token and no SSO markers gives the generic message",
111+
owner: "octocat",
112+
body: `<html>just a page, no token</html>`,
113+
errContains: []string{"do you have write access to octocat/hello"},
114+
},
115+
}
116+
for _, tc := range cases {
117+
t.Run(tc.name, func(t *testing.T) {
118+
client := &http.Client{Transport: stubTransport(http.StatusOK, tc.body)}
119+
tok, err := GetUploadToken(client, tc.owner, "hello")
120+
121+
if tc.wantToken != "" {
122+
if err != nil {
123+
t.Fatalf("expected success, got error: %v", err)
124+
}
125+
if tok != tc.wantToken {
126+
t.Errorf("token = %q, want %q", tok, tc.wantToken)
127+
}
128+
return
129+
}
130+
131+
if err == nil {
132+
t.Fatal("expected an error, got nil")
133+
}
134+
for _, s := range tc.errContains {
135+
if !strings.Contains(err.Error(), s) {
136+
t.Errorf("error missing %q; got: %s", s, err.Error())
137+
}
138+
}
139+
for _, s := range tc.errExcludes {
140+
if strings.Contains(err.Error(), s) {
141+
t.Errorf("error should not contain %q; got: %s", s, err.Error())
142+
}
143+
}
144+
})
145+
}
146+
}
147+
148+
// stubTransport answers every request with the given status and body, so
149+
// GetUploadToken's hardcoded github.com URL is served locally without a network
150+
// call.
151+
type roundTripFunc func(*http.Request) (*http.Response, error)
152+
153+
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
154+
155+
func stubTransport(status int, body string) http.RoundTripper {
156+
return roundTripFunc(func(r *http.Request) (*http.Response, error) {
157+
return &http.Response{
158+
StatusCode: status,
159+
Body: io.NopCloser(strings.NewReader(body)),
160+
Header: make(http.Header),
161+
Request: r,
162+
}, nil
163+
})
164+
}

0 commit comments

Comments
 (0)