Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a custom CORS proxy for our clients #681

Merged
merged 1 commit into from
Jan 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions app/controllers/api/v2/CorsProxyController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Readable } from 'stream';
import { ReadableStream } from 'stream/web';

import { Context } from 'koa';
import { Duration } from 'luxon';

import { ForbiddenException, ValidationException } from '../../../support/exceptions';
import { currentConfig } from '../../../support/app-async-context';

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 ForbiddenException('Missing origin');
}
} else if (
// Origin header is present, check it validity
!(
allowedOrigins.includes(origin) ||
(allowLocalhostOrigins && /^https?:\/localhost(:\d+)?$/.test(origin))
)
) {
throw new ForbiddenException('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 that we want to pass to the client
for (const header of ['Location', 'Content-Type', 'Content-Length']) {
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;
}
2 changes: 2 additions & 0 deletions app/models/auth-tokens/app-tokens-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 2 additions & 0 deletions app/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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());
Expand Down
7 changes: 7 additions & 0 deletions app/routes/api/v2/CorsProxyRoute.ts
Original file line number Diff line number Diff line change
@@ -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);
}
14 changes: 14 additions & 0 deletions config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
80 changes: 80 additions & 0 deletions test/functional/cors-proxy.ts
Original file line number Diff line number Diff line change
@@ -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: 403, 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' });
});
});
11 changes: 11 additions & 0 deletions test/functional/functional_test_helper.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Context } from 'koa';

import { Comment, Group, Post, User } from '../../app/models';
import { UUID } from '../../app/support/types';

Expand Down Expand Up @@ -50,3 +52,12 @@ export function justCreateGroup(
): Promise<Group>;

export function justLikeComment(commentObj: Comment, userCtx: UserCtx): Promise<void>;

export class MockHTTPServer {
readonly port: number;
readonly origin: string;

constructor(handler: (ctx: Context) => void, opts?: { timeout?: number });
start(): Promise<void>;
stop(): Promise<void>;
}
6 changes: 5 additions & 1 deletion test/helpers/with-modified-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config>) {
export function withModifiedConfig(patch: DeepPartial<Config> | (() => DeepPartial<Config>)) {
let rollback: () => void = noop;
before(() => {
if (typeof patch === 'function') {
patch = patch();
}

const modifiedConfig = merge({}, currentConfig(), patch);
rollback = setExplicitConfig(modifiedConfig);
});
Expand Down
7 changes: 7 additions & 0 deletions types/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ declare module 'config' {
headLikes: number;
minOmittedLikes: number;
};

corsProxy: {
timeout: ISO8601DurationString;
allowedOrigins: string[];
allowedURlPrefixes: string[];
allowLocalhostOrigins: boolean;
};
};

export type TranslationLimits = {
Expand Down
Loading