Repository navigation
Description
Contact Details
What happened?
Severity: Medium
CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N → 5.3
CWE: CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-290 (Authentication Bypass by Spoofing)
Affected versions: All versions (master @ ab67c6d)
Patched versions: None
Affected products: worknenjoy/gitpay — backend (src/)
Summary
The private-issue import flow passes the target user id through the OAuth redirect_uri query string and trusts it on the callback. Nothing binds the GitHub identity that completed the OAuth flow to that userId. An unauthenticated attacker can start the flow with userId set to any GitPay user, authorize with their own GitHub account, and create a private: true task attributed to the victim.
Affected code
src/app/routes/auth/providers.ts:47,51
47: router.get('/callback/github/private', createPrivateTask) // public
51: router.get('/authorize/github/private', authorizeGithubPrivateIssue) // public
src/app/controllers/auth/auth.ts (authorizeGithubPrivateIssue) builds:
.../callback/github/private?userId=${params.userId}&url=${params.url}
src/app/controllers/user/user.ts:161-195 (createPrivateTask) reads userId from req.query and calls task.taskBuilds({ provider:'github', private:true, userId, url, token }).
Note: taskBuilds ignores the token it receives — GithubConnect (src/client/provider/github.ts) authenticates only with the application's client_id/client_secret. The task row is still created under the victim's account; the issue fetch works for public issues rather than genuinely private ones.
Proof of concept
Runnable PoC: poc/11-private-task-userid-spoof.js
Setup: victim GitPay user id V (public via GET /users?username=...).
As an unauthenticated attacker, open:
GET /authorize/github/private?userId=&url=https://github.com/owner/repo/issues/1
GitPay redirects to GitHub with scope=repo and a redirect_uri containing userId=. Authorize with the attacker's own GitHub account.
GitHub redirects to /callback/github/private?code=...&userId=&url=...; createPrivateTask exchanges the code and calls taskBuilds with userId=.
A Task is created with userId = V and private = true. Because taskFetch returns early for private tasks, it does not appear in the normal public listing path.
Expected: userId must come from the authenticated session or a signed state value, and the OAuth identity must be matched to that account.
Actual: arbitrary userId accepted from the query string.
Impact
Unauthenticated task injection under an arbitrary user's account, with attacker-chosen issue URL and private: true. This pollutes the victim's account, can be used to stage later payment/assignment attacks, and bypasses the public task path.
Workarounds / Remediation
Carry userId in a signed state parameter (HMAC) and verify it on callback; or require an authenticated session and use req.user.id.
After the OAuth exchange, verify that the GitHub account belongs to the target user before creating the task.
Validate url with the existing parseAndValidateIssueUrl (already applied in taskBuilds).
References
gitpay/src/app/routes/auth/providers.ts
Lines 47 to 51 in ab67c6d
| router.get('/callback/github/private', createPrivateTask) | |
| router.get('/connect/github', secure, connectGithub) | |
| router.get('/authorize/github/private', authorizeGithubPrivateIssue) |
gitpay/src/app/controllers/user/user.ts
Lines 161 to 195 in ab67c6d
| export const createPrivateTask = async (req: any, res: any) => { | |
| const { url, code, userId } = req.query | |
| const githubClientId = secrets.github.id | |
| const githubClientSecret = secrets.github.secret | |
| const redirectPrivateTaskError = (message?: string) => { | |
| const encodedError = encodeURIComponent(message || 'We could not import the issue.') | |
| return res.redirect( | |
| `${process.env.FRONTEND_HOST}/#/profile?createTaskError=true&message=${encodedError}` | |
| ) | |
| } | |
| try { | |
| const response = await requestPromise({ | |
| method: 'POST', | |
| uri: 'https://github.com/login/oauth/access_token/', | |
| headers: { | |
| 'User-Agent': 'octonode/0.3 (https://github.com/pksunkara/octonode) terminal/0.0', | |
| Authorization: | |
| 'Basic ' + Buffer.from(`${githubClientId}:${githubClientSecret}`).toString('base64') | |
| }, | |
| body: { | |
| code | |
| }, | |
| json: true | |
| }) | |
| if (response.access_token) { | |
| try { | |
| const taskResult = await task.taskBuilds({ | |
| provider: 'github', | |
| private: true, | |
| userId, | |
| url, | |
| token: response.access_token | |
| }) | |
| res.redirect(`${process.env.FRONTEND_HOST}/#/task/${taskResult.id}`) | |
| // return res.send(data) |
https://github.com/worknenjoy/gitpay/blob/ab67c6d741a7245ec200636c1b81c7bcc468d677/src/app/controllers/auth/auth.ts
https://github.com/worknenjoy/gitpay/blob/ab67c6d741a7245ec200636c1b81c7bcc468d677/src/client/provider/github.ts
regards
Alwoares
11-private-task-userid-spoof.js
What browsers are you seeing the problem on?
Chrome
Relevant log output
💰 Is there a bounty for this issue?
Yes
💵 Bounty Amount (if any)
200$
📜 Bounty Details (if applicable)
No response
Activity
fangjian-bit commentedon Oct 26, 2026
Contributor details
Proposal
Please re-state the problem that we are trying to solve in this issue.
In
worknenjoy/gitpay, the private issue import flow suffers from an Authentication & Authorization Bypass vulnerability (CWE-639: Authorization Bypass Through User-Controlled Key / IDOR, CWE-290: Authentication Bypass by Spoofing, and CWE-352: Missing OAuth State Parameter).Currently, when a user initiates the private task creation flow:
GET /authorize/github/privateis a public, unauthenticated endpoint that accepts arbitraryuserIdandurlquery parameters and directly interpolates them into the OAuthredirect_uristring:${process.env.API_HOST}/callback/github/private?userId=${params.userId}&url=${params.url}.https://github.com/login/oauth/authorize) without requiring an authenticated session and without attaching a cryptographically signed OAuthstateparameter.GET /callback/github/privateis also completely unauthenticated. It blindly readsuserIdandurlfromreq.query, exchanges the returned OAuth authorizationcodefor an access token, and immediately callstask.taskBuilds({ provider: 'github', private: true, userId, url, token: response.access_token }).userId.userIdin the callback has not been tampered with or substituted.userId.Impact & Exploitability:
An unauthenticated attacker can discover any victim's GitPay user ID (which is publicly exposed via
GET /users?username=...), triggerGET /authorize/github/private?userId=<victim_id>&url=<target_issue_url>, authorize with the attacker's own GitHub account, and inject an arbitrary private task attributed to the victim. Alternatively, an attacker can bypass the authorization endpoint altogether, obtain a GitHub OAuth code with their own account, and invokeGET /callback/github/private?userId=<victim_id>&url=...&code=<attacker_code>.Because tasks marked
private: trueare filtered out of public listings intaskSearch.tsand return early intaskFetch.ts, the victim cannot detect the injected task on public feeds, yet the task is bound to their account. This allows attackers to pollute victim accounts, stage unauthorized payment captures or order assignments, and trigger unwanted email notifications.Expected Behavior:
userIdmust never be trusted from an unverified query string. The authorization endpoint must require an authenticated session/token, the OAuth flow must bind and integrity-protect parameters via a signed HMAC/JWTstateparameter, and the callback handler must verify thestatesignature and confirm that the authenticated GitHub account matches the target GitPay user before creating any task.What is the root cause of that problem?
The vulnerability originates from three interconnected design flaws across routing, authorization, and OAuth callback handling:
1. Insecure Public Route & Dynamic Parameter Injection into
redirect_uriinsrc/app/controllers/auth/auth.tsIn
src/app/routes/auth/providers.ts:51, the route is mounted publicly without thesecureauthentication middleware:In
src/app/controllers/auth/auth.ts:params.userIdfrom the unauthenticated query string.userIdandurl) directly into theredirect_uriparameter rather than using a cryptographically signed OAuthstateparameter.stateparameter to guarantee integrity and prevent CSRF and parameter tampering.2. Unauthenticated Callback & Blind Trust of
req.query.userIdinsrc/app/controllers/user/user.tsIn
src/app/routes/auth/providers.ts:47:In
src/app/controllers/user/user.ts:createPrivateTaskdirectly consumesreq.query.userIdwithout checking any session, JWT token, or OAuthstate.code(generated by any GitHub user) can call/callback/github/privatewith any targetuserId.3. Complete Absence of GitHub Identity Binding to GitPay Account
codefor anaccess_token,createPrivateTasknever querieshttps://api.github.com/userto ascertain who authorized the OAuth token.targetUser.provider_idortargetUser.provider_username.What changes do you think we should make in order to solve the problem?
We will implement a defense-in-depth fix comprising three complementary layers:
Protect
/authorize/github/privatewith Authentication and Cryptographically Signstate:securemiddleware to/authorize/github/privateinsrc/app/routes/auth/providers.ts.authorizeGithubPrivateIssue, deriveuserIdstrictly fromreq.user.id.urlwithparseAndValidateIssueUrl(url, 'github')upfront.statetoken (15-minute expiration) signed withprocess.env.SECRET_PHRASEcontaining{ userId: req.user.id, url }.redirect_uri(${process.env.API_HOST}/callback/github/private) and pass the signed token in thestateparameter.Verify Signed
stateand Enforce GitHub Account Identity Binding increatePrivateTask:src/app/controllers/user/user.ts, requirereq.query.stateand verify it usingjwt.verify(state, process.env.SECRET_PHRASE). Extract verifieduserIdandurl. Ignore unverifiedreq.query.userId.access_token, queryhttps://api.github.com/userto obtain the authenticated GitHub profile (ghUser.id,ghUser.login).targetUser = await models.User.findByPk(userId):targetUser.provider === 'github'andtargetUser.provider_id), verify thatString(targetUser.provider_id) === String(ghUser.id). If mismatched, reject withGitHub identity mismatch.ghUser.idis not bound to a different GitPay account to prevent cross-account identity spoofing.task.taskBuildswith verified parameters.Update Frontend Redirect in
import-issue-dialog.tsx:import-issue-dialog.tsxto include&token=${user.data.token}inwindow.location.hrefso thesecuremiddleware can authenticate the browser navigation request.Code Diffs:
What alternative solutions did you explore? (Optional)
Server-Side Session / Redis Cache for Nonce & State Lookup:
{ [nonce]: { userId, url } }into Redis or an Express session with a 15-minute TTL, and passstate=nonce.process.env.SECRET_PHRASE). Introducing Redis or session stores for a single OAuth route introduces unnecessary infrastructure dependencies and deployment overhead. Signed JWTs achieve identical tamper-proof cryptographic guarantees, expiration management, and replay defense without external state stores.Requiring Bearer Token Header on
/callback/github/privateDirectly:securemiddleware on/callback/github/privateand readreq.user.id.Authorization: Bearer <token>) during external redirects fromgithub.com. Using the standard OAuthstateparameter solves this within standard protocol constraints.Permitting Arbitrary GitHub Accounts Without Identity Binding:
{ userId, url }instate, but allow any GitHub account to complete the OAuth exchange.https://api.github.com/userand checkingtargetUser.provider_idprovides complete end-to-end identity binding.What automated tests can you add to prevent this from re-occurring?
Add a comprehensive security test suite in
test/api/task/privateTaskSecurity.test.tsusing Supertest, Mocha, Chai, and Nock to verify unauthenticated rejection, state signature enforcement, identity binding, and non-regression:fix: resolve worknenjoy#1626 with verified patch
fangjian-bit commentedon Oct 27, 2026
Hi maintainers @worknenjoy! I have submitted production-grade PR #1636 to fix this issue cleanly.
fix(security): verify authenticated session userId against OAuth call…
newworldba commentedon Nov 8, 2026
PR ready: #1629. It replaces caller-controlled OAuth userId with a signed, short-lived state token, verifies GitHub identity against the linked GitPay account, and includes focused tests for missing/tampered state and identity mismatch.
fix(auth): bind GitHub private-issue OAuth state and verify identity (w…
alfredoalvarado53-ui commentedon Nov 10, 2026
Submitted fix in #1661.
The fix protects
/authorize/github/privatewithsecure, signs(userId, url, nonce, exp)in an HMAC-SHA256stateparameter derived exclusively fromreq.user.id, verifies thestateandparseAndValidateIssueUrlincreatePrivateTask, and confirms the GitHub OAuth account owns the target GitPay account before creating the task.Payout addresses:
0xa554a9f6b26e6a418df3623b41e222e18a3b8da64s7cT9bzkQAJrUz18BWGePbhQA1C5YMFJmhrVLB9Fpar