Visitar URL original
fix(authentication-oauth): only link accounts from an allowed origin · feathersjs/feathers@1c29862 · GitHub
Skip to content

Commit 1c29862

Browse files
committed
fix(authentication-oauth): only link accounts from an allowed origin
1 parent 3673cde commit 1c29862

3 files changed

Lines changed: 156 additions & 1 deletion

File tree

‎packages/authentication-oauth/src/service.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,14 @@ export class OAuthService {
220220
}
221221

222222
if (feathers_token) {
223-
debug('Got feathers_token query parameter to link accounts', feathers_token)
223+
debug('Got feathers_token query parameter to link accounts')
224+
const strategy = this.service.getStrategy(params.route.provider) as OAuthStrategy
225+
226+
if (typeof strategy?.validateLink !== 'function') {
227+
throw new NotAuthenticated('Account linking is not supported for this provider.')
228+
}
229+
230+
await strategy.validateLink(params)
224231
session.accessToken = feathers_token
225232
}
226233

‎packages/authentication-oauth/src/strategy.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,29 @@ export class OAuthStrategy extends AuthenticationBaseStrategy {
191191
return redirect
192192
}
193193

194+
/**
195+
* Account linking with a `feathers_token` must be started from the app, since anyone can send a
196+
* link with their own token. Override to allow linking from clients that send no referer.
197+
*
198+
* @throws NotAuthenticated if the referer is missing or not an allowed origin
199+
*/
200+
async validateLink(params: Params) {
201+
const { redirect, origins = this.app.get('origins') } = this.authentication.configuration.oauth
202+
const { referer, host } = params.headers || {}
203+
const allowed = Array.isArray(origins) ? origins : redirect ? [redirect] : []
204+
let url: URL
205+
206+
try {
207+
url = new URL(referer)
208+
} catch {
209+
throw new NotAuthenticated('Account linking must be started from an allowed origin.')
210+
}
211+
212+
if (url.host !== host && !allowed.some((origin: string) => isOriginAllowed(url.origin, origin))) {
213+
throw new NotAuthenticated('Account linking must be started from an allowed origin.')
214+
}
215+
}
216+
194217
async getRedirect(
195218
data: AuthenticationResult | Error,
196219
params?: AuthenticationParams
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { strict as assert } from 'assert'
2+
import axios from 'axios'
3+
import { CookieJar } from 'tough-cookie'
4+
import { expressFixture } from './utils/fixture'
5+
6+
describe('@feathersjs/authentication-oauth account linking', () => {
7+
const port = 9786
8+
const host = `http://localhost:${port}`
9+
const message = 'Account linking must be started from an allowed origin.'
10+
const req = axios.create({
11+
maxRedirects: 0,
12+
validateStatus: () => true
13+
})
14+
let app: Awaited<ReturnType<typeof expressFixture>>
15+
16+
const createUser = async (email: string) => {
17+
const user = await app.service('users').create({ email })
18+
const token = await app.service('authentication').createAccessToken({}, { subject: `${user.id}` })
19+
20+
return { user, token }
21+
}
22+
23+
const linkFlow = async (token: string | undefined, headers: Record<string, string> = {}) => {
24+
const query = token ? `?feathers_token=${token}` : ''
25+
const response = await req.get(`${host}/oauth/gitlab${query}`, { headers })
26+
27+
if (response.status !== 303) {
28+
return response
29+
}
30+
31+
const cookie = new CookieJar()
32+
response.headers['set-cookie']?.forEach((value) => cookie.setCookie(value, host))
33+
34+
const provider = await req.get(response.data.location)
35+
36+
return req.get(provider.headers.location, { headers: { cookie: await cookie.getCookieString(host) } })
37+
}
38+
39+
before(async () => {
40+
app = await expressFixture(port, 5124)
41+
})
42+
43+
after(async () => {
44+
await app.teardown()
45+
})
46+
47+
afterEach(async () => {
48+
const users = (await app.service('users').find({ paginate: false })) as any[]
49+
50+
for (const user of users) {
51+
await app.service('users').remove(user.id)
52+
}
53+
})
54+
55+
it('rejects a feathers_token link without a referer', async () => {
56+
const { user: attacker, token } = await createUser('attacker@example.com')
57+
const response = await linkFlow(token)
58+
59+
assert.equal(response.status, 401)
60+
assert.equal(response.data.message, message)
61+
assert.equal((await app.service('users').get(attacker.id)).gitlabId, undefined)
62+
})
63+
64+
it('rejects a feathers_token link from another origin', async () => {
65+
const { user: attacker, token } = await createUser('attacker@example.com')
66+
const response = await linkFlow(token, { referer: 'https://evil.example/page' })
67+
68+
assert.equal(response.status, 401)
69+
assert.equal(response.data.message, message)
70+
assert.equal((await app.service('users').get(attacker.id)).gitlabId, undefined)
71+
})
72+
73+
it('links the account when started from the API host', async () => {
74+
const { user, token } = await createUser('user@example.com')
75+
const { data } = await linkFlow(token, { referer: `${host}/settings` })
76+
77+
assert.ok(data.accessToken)
78+
assert.equal(data.user.id, user.id)
79+
assert.equal(data.user.gitlabId, 'test')
80+
})
81+
82+
it('uses the configured origins', async () => {
83+
const oauth = app.get('authentication').oauth
84+
const { token } = await createUser('user@example.com')
85+
86+
oauth.origins = ['http://localhost:3000']
87+
88+
try {
89+
const allowed = await req.get(`${host}/oauth/gitlab?feathers_token=${token}`, {
90+
headers: { referer: 'http://localhost:3000/settings' }
91+
})
92+
const denied = await req.get(`${host}/oauth/gitlab?feathers_token=${token}`, {
93+
headers: { referer: 'https://evil.example/' }
94+
})
95+
96+
assert.equal(allowed.status, 303)
97+
assert.equal(denied.status, 401)
98+
} finally {
99+
delete oauth.origins
100+
}
101+
})
102+
103+
it('sign in without feathers_token still works without a referer', async () => {
104+
const { data } = await linkFlow(undefined)
105+
106+
assert.ok(data.accessToken)
107+
})
108+
109+
it('validateLink can be overridden to allow clients without a referer', async () => {
110+
const strategy = app.service('authentication').getStrategy('gitlab') as any
111+
const original = strategy.validateLink
112+
const { user, token } = await createUser('user@example.com')
113+
114+
strategy.validateLink = async () => {}
115+
116+
try {
117+
const { data } = await linkFlow(token)
118+
119+
assert.equal(data.user.id, user.id)
120+
assert.equal(data.user.gitlabId, 'test')
121+
} finally {
122+
strategy.validateLink = original
123+
}
124+
})
125+
})

0 commit comments

Comments
 (0)