Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set up GitHub oauth for rerun button #13008

Merged
merged 1 commit into from
Jun 20, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions prow/cmd/deck/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,13 +453,14 @@ func prodOnlyMain(cfg config.Getter, o options, mux *http.ServeMux) *http.ServeM
githubOAuthConfig.InitGitHubOAuthConfig(cookie)

goa := githuboauth.NewAgent(&githubOAuthConfig, logrus.WithField("client", "githuboauth"))
oauthClient := &oauth2.Config{
oauthClient := githuboauth.NewClient(&oauth2.Config{
ClientID: githubOAuthConfig.ClientID,
ClientSecret: githubOAuthConfig.ClientSecret,
RedirectURL: githubOAuthConfig.RedirectURL,
Scopes: githubOAuthConfig.Scopes,
Endpoint: github.Endpoint,
}
},
)

repoSet := make(map[string]bool)
for r := range cfg().Presubmits {
Expand Down Expand Up @@ -1231,6 +1232,5 @@ func handleFavicon(staticFilesLocation string, cfg config.Getter) http.HandlerFu

func isValidatedGitOAuthConfig(githubOAuthConfig *config.GitHubOAuthConfig) bool {
return githubOAuthConfig.ClientID != "" && githubOAuthConfig.ClientSecret != "" &&
githubOAuthConfig.RedirectURL != "" &&
githubOAuthConfig.FinalRedirectURL != ""
githubOAuthConfig.RedirectURL != ""
}
15 changes: 15 additions & 0 deletions prow/cmd/deck/static/common/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,18 @@ export namespace tidehistory {
return link;
}
}

export function getCookieByName(name: string): string {
if (!document.cookie) {
return "";
}
const docCookies = decodeURIComponent(document.cookie).split(";");
for (const cookie of docCookies) {
const c = cookie.trim();
const pref = name + "=";
if (c.indexOf(pref) === 0) {
return c.slice(pref.length);
}
}
return "";
}
22 changes: 2 additions & 20 deletions prow/cmd/deck/static/pr/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {Context} from '../api/github';
import {Label, PullRequest, UserData} from '../api/pr';
import {Job, JobState} from '../api/prow';
import {Blocker, TideData, TidePool, TideQuery as ITideQuery} from '../api/tide';
import {tidehistory} from '../common/common';
import {getCookieByName, tidehistory} from '../common/common';

declare const tideData: TideData;
declare const allBuilds: Job[];
Expand Down Expand Up @@ -179,24 +179,6 @@ function onLoadQuery(): string {
return "";
}

/**
* Gets cookie by its name.
*/
function getCookieByName(name: string): string {
if (!document.cookie) {
return "";
}
const cookies = decodeURIComponent(document.cookie).split(";");
for (const cookie of cookies) {
const c = cookie.trim();
const pref = name + "=";
if (c.indexOf(pref) === 0) {
return c.slice(pref.length);
}
}
return "";
}

/**
* Creates an alert for merge blocking issues on tide.
*/
Expand Down Expand Up @@ -1154,7 +1136,7 @@ function createPRCard(pr: PullRequest, builds: UnifiedContext[] = [], queries: P
* Redirect to initiate github login flow.
*/
function forceGitHubLogin(): void {
window.location.href = window.location.origin + "/github-login";
window.location.href = window.location.origin + "/github-login?dest=%2Fpr";
}

type VagueState = "succeeded" | "failed" | "pending" | "unknown";
Expand Down
47 changes: 30 additions & 17 deletions prow/cmd/deck/static/prow/prow.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import moment from "moment";
import {Job, JobState, JobType} from "../api/prow";
import {cell, icon} from "../common/common";
import {cell, getCookieByName, icon} from "../common/common";
import {FuzzySearch} from './fuzzy-search';
import {JobHistogram, JobSample} from './histogram';

Expand Down Expand Up @@ -398,6 +398,7 @@ function escapeRegexLiteral(s: string): string {
}

function redraw(fz: FuzzySearch): void {
const rerunStatus = getParameterByName("rerun");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a general architectural point, I don't think we want to implement this by having users follow links and being redirected around (and indeed the current implementation based on this scheme in #12827 is unsafe).

Assuming we ultimately end up hitting an API via XHR, does it still make sense to have this query supported and carried around?

const modal = document.getElementById('rerun')!;
const rerunCommand = document.getElementById('rerun-content')!;
window.onclick = (event) => {
Expand Down Expand Up @@ -649,33 +650,45 @@ function redraw(fz: FuzzySearch): void {
max = 2 * 3600;
}
drawJobHistogram(totalJob, jobHistogram, now - (12 * 3600), now, max);
if (rerunStatus != null) {
Katharine marked this conversation as resolved.
Show resolved Hide resolved
modal.style.display = "block";
rerunCommand.innerHTML = `Nice try! The direct rerun feature hasn't been implemented yet, so that button does nothing.`;
}

}

function createRerunCell(modal: HTMLElement, rerunElement: HTMLElement, prowjob: string): HTMLTableDataCellElement {
const url = `https://${window.location.hostname}/rerun?prowjob=${prowjob}`;
const url = `/rerun?prowjob=${prowjob}`;
const c = document.createElement("td");
let i;
if (rerunCreatesJob) {
i = icon.create("refresh", "Rerun this job");
i.onclick = () => {
const form = document.createElement('form');
form.method = 'POST';
form.action = `${url}`;
c.appendChild(form);
form.submit();
};
} else {
i = icon.create("refresh", "Show instructions for rerunning this job");
i.onclick = () => {
const i = icon.create("refresh", "Show instructions for rerunning this job");
const login = getCookieByName("github_login");
i.onclick = () => {
modal.style.display = "block";
rerunElement.innerHTML = `kubectl create -f "<a href="${url}">${url}</a>"`;
const copyButton = document.createElement('a');
copyButton.className = "mdl-button mdl-js-button mdl-button--icon";
copyButton.onclick = () => copyToClipboardWithToast(`kubectl create -f "${url}"`);
copyButton.innerHTML = "<i class='material-icons state triggered' style='color: gray'>file_copy</i>";
rerunElement.appendChild(copyButton);
};
}
const runButton = document.createElement('a');
runButton.innerHTML = "<button class='mdl-button mdl-js-button'>Run</button>";
if (login === "") {
runButton.href = `/github-login?dest=%2F?rerun=work_in_progress`;
} else {
if (rerunCreatesJob) {
runButton.onclick = () => {
const form = document.createElement('form');
form.method = 'POST';
form.action = `${url}`;
c.appendChild(form);
form.submit();
};
} else {
runButton.href = `/?rerun=work_in_progress`;
}
}
rerunElement.appendChild(runButton);
};
c.appendChild(i);
c.classList.add("icon-cell");
return c;
Expand Down
9 changes: 4 additions & 5 deletions prow/config/githuboauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,10 @@ type Cookie struct {
// GitHubOAuthConfig is a config for requesting users access tokens from GitHub API. It also has
// a Cookie Store that retains user credentials deriving from GitHub API.
type GitHubOAuthConfig struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURL string `json:"redirect_url"`
Scopes []string `json:"scopes,omitempty"`
FinalRedirectURL string `json:"final_redirect_url"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURL string `json:"redirect_url"`
Scopes []string `json:"scopes,omitempty"`

CookieStore *sessions.CookieStore `json:"-"`
}
Expand Down
53 changes: 48 additions & 5 deletions prow/githuboauth/githuboauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"encoding/hex"
"fmt"
"net/http"
"net/url"
"time"

"github.com/google/go-github/github"
Expand Down Expand Up @@ -54,13 +55,43 @@ type GitHubClientGetter interface {

// OAuthClient is an interface for a GitHub OAuth client.
type OAuthClient interface {
WithFinalRedirectURL(url string) (OAuthClient, error)
// Exchanges code from GitHub OAuth redirect for user access token.
Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
// Returns a URL to GitHub's OAuth 2.0 consent page. The state is a token to protect the user
// from an XSRF attack.
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
}

type client struct {
*oauth2.Config
}

func NewClient(config *oauth2.Config) client {
return client{
config,
}
}

func (cli client) WithFinalRedirectURL(path string) (OAuthClient, error) {
parsedURL, err := url.Parse(cli.RedirectURL)
if err != nil {
return nil, err
}
q := parsedURL.Query()
q.Set("dest", path)
parsedURL.RawQuery = q.Encode()
return NewClient(
&oauth2.Config{
ClientID: cli.ClientID,
ClientSecret: cli.ClientSecret,
RedirectURL: parsedURL.String(),
Scopes: cli.Scopes,
Endpoint: cli.Endpoint,
},
), nil
}

type githubClientGetter struct{}

func (gci *githubClientGetter) GetGitHubClient(accessToken string, dryRun bool) GitHubClientWrapper {
Expand Down Expand Up @@ -92,6 +123,7 @@ func NewAgent(config *config.GitHubOAuthConfig, logger *logrus.Entry) *Agent {
// redirect user to GitHub OAuth end-point for authentication.
func (ga *Agent) HandleLogin(client OAuthClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
destPage := r.URL.Query().Get("dest")
stateToken := xsrftoken.Generate(ga.gc.ClientSecret, "", "")
state := hex.EncodeToString([]byte(stateToken))
oauthSession, err := ga.gc.CookieStore.New(r, oauthSessionCookie)
Expand All @@ -108,8 +140,11 @@ func (ga *Agent) HandleLogin(client OAuthClient) http.HandlerFunc {
ga.serverError(w, "Save oauth session", err)
return
}

redirectURL := client.AuthCodeURL(state, oauth2.ApprovalForce, oauth2.AccessTypeOnline)
newClient, err := client.WithFinalRedirectURL(destPage)
if err != nil {
ga.serverError(w, "Failed to parse redirect URL", err)
}
redirectURL := newClient.AuthCodeURL(state, oauth2.ApprovalForce, oauth2.AccessTypeOnline)
http.Redirect(w, r, redirectURL, http.StatusFound)
}
}
Expand All @@ -135,7 +170,7 @@ func (ga *Agent) HandleLogout(client OAuthClient) http.HandlerFunc {
loginCookie.Expires = time.Now().Add(-time.Hour * 24)
http.SetCookie(w, loginCookie)
}
http.Redirect(w, r, ga.gc.FinalRedirectURL, http.StatusFound)
http.Redirect(w, r, r.URL.Host, http.StatusFound)
stevekuznetsov marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand All @@ -144,6 +179,14 @@ func (ga *Agent) HandleLogout(client OAuthClient) http.HandlerFunc {
// the final destination in the config, which should be the front-end.
func (ga *Agent) HandleRedirect(client OAuthClient, getter GitHubClientGetter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
finalRedirectURL, err := r.URL.Parse(r.URL.Query().Get("dest"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is broadly considered to be unsafe: by accepting any arbitrary URL as a redirect destination, we are acting as an "open redirect" - that is, an attacker can make a prow.k8s.io link go to any website (in this case, with a GitHub login in the middle), which opens a nominal phishing vector.

These redirects should be locked down more - IIRC an earlier version of this PR only let you access pages on the same domain, which largely mitigates the issue. An alternative approach is an explicit whitelist of valid redirect targets.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this does the same thing as the earlier version, just using the url library. r.URL.Parse uses the hostname from the request URL, which I think has to be prow.k8s.io?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(*URL).Parse() parses a string in the context of the original URL. Given a string that contains only a path, like /pr, r.URL.Parse("/pr") will use the initial context and produces a URL like https://prow.k8s.io/pr.

However, given a complete URL, e.g. https://evil.com/foo, the context is no longer necessary, and so r.URL.Parse("https://evil.com/foo") will produce https://evil.com/foo (try it out!).

The previous string concatenation approach was safer: there is no way (that I am aware of) to get out of prow.k8s.io like that.

Either reverting to string concatenation or verifying that finalRedirectURL has the host you expect should fix the problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I check now that there's no host specified in finalRedirectURL, which should make sure it's redirected back to the same host.

//This check prevents someone from specifying a different host to redirect to.
if finalRedirectURL.Host != "" {
ga.serverError(w, "Invalid hostname", fmt.Errorf("%s, expected %s", finalRedirectURL.Host, r.URL.Host))
}
if err != nil {
ga.serverError(w, "Failed to parse final destination from OAuth redirect payload", err)
}
state := r.FormValue("state")
stateTokenRaw, err := hex.DecodeString(state)
if err != nil {
Expand All @@ -163,7 +206,7 @@ func (ga *Agent) HandleRedirect(client OAuthClient, getter GitHubClientGetter) h
}
secretState, ok := oauthSession.Values[stateKey].(string)
if !ok {
ga.serverError(w, "Get secret state", fmt.Errorf("empty string or cannot convert to string"))
ga.serverError(w, "Get secret state", fmt.Errorf("empty string or cannot convert to string. this probably means the options passed to GitHub don't match what was expected"))
return
}
// Validate the state parameter to prevent cross-site attack.
Expand Down Expand Up @@ -219,7 +262,7 @@ func (ga *Agent) HandleRedirect(client OAuthClient, getter GitHubClientGetter) h
Expires: time.Now().Add(time.Hour * 24 * 30),
Secure: true,
})
http.Redirect(w, r, ga.gc.FinalRedirectURL, http.StatusFound)
http.Redirect(w, r, finalRedirectURL.String(), http.StatusFound)
}
}

Expand Down
Loading