diff --git a/CHANGELOG.md b/CHANGELOG.md index ed8bf0fca..f523c1e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [2.23.0] - Not released +### Added +- New API method `GET /v2/cors-proxy?url=...`. This method acts as a simple + proxy for web clients that need to make requests to other origins + (specifically, to oEmbed endpoints of media providers). The proxy is + deliberately limited: the valid request origins and URL prefixes are defined + in the server config (see the _corsProxy_ config entry). ## [2.22.4] - 2024-12-04 ### Changed diff --git a/app/controllers/api/v2/CorsProxyController.ts b/app/controllers/api/v2/CorsProxyController.ts new file mode 100644 index 000000000..db13f8b35 --- /dev/null +++ b/app/controllers/api/v2/CorsProxyController.ts @@ -0,0 +1,79 @@ +import { Readable } from 'stream'; +import { ReadableStream } from 'stream/web'; + +import { Context } from 'koa'; +import { Duration } from 'luxon'; + +import { BadRequestException, ValidationException } from '../../../support/exceptions'; +import { currentConfig } from '../../../support/app-async-context'; + +/** + * Headers of the remote server response that we want to pass to the client + */ +const headersToPass = ['Location', 'Content-Type', 'Content-Length']; + +const fallbackTimeoutMs = 1000; + +export async function proxy(ctx: Context) { + const { + timeout: timeoutString, + allowedOrigins, + allowedURlPrefixes, + allowLocalhostOrigins, + } = currentConfig().corsProxy; + + const { origin } = ctx.headers; + + if (typeof origin !== 'string') { + // If the client is hosted at the same origin as the server, the browser + // will not send the Origin header. The 'none' value is used to allow these + // types of requests. + if (!allowedOrigins.includes('none')) { + throw new BadRequestException('Missing origin'); + } + } else if ( + // Origin header is present, check it validity + !( + allowedOrigins.includes(origin) || + (allowLocalhostOrigins && /^https?:\/localhost(:\d+)?$/.test(origin)) + ) + ) { + throw new BadRequestException('Origin not allowed'); + } + + let { url } = ctx.request.query; + + if (Array.isArray(url)) { + // When there is more than one 'url' parameter, use the first one + [url] = url; + } + + if (typeof url !== 'string') { + throw new ValidationException("Missing 'url' parameter"); + } + + // Check if the URL has allowed prefix + if (!allowedURlPrefixes.some((prefix) => url.startsWith(prefix))) { + throw new ValidationException('URL not allowed'); + } + + const timeoutDuration = Duration.fromISO(timeoutString); + const timeoutMs = timeoutDuration.isValid ? timeoutDuration.toMillis() : fallbackTimeoutMs; + + // Perform the request with timeout + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + + // Copying to the client: + // 1. The response status code + ctx.status = response.status; + + // 2. Some of response headers (`headersToPass` list) + for (const header of headersToPass) { + if (response.headers.has(header)) { + ctx.set(header, response.headers.get(header)!); + } + } + + // 3. And the response body itself + ctx.body = response.body ? Readable.fromWeb(response.body as ReadableStream) : null; +} diff --git a/app/models/auth-tokens/app-tokens-scopes.ts b/app/models/auth-tokens/app-tokens-scopes.ts index bc5f92204..b92858418 100644 --- a/app/models/auth-tokens/app-tokens-scopes.ts +++ b/app/models/auth-tokens/app-tokens-scopes.ts @@ -52,6 +52,8 @@ export const alwaysDisallowedRoutes = [ 'POST /vN/users', // Email verification 'POST /vN/users/verifyEmail', + // CORS proxy + 'GET /vN/cors-proxy', ]; export const appTokensScopes = [ diff --git a/app/routes.js b/app/routes.js index d1b707ae7..81e3a497c 100644 --- a/app/routes.js +++ b/app/routes.js @@ -28,6 +28,7 @@ import InvitationsRoute from './routes/api/v2/InvitationsRoute'; import AppTokensRoute from './routes/api/v2/AppTokens'; import ServerInfoRoute from './routes/api/v2/ServerInfo'; import ExtAuthRoute from './routes/api/v2/ExtAuth'; +import CorsProxyRoute from './routes/api/v2/CorsProxyRoute'; import AdminCommonRoute from './routes/api/admin/CommonRoute'; import AdminAdminRoute from './routes/api/admin/AdminRoute'; import AdminModeratorRoute from './routes/api/admin/ModeratorRoute'; @@ -92,6 +93,7 @@ export function createRouter() { ServerInfoRoute(publicRouter); ExtAuthRoute(publicRouter); AttachmentsRouteV2(publicRouter); + CorsProxyRoute(publicRouter); const router = new Router(); router.use('/v([1-9]\\d*)', publicRouter.routes(), publicRouter.allowedMethods()); diff --git a/app/routes/api/v2/CorsProxyRoute.ts b/app/routes/api/v2/CorsProxyRoute.ts new file mode 100644 index 000000000..82142ec87 --- /dev/null +++ b/app/routes/api/v2/CorsProxyRoute.ts @@ -0,0 +1,7 @@ +import type Router from '@koa/router'; + +import { proxy } from '../../../controllers/api/v2/CorsProxyController'; + +export default function addRoutes(app: Router) { + app.get('/cors-proxy', proxy); +} diff --git a/config/default.js b/config/default.js index 4b01562eb..7d285d76c 100644 --- a/config/default.js +++ b/config/default.js @@ -511,4 +511,18 @@ config.foldingInPosts = { minOmittedLikes: 2, // Minimum number of omitted likes }; +config.corsProxy = { + // Timeout in ISO 8601 duration format + timeout: 'PT5S', + // The allowlist of request origins. 'none' is the special value that means + // 'no Origin header' (for the case when the client and the server are on the + // same host). + allowedOrigins: ['none'], + // Allow requests with any 'https?://localhost:*' origins (for local + // development). + allowLocalhostOrigins: true, + // The allowlist of proxied URL prefixes. + allowedURlPrefixes: [], +}; + module.exports = config; diff --git a/test/functional/cors-proxy.ts b/test/functional/cors-proxy.ts new file mode 100644 index 000000000..d195e6308 --- /dev/null +++ b/test/functional/cors-proxy.ts @@ -0,0 +1,80 @@ +import { after, before, describe, it } from 'mocha'; +import expect from 'unexpected'; +import { Context } from 'koa'; + +import { withModifiedConfig } from '../helpers/with-modified-config'; + +import { performJSONRequest, MockHTTPServer } from './functional_test_helper'; + +const server = new MockHTTPServer((ctx: Context) => { + const { + request: { url }, + } = ctx; + + if (url === '/example.txt') { + ctx.status = 200; + ctx.response.type = 'text/plain'; + ctx.body = 'Example text'; + } else { + ctx.status = 404; + ctx.response.type = 'text/plain'; + ctx.body = 'Not found'; + } +}); + +describe('CORS proxy', () => { + before(() => server.start()); + after(() => server.stop()); + + withModifiedConfig(() => ({ + corsProxy: { + allowedOrigins: ['none', 'http://localhost:3000'], + allowedURlPrefixes: [`${server.origin}/example`], + }, + })); + + it(`should return error if called without url`, async () => { + const resp = await performJSONRequest('GET', '/v2/cors-proxy'); + expect(resp, 'to equal', { __httpCode: 422, err: "Missing 'url' parameter" }); + }); + + it(`should return error if called with not allowed url`, async () => { + const url = `${server.origin}/index.html`; + const resp = await performJSONRequest('GET', `/v2/cors-proxy?url=${encodeURIComponent(url)}`); + expect(resp, 'to equal', { __httpCode: 422, err: 'URL not allowed' }); + }); + + it(`should return error if called with invalid origin`, async () => { + const url = `${server.origin}/example.txt`; + const resp = await performJSONRequest( + 'GET', + `/v2/cors-proxy?url=${encodeURIComponent(url)}`, + null, + { Origin: 'https://badorigin.net' }, + ); + expect(resp, 'to equal', { __httpCode: 400, err: 'Origin not allowed' }); + }); + + it(`should call with allowed url and without origin`, async () => { + const url = `${server.origin}/example.txt`; + const resp = await performJSONRequest('GET', `/v2/cors-proxy?url=${encodeURIComponent(url)}`); + expect(resp, 'to satisfy', { __httpCode: 200, textResponse: 'Example text' }); + }); + + it(`should call with allowed (but non-existing) url and without origin`, async () => { + const url = `${server.origin}/example.pdf`; + const resp = await performJSONRequest('GET', `/v2/cors-proxy?url=${encodeURIComponent(url)}`); + expect(resp, 'to satisfy', { __httpCode: 404, textResponse: 'Not found' }); + }); + + it(`should call with allowed url and origin`, async () => { + const url = `${server.origin}/example.txt`; + const resp = await performJSONRequest( + 'GET', + `/v2/cors-proxy?url=${encodeURIComponent(url)}`, + null, + { Origin: 'http://localhost:3000' }, + ); + expect(resp, 'to satisfy', { __httpCode: 200, textResponse: 'Example text' }); + }); +}); diff --git a/test/functional/functional_test_helper.d.ts b/test/functional/functional_test_helper.d.ts index 2d0dd847b..ba2e02699 100644 --- a/test/functional/functional_test_helper.d.ts +++ b/test/functional/functional_test_helper.d.ts @@ -1,3 +1,5 @@ +import { Context } from 'koa'; + import { Comment, Group, Post, User } from '../../app/models'; import { UUID } from '../../app/support/types'; @@ -50,3 +52,12 @@ export function justCreateGroup( ): Promise; export function justLikeComment(commentObj: Comment, userCtx: UserCtx): Promise; + +export class MockHTTPServer { + readonly port: number; + readonly origin: string; + + constructor(handler: (ctx: Context) => void, opts?: { timeout?: number }); + start(): Promise; + stop(): Promise; +} diff --git a/test/helpers/with-modified-config.ts b/test/helpers/with-modified-config.ts index 0c395c7b7..11823ea94 100644 --- a/test/helpers/with-modified-config.ts +++ b/test/helpers/with-modified-config.ts @@ -14,9 +14,13 @@ import { currentConfig, setExplicitConfig } from '../../app/support/app-async-co * integration or unit tests. In all test in the given 'describe' block, the * currentConfig() function will return the patched config. */ -export function withModifiedConfig(patch: DeepPartial) { +export function withModifiedConfig(patch: DeepPartial | (() => DeepPartial)) { let rollback: () => void = noop; before(() => { + if (typeof patch === 'function') { + patch = patch(); + } + const modifiedConfig = merge({}, currentConfig(), patch); rollback = setExplicitConfig(modifiedConfig); }); diff --git a/types/config.d.ts b/types/config.d.ts index 7aed053b5..459aef567 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -194,6 +194,13 @@ declare module 'config' { headLikes: number; minOmittedLikes: number; }; + + corsProxy: { + timeout: ISO8601DurationString; + allowedOrigins: string[]; + allowedURlPrefixes: string[]; + allowLocalhostOrigins: boolean; + }; }; export type TranslationLimits = {