strict redirection uri
This commit is contained in:
		| @@ -101,6 +101,7 @@ | ||||
| 		"got": "13.0.0", | ||||
| 		"happy-dom": "9.20.3", | ||||
| 		"hpagent": "1.2.0", | ||||
| 		"http-link-header": "^1.1.0", | ||||
| 		"ioredis": "5.3.2", | ||||
| 		"ip-cidr": "3.1.0", | ||||
| 		"ipaddr.js": "2.1.0", | ||||
| @@ -180,6 +181,7 @@ | ||||
| 		"@types/escape-regexp": "0.0.1", | ||||
| 		"@types/express-session": "^1.17.6", | ||||
| 		"@types/fluent-ffmpeg": "2.1.21", | ||||
| 		"@types/http-link-header": "^1.0.3", | ||||
| 		"@types/jest": "29.5.2", | ||||
| 		"@types/js-yaml": "4.0.5", | ||||
| 		"@types/jsdom": "21.1.1", | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import dns from 'node:dns/promises'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import fastifyMiddie, { IncomingMessageExtended } from '@fastify/middie'; | ||||
| import { JSDOM } from 'jsdom'; | ||||
| import parseLinkHeader from 'parse-link-header'; | ||||
| import httpLinkHeader from 'http-link-header'; | ||||
| import ipaddr from 'ipaddr.js'; | ||||
| import oauth2orize, { type OAuth2 } from 'oauth2orize'; | ||||
| import * as oauth2Query from 'oauth2orize/lib/response/query.js'; | ||||
| @@ -103,18 +103,33 @@ function validateClientId(raw: string): URL { | ||||
| // 	return `uid:${uid}`; | ||||
| // } | ||||
|  | ||||
| async function fetchFromClientId(httpRequestService: HttpRequestService, id: string): Promise<string | void> { | ||||
| interface ClientInformation { | ||||
| 	id: string; | ||||
| 	redirectUris: string[]; | ||||
| 	name: string; | ||||
| } | ||||
|  | ||||
| async function discoverClientInformation(httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> { | ||||
| 	try { | ||||
| 		const res = await httpRequestService.send(id); | ||||
| 		let redirectUri = parseLinkHeader(res.headers.get('link'))?.redirect_uri?.url; | ||||
| 		if (redirectUri) { | ||||
| 			return new URL(redirectUri, res.url).toString(); | ||||
| 		const redirectUris: string[] = []; | ||||
|  | ||||
| 		const linkHeader = res.headers.get('link'); | ||||
| 		if (linkHeader) { | ||||
| 			redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri)); | ||||
| 		} | ||||
|  | ||||
| 		redirectUri = JSDOM.fragment(await res.text()).querySelector<HTMLLinkElement>('link[rel=redirect_uri][href]')?.href; | ||||
| 		if (redirectUri) { | ||||
| 			return new URL(redirectUri, res.url).toString(); | ||||
| 		} | ||||
| 		const fragment = JSDOM.fragment(await res.text()); | ||||
|  | ||||
| 		redirectUris.push(...[...fragment.querySelectorAll<HTMLLinkElement>('link[rel=redirect_uri][href]')].map(el => el.href)); | ||||
|  | ||||
| 		const name = fragment.querySelector<HTMLElement>('.h-app .p-name')?.textContent?.trim() ?? id; | ||||
|  | ||||
| 		return { | ||||
| 			id, | ||||
| 			redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()), | ||||
| 			name, | ||||
| 		}; | ||||
| 	} catch { | ||||
| 		throw new Error('Failed to fetch client information'); | ||||
| 	} | ||||
| @@ -267,7 +282,7 @@ async function fetchFromClientId(httpRequestService: HttpRequestService, id: str | ||||
| // 	}; | ||||
| // } | ||||
|  | ||||
| function pkceS256(codeVerifier: string) { | ||||
| function pkceS256(codeVerifier: string): string { | ||||
| 	return crypto.createHash('sha256') | ||||
| 		.update(codeVerifier, 'ascii') | ||||
| 		.digest('base64url'); | ||||
| @@ -362,7 +377,7 @@ export class OAuth2ProviderService { | ||||
| 				} | ||||
|  | ||||
| 				TEMP_GRANT_CODES[code] = { | ||||
| 					clientId: client, | ||||
| 					clientId: client.id, | ||||
| 					userId: user.id, | ||||
| 					redirectUri, | ||||
| 					codeChallenge: areq.codeChallenge, | ||||
| @@ -470,7 +485,7 @@ export class OAuth2ProviderService { | ||||
| 			reply.header('Cache-Control', 'no-store'); | ||||
| 			return await reply.view('oauth', { | ||||
| 				transactionId: oauth2.transactionID, | ||||
| 				clientId: oauth2.client, | ||||
| 				clientName: oauth2.client.name, | ||||
| 				scope: scopes.join(' '), | ||||
| 			}); | ||||
| 		}); | ||||
| @@ -494,18 +509,22 @@ export class OAuth2ProviderService { | ||||
| 			(async (): Promise<OmitFirstElement<Parameters<typeof done>>> => { | ||||
| 				console.log('HIT /oauth/authorize validation middleware'); | ||||
|  | ||||
| 				// Find client information from the remote. | ||||
| 				const clientUrl = validateClientId(clientId); | ||||
| 				const redirectUrl = new URL(redirectUri); | ||||
|  | ||||
| 				// https://indieauth.spec.indieweb.org/#authorization-request | ||||
| 				// Allow same-origin redirection | ||||
| 				if (redirectUrl.protocol !== clientUrl.protocol || redirectUrl.host !== clientUrl.host) { | ||||
| 					// TODO: allow only explicit redirect_uri by Client Information Discovery | ||||
| 					throw new Error('cross-origin redirect_uri is not supported yet.'); | ||||
| 				if (process.env.NODE_ENV !== 'test') { | ||||
| 					const lookup = await dns.lookup(clientUrl.hostname); | ||||
| 					if (ipaddr.parse(lookup.address).range() === 'loopback') { | ||||
| 						throw new Error('client_id unexpectedly resolves to loopback IP.'); | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				return [clientId, redirectUri]; | ||||
| 				// Find client information from the remote. | ||||
| 				const clientInfo = await discoverClientInformation(this.httpRequestService, clientUrl.href); | ||||
| 				if (!clientInfo.redirectUris.includes(redirectUri)) { | ||||
| 					throw new Error('Invalid redirect_uri'); | ||||
| 				} | ||||
|  | ||||
| 				return [clientInfo, redirectUri]; | ||||
| 			})().then(args => done(null, ...args), err => done(err)); | ||||
| 		})); | ||||
| 		// for (const middleware of this.#server.decision()) { | ||||
|   | ||||
| @@ -5,5 +5,5 @@ block meta | ||||
| 	//- user navigates away via the navigation bar | ||||
| 	//- XXX: Remove navigation bar in auth page? | ||||
| 	meta(name='misskey:oauth:transaction-id' content=transactionId) | ||||
| 	meta(name='misskey:oauth:client-id' content=clientId) | ||||
| 	meta(name='misskey:oauth:client-name' content=clientName) | ||||
| 	meta(name='misskey:oauth:scope' content=scope) | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| process.env.NODE_ENV = 'test'; | ||||
|  | ||||
| import * as assert from 'assert'; | ||||
| import { port, relativeFetch, signup, startServer } from '../utils.js'; | ||||
| import type { INestApplicationContext } from '@nestjs/common'; | ||||
| import { AuthorizationCode } from 'simple-oauth2'; | ||||
| import pkceChallenge from 'pkce-challenge'; | ||||
| import { JSDOM } from 'jsdom'; | ||||
| import { port, relativeFetch, signup, startServer } from '../utils.js'; | ||||
| import type { INestApplicationContext } from '@nestjs/common'; | ||||
| import Fastify, { type FastifyInstance } from 'fastify'; | ||||
|  | ||||
| const host = `http://127.0.0.1:${port}`; | ||||
|  | ||||
| @@ -28,9 +29,12 @@ function getClient(): AuthorizationCode<'client_id'> { | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function getTransactionId(html: string): string | undefined { | ||||
| function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } | undefined { | ||||
| 	const fragment = JSDOM.fragment(html); | ||||
| 	return fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]')?.content; | ||||
| 	return { | ||||
| 		transactionId: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]')?.content, | ||||
| 		clientName: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| function fetchDecision(cookie: string, transactionId: string, user: any, { cancel }: { cancel?: boolean } = {}): Promise<Response> { | ||||
| @@ -51,24 +55,35 @@ function fetchDecision(cookie: string, transactionId: string, user: any, { cance | ||||
|  | ||||
| async function fetchDecisionFromResponse(response: Response, user: any, { cancel }: { cancel?: boolean } = {}): Promise<Response> { | ||||
| 	const cookie = response.headers.get('set-cookie'); | ||||
| 	const transactionId = getTransactionId(await response.text()); | ||||
| 	const { transactionId } = getMeta(await response.text()); | ||||
|  | ||||
| 	return await fetchDecision(cookie!, transactionId!, user, { cancel }); | ||||
| } | ||||
|  | ||||
| describe('OAuth', () => { | ||||
| 	let app: INestApplicationContext; | ||||
| 	let fastify: FastifyInstance; | ||||
|  | ||||
| 	let alice: any; | ||||
|  | ||||
| 	beforeAll(async () => { | ||||
| 		app = await startServer(); | ||||
| 		fastify = Fastify(); | ||||
| 		fastify.get('/', async (request, reply) => { | ||||
| 			reply.send(` | ||||
| 				<!DOCTYPE html> | ||||
| 				<link rel="redirect_uri" href="/redirect" /> | ||||
| 				<div class="h-app"><div class="p-name">Misklient | ||||
| 			`); | ||||
| 		}); | ||||
| 		fastify.listen({ port: clientPort, host: '0.0.0.0' }); | ||||
|  | ||||
| 		alice = await signup({ username: 'alice' }); | ||||
| 		// fastify = Fastify(); | ||||
| 	}, 1000 * 60 * 2); | ||||
|  | ||||
| 	afterAll(async () => { | ||||
| 		await app.close(); | ||||
| 		await fastify.close(); | ||||
| 	}); | ||||
|  | ||||
| 	test('Full flow', async () => { | ||||
| @@ -87,10 +102,11 @@ describe('OAuth', () => { | ||||
| 		const cookie = response.headers.get('set-cookie'); | ||||
| 		assert.ok(cookie?.startsWith('connect.sid=')); | ||||
|  | ||||
| 		const transactionId = getTransactionId(await response.text()); | ||||
| 		assert.strictEqual(typeof transactionId, 'string'); | ||||
| 		const meta = getMeta(await response.text()); | ||||
| 		assert.strictEqual(typeof meta.transactionId, 'string'); | ||||
| 		assert.strictEqual(meta?.clientName, 'Misklient'); | ||||
|  | ||||
| 		const decisionResponse = await fetchDecision(cookie!, transactionId!, alice); | ||||
| 		const decisionResponse = await fetchDecision(cookie!, meta.transactionId!, alice); | ||||
| 		assert.strictEqual(decisionResponse.status, 302); | ||||
| 		assert.ok(decisionResponse.headers.has('location')); | ||||
|  | ||||
| @@ -468,6 +484,20 @@ describe('OAuth', () => { | ||||
| 			assert.strictEqual(response.status, 500); | ||||
| 		}); | ||||
|  | ||||
| 		test('Invalid redirect_uri including the valid one at authorization endpoint', async () => { | ||||
| 			const client = getClient(); | ||||
|  | ||||
| 			const response = await fetch(client.authorizeURL({ | ||||
| 				redirect_uri: 'http://127.0.0.1/redirection', | ||||
| 				scope: 'write:notes', | ||||
| 				state: 'state', | ||||
| 				code_challenge: 'code', | ||||
| 				code_challenge_method: 'S256', | ||||
| 			})); | ||||
| 			// TODO: status code | ||||
| 			assert.strictEqual(response.status, 500); | ||||
| 		}); | ||||
|  | ||||
| 		test('No redirect_uri at authorization endpoint', async () => { | ||||
| 			const client = getClient(); | ||||
|  | ||||
| @@ -508,6 +538,33 @@ describe('OAuth', () => { | ||||
| 			})); | ||||
| 		}); | ||||
|  | ||||
| 		test('Invalid redirect_uri including the valid one at token endpoint', async () => { | ||||
| 			const { code_challenge, code_verifier } = pkceChallenge.default(128); | ||||
|  | ||||
| 			const client = getClient(); | ||||
|  | ||||
| 			const response = await fetch(client.authorizeURL({ | ||||
| 				redirect_uri, | ||||
| 				scope: 'write:notes', | ||||
| 				state: 'state', | ||||
| 				code_challenge, | ||||
| 				code_challenge_method: 'S256', | ||||
| 			})); | ||||
| 			assert.strictEqual(response.status, 200); | ||||
|  | ||||
| 			const decisionResponse = await fetchDecisionFromResponse(response, alice); | ||||
| 			assert.strictEqual(decisionResponse.status, 302); | ||||
|  | ||||
| 			const location = new URL(decisionResponse.headers.get('location')!); | ||||
| 			assert.ok(location.searchParams.has('code')); | ||||
|  | ||||
| 			await assert.rejects(client.getToken({ | ||||
| 				code: location.searchParams.get('code')!, | ||||
| 				redirect_uri: 'http://127.0.0.1/redirection', | ||||
| 				code_verifier, | ||||
| 			})); | ||||
| 		}); | ||||
|  | ||||
| 		test('No redirect_uri at token endpoint', async () => { | ||||
| 			const { code_challenge, code_verifier } = pkceChallenge.default(128); | ||||
|  | ||||
| @@ -533,8 +590,6 @@ describe('OAuth', () => { | ||||
| 				code_verifier, | ||||
| 			})); | ||||
| 		}); | ||||
|  | ||||
| 		// TODO: disallow random same-origin URLs with strict redirect_uris with client information discovery | ||||
| 	}); | ||||
|  | ||||
| 	test('Server metadata', async () => { | ||||
| @@ -550,5 +605,5 @@ describe('OAuth', () => { | ||||
|  | ||||
| 	// TODO: Error format required by OAuth spec | ||||
|  | ||||
| 	// TODO: Client Information Discovery | ||||
| 	// TODO: Client Information Discovery (use http header, loopback check, missing name or redirection uri) | ||||
| }); | ||||
|   | ||||
| @@ -40,7 +40,7 @@ if (transactionIdMeta) { | ||||
| 	transactionIdMeta.remove(); | ||||
| } | ||||
|  | ||||
| const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-id"]')?.content; | ||||
| const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content; | ||||
| const _permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ') ?? []; | ||||
|  | ||||
| function onLogin(res): void { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Kagami Sascha Rosylight
					Kagami Sascha Rosylight