fix(gist-export): validate OAuth2 state and redirect URI

The state of the OAuth2 response was not verified.
In theory an attacker could use this to extract the contents of a note.

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-06-17 21:50:50 +02:00
parent dd40a6c04b
commit fbd7307f16
2 changed files with 65 additions and 57 deletions

View File

@@ -65,69 +65,73 @@ function githubActions (req, res, next) {
function githubActionGist (req, res, note) {
const code = req.query.code
const state = req.query.state
if (!code || !state) {
const sessionsState = req.session.githubGistState
const redirectUri = req.session.githubGistRedirectUri
delete req.session.githubGistState
delete req.session.githubGistRedirectUri
if (!code || !state || sessionsState !== state) {
return errors.errorForbidden(res)
} else {
const data = {
client_id: config.github.clientID,
client_secret: config.github.clientSecret,
code,
state
}
const data = {
client_id: config.github.clientID,
client_secret: config.github.clientSecret,
code,
redirect_uri: redirectUri
}
const authUrl = 'https://github.com/login/oauth/access_token'
fetch(authUrl, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
}
const authUrl = 'https://github.com/login/oauth/access_token'
fetch(authUrl, {
}).then(resp => {
if (!resp.ok) {
throw new Error('forbidden')
}
return resp.json()
}).then(body => {
const accessToken = body.access_token
if (!accessToken) {
throw new Error('forbidden')
}
const content = note.content
const title = models.Note.decodeTitle(note.title)
const filename = title.replace('/', ' ') + '.md'
const gist = {
files: {}
}
gist.files[filename] = {
content
}
const gistUrl = 'https://api.github.com/gists'
return fetch(gistUrl, {
method: 'POST',
body: JSON.stringify(data),
body: JSON.stringify(gist),
headers: {
'User-Agent': 'HedgeDoc',
Authorization: 'token ' + accessToken,
'Content-Type': 'application/json',
Accept: 'application/json'
}
}).then(resp => {
if (!resp.ok) {
throw new Error('forbidden')
}
return resp.json()
}).then(body => {
const accessToken = body.access_token
if (!accessToken) {
throw new Error('forbidden')
}
const content = note.content
const title = models.Note.decodeTitle(note.title)
const filename = title.replace('/', ' ') + '.md'
const gist = {
files: {}
}
gist.files[filename] = {
content
}
const gistUrl = 'https://api.github.com/gists'
return fetch(gistUrl, {
method: 'POST',
body: JSON.stringify(gist),
headers: {
'User-Agent': 'HedgeDoc',
Authorization: 'token ' + accessToken,
'Content-Type': 'application/json',
Accept: 'application/json'
}
})
}).then(resp => {
if (resp.status !== 201) {
throw new Error('forbidden')
}
return resp.json()
}).then(body => {
res.setHeader('referer', '')
res.redirect(body.html_url)
}).catch(error => {
if (error.message === 'forbidden') {
return errors.errorForbidden(res)
}
logger.error('GitHub Gist auth failed: ' + error)
return errors.errorInternalError(res)
})
}
}).then(resp => {
if (resp.status !== 201) {
throw new Error('forbidden')
}
return resp.json()
}).then(body => {
res.setHeader('referer', '')
res.redirect(body.html_url)
}).catch(error => {
if (error.message === 'forbidden') {
return errors.errorForbidden(res)
}
logger.error('GitHub Gist auth failed: ' + error)
return errors.errorInternalError(res)
})
}
function gitlabActions (req, res, next) {