[Gitlab Pages Auth Bypass] Able to steal a user's Authentication Code For Gitlab Pages

**[HackerOne report #718460](https://hackerone.com/reports/718460)** by `ngalog` on 2019-10-21, assigned to @jeremymatos:

### Summary

I bypassed the regex for the gitlab pages authentication in gitlab.com

### Steps to reproduce  
- Login to gitlab and then visit https://projects.gitlab.io/auth?domain=https://ronchangitlab.io&state=xdgnwM0hmRQ7g5xoevNV6g==  
- Then attacker can get the access authorization code in ronchangitlab.io, then they can use it to exchange for victim's gitlab pages cookies

## Impact

Gitlab Pages authentication bypass

## Issue on dev

https://dev.gitlab.org/gitlab/gitlabhq/issues/2938

https://gitlab.com/gitlab-org/security/gitlab/-/issues/102

---

## Solution

### Proposals

- Proposal 1 - https://gitlab.com/gitlab-org/gitlab-pages/-/issues/262#note_255332604 and WIP PoC https://dev.gitlab.org/gitlab/gitlab-pages/-/merge_requests/21
- Proposal 2 - https://gitlab.com/gitlab-org/gitlab-pages/-/issues/262#note_341774099

### Proposal 2

~~Going ahead with proposal 2~~

1. Generate a JWT signing key based on the `auth-secret` on Pages startup
1. When we initiate the authentication flow, generate a JWT token with claims, set it as the state and save to the encrypted cookie
  ```go
    state := jwt(domain, randomNewState, jwtSigningKey)
  ```
3. Redirect to `projects.gitlab.io?state=state` and validate the state's JWT signature
4. Extract the domain from the JWT claims and continue OAuth flow.
5. When we receive `mydomain.gitlab.io?stat=state&code=code` we verify the state's JWT signature again.
6. Continue as usual

---
## Implemented solution

It was later discovered that the problem lies in someone being able to steal an OAuth `code` as part of the authentication flow. To mitigate this, the process is to encrypt the code and add it to a JWT which is then signed.
The flow is

1. Generate a JWT signing key based on the `auth-secret` on Pages startup.
1. On redirection from GitLab to https://gitlab.io/auth?code=plaintext&state=random, encrypt and sign the code
1. Encrypt code `encryptedCode=AES-GCM(code, domain (as salt), hkdfKey(auth-secret)`
1. Use the JWT as new code `code=JWT(encryptedCode, nonce, signingKey)`
1. Redirect to `mydomain.com/auth?code=JWT(encryptedCode, nonce, signingKey)&state=random` and strip the `token` from the query string to mitigate https://gitlab.com/gitlab-org/gitlab/-/issues/285244
1. When we receive `mydomain.gitlab.io?state=random&code=code` we verify the code's JWT signature.
1. Get `encryptedCode` and `nonce` from the JWT claims and decrypt the code
1. Exchange for access token and serve content if successful

---
<details>
<summary>Sequence diagrams from the comment below</summary>

### Summary

* The goal of the attack is to ready the content of site `mygroup.gitlab.io` which is private
* To achieve this attacker sends link `project.gitlab.io?domain=Attackersdomain.com` to user, which result in `attackersdomain.com?code=mygroupcode&state=irrelevant`
* After receiving the `code` attacker can complete the Auth process for `mygroup.gitlab.io`

Note: it's not possible for attacker to get `token`, only to read the content of private web-site.

### How the current Auth workflow works

```mermaid
sequenceDiagram
    participant U as User
    participant D as mygroup.gitlab.io
    participant P as projects.gitlab.io
    participant G as gitlab.com
    U->>D: get index.html
    D->>U: not authorzied, redirect to projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123
    U->>P: projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123
    rect rgb(0, 255, 0)
    Note over P,G: OAuth workflow
    P->>U: redirect to gitlab.com/oauth?redirect_url=projects.gitlab.io&state=123
    U->>G: gitlab.com/oauth?redirect_url=projects.gitlab.io&state=123
    G->>U: redirect to projects.gitlab.io?state=123&code=mycode
    U->>P: projects.gitlab.io?state=123&code=mycode
    end
    P->>U: redirect to mygroup.gitlab.io?state=123&code=mycode
    U->>D: mygroup.gitlab.io?state=123&code=mycode
    rect rgb(0, 255, 0)
    Note over D,G: Exchange code for token and verify that user has permissions for the mygroup.gitlab.io
    D->>G: get token by code=mycode
    G->>D: token = blablabla
    D->>G: get project by id with token = blablabla
    G->>D: success
    end
    D->>U: success
```

### Requests highlined in the green are not so relevant to the described attack, so let's get rid of them

```mermaid
sequenceDiagram
    participant U as User
    participant D as mygroup.gitlab.io
    participant P as projects.gitlab.io
    participant G as gitlab.com
    U->>D: get index.html
    D->>U: not authorzied, redirect to projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123
    U->>P: projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123
    Note over P,G: OAuth workflow
    P->>U: redirect to mygroup.gitlab.io?state=123&code=mycode
    U->>D: mygroup.gitlab.io?state=123&code=mycode
    Note over D,G: Exchange code for token and verify that user has permissions for the mygroup.gitlab.io
    D->>U: success
```

### Some notes about sessions(stored in encrypted and signed cookies with 10 minutes expiration time)

```mermaid
sequenceDiagram
    participant U as User
    participant D as mygroup.gitlab.io
    participant P as projects.gitlab.io
    participant G as gitlab.com
    U->>D: get index.html
    Note left of D: generate random state and save it in the session cookie
    D->>U: not authorzied, redirect to projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123
    U->>P: projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=123
    Note over P,G: domain=mygroup.gitlab.io is being saved to the session cookie
    Note over P,G: OAuth workflow
    Note over P,G: verify that "GitLab Pages conrolls the domain" from session cookie 
    P->>U: redirect to mygroup.gitlab.io?state=123&code=mycode
    U->>D: mygroup.gitlab.io?state=123&code=mycode
    Note left of D: Verify that state from the query parameter is the same as in session cookie
    Note over D,G: Exchange code for token and verify that user has permissions for the mygroup.gitlab.io
    D->>U: success
```

### A vulnerable step

You can see `verify that "GitLab Pages conrolls the domain" from session cookie` step above.

This is how it's implemented:
```golang
func (a *Auth) domainAllowed(name string, domains source.Source) bool {
	// This is incorrect but it's not important because of second check
	isConfigured := (name == a.pagesDomain) || strings.HasSuffix("."+name, a.pagesDomain)

	if isConfigured {
		return true
	}

	// This check is super easy to bypass by just adding the `attackersdomain.com` to any pages project passing validation
	// Note that validation does not mean that the correct CNAME record is set
	domain, err := domains.GetDomain(name)

	// domain exists and there is no error
	return (domain != nil && err == nil)
}
```

### The attack as it's can be performed currently

(note this diagram is a little different from [my previous comment](https://gitlab.com/gitlab-org/security/gitlab-pages/-/merge_requests/1#note_359604339):
I placed attacker requesting user domain last to bring related steps closer, it's still possible to perform the attack this way, and it's actually even more convenient)

Vulnerability executed is highlighted in green.

```mermaid
sequenceDiagram
    participant A as Attacker
    participant U as User
    participant D as mygroup.gitlab.io
    participant P as projects.gitlab.io
    participant G as gitlab.com
    participant AD as ATTACKERSDOMAIN.com
    A->U: can you please visit projects.gitlab.io/auth?domain=ATTACKERSDOMAIN.COM&state=IRRELEVANT
    U->>P: projects.gitlab.io/auth?domain=ATTACKERSDOMAIN.COM&state=IRRELEVANT
    Note over P,G: domain=ATTACKERSDOMAIN.COM is being saved to the session cookie
    Note over P,G: OAuth workflow
    rect rgb(0, 255, 0)
    Note over P,G: verify that "GitLab Pages controls the domain" from session cookie
    Note over P,G: This check is bypassed by adding ATTACKERSDOMAIN.COM to a random pages project
    P->>U: redirect to ATTACKERSDOMAIN.COM?state=IRRELEVANT&code=mycode
    U->>AD: ?state=IRRELEVANT&code=mycode
    end
    AD->>A: code=mycode
    Note right of A: Need to get a valid state in cookie:
    A->>D: get index.html
    D->>A: unauthorized, redirect to projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=USERSTATE
    Note right of A: skip the auth, just go the last step manually:
    A->>D: /auth?state=USERSTATE&code=mycode
    Note over D,G: Exchange code for token and verify that user has permissions for the mygroup.gitlab.io
    D->>A: success
```

### The JWT fix

We tried to fix this in https://gitlab.com/gitlab-org/security/gitlab-pages/-/merge_requests/1

It works by completely removing the `domain` parameter and including it in the `state=JWT({random=random, domain=domain...})`.

So the attacker can't send a link to domain.

But, it can be exploited in a little more complicated way:
```mermaid
sequenceDiagram
    participant A as Attacker
    participant Pages as Pages Server
    participant U as User
    participant D as mygroup.gitlab.io
    participant P as projects.gitlab.io
    participant G as gitlab.com
    participant AD as ATTACKERSDOMAIN.com
    Note right of A: Need to get a valid state for ATTACKERSDOMAIN.COM
    Note right of A: Add ATTACKERSDOMAIN.COM as the domain for the private pages project
    Note right of A: But not setup DNS for it
    A->Pages: ATTACKERSDOMAIN.com/index
    Pages->A: not authorized, redirect to projects.gitlab.io/auth?state=JWT({random=random, domain=ATTACKERSDOMAIN.com})
    Note right of A: Let's call JWT({random=random, domain=ATTACKERSDOMAIN.com}) ATTACKERSTATE
    A->U: can you please visit projects.gitlab.io/auth?state=ATTACKERSTATE
    U->>P: projects.gitlab.io/auth?state=ATTACKERSTATE
    Note over P,G: OAuth workflow
    rect rgb(0, 255, 0)
    Note over P,G: use the domain from state, wich is ATTACKERSDOMAIN.COM
    P->>U: redirect to ATTACKERSDOMAIN.COM?state=ATTACKERSTATE&code=mycode
    U->>AD: ?state=ATTACKERSTATE&code=mycode
    end
    AD->>A: code=mycode
    Note right of A: Need to get a valid state in cookie:
    A->>D: get index.html
    D->>A: unauthorized, redirect to projects.gitlab.io/auth?state=USERSTATE(JWT({random=random, domain=mygroup.gitlab.io}))
    Note right of A: skip the auth, just go the last step manually:
    A->>D: /auth?state=USERSTATE&code=mycode
    Note over D,G: Exchange code for token and verify that user has permissions for the mygroup.gitlab.io
    D->>A: success
```

### The "~~Signed~~ Encrypted code" fix

My current idea is to sign the code instead of state when we redirect back to user domain

```mermaid
sequenceDiagram
    participant A as Attacker
    participant U as User
    participant D as mygroup.gitlab.io
    participant P as projects.gitlab.io
    participant G as gitlab.com
    participant AD as ATTACKERSDOMAIN.com
    A->U: can you please visit projects.gitlab.io/auth?domain=ATTACKERSDOMAIN.COM&state=IRRELEVANT
    U->>P: projects.gitlab.io/auth?domain=ATTACKERSDOMAIN.COM&state=IRRELEVANT
    Note over P,G: domain=ATTACKERSDOMAIN.COM is being saved to the session cookie
    Note over P,G: OAuth workflow
    P->>U: redirect to ATTACKERSDOMAIN.COM?state=IRRELEVANT&securecode=ENCRYPTED({domain=ATTACKERSDOMAIN.COM, code=mycode})
    U->>AD: ?state=IRRELEVANT&securecode=ENCRYPTED({domain=ATTACKERSDOMAIN.COM, code=mycode})
    AD->>A: securecode=ENCRYPTED({domain=ATTACKERSDOMAIN.COM, code=mycode})
    Note right of A: Need to get a valid state in cookie:
    A->>D: get index.html
    D->>A: unauthorized, redirect to projects.gitlab.io/auth?domain=mygroup.gitlab.io&state=USERSTATE
    Note right of A: skip the auth, just go the last step manually:
    A->>D: /auth?state=USERSTATE&securecode=ENCRYPTED({domain=ATTACKERSDOMAIN.COM, code=mycode})
    Note right of D: decrypt code, and get domain=ATTACKERSDOMAIN.COM, and code=mycode
    Note right of D: check if ATTACKERSDOMAIN.COM==mygroup.gitlab.io
    Note right of D: it's not equal, so user was tricked into clicking this,
    D->>A: failure
```

**I can't say if there is a way to bypass this check. I would really appreciate if everyone who read that far tries to break it** :wink: 

</details>

Story points: 7.0