Skip to content

Commit

Permalink
Merge stable into release
Browse files Browse the repository at this point in the history
  • Loading branch information
davidmz committed Jan 5, 2025
2 parents ac54008 + 9e96b1e commit df9bb6b
Show file tree
Hide file tree
Showing 11 changed files with 225 additions and 2 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
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
- The `GET /v2/users/sparseMatches?qs=...` API endpoint now returns private
Expand Down
77 changes: 77 additions & 0 deletions app/controllers/api/v2/CorsProxyController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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, {
headers: { 'Accept-Encoding': 'identity' },
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;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "freefeed-server",
"description": "FreeFeed is an open source FriendFeed clone (yes, it is free and open!) based on Pepyatka open-source FriendFeed clone (yes, that one is also free and open). Basically, this is a social real-time feed aggregator that allows you to share cute kittens, coordinate upcoming events, discuss any other cool stuff on the Internet or setup a private Pepyatka instance in your company.",
"homepage": "https://freefeed.net",
"version": "2.22.4",
"version": "2.23.0",
"private": true,
"scripts": {
"start": "cross-env TZ=UTC yarn babel index.js",
Expand Down
91 changes: 91 additions & 0 deletions test/functional/cors-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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://goodorigin.net'],
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://goodorigin.net' },
);
expect(resp, 'to satisfy', { __httpCode: 200, textResponse: 'Example text' });
});

it(`should call with allowed url and localhost origin`, async () => {
const url = `${server.origin}/example.txt`;
const resp = await performJSONRequest(
'GET',
`/v2/cors-proxy?url=${encodeURIComponent(url)}`,
null,
{ Origin: 'http://localhost:8080' },
);
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

0 comments on commit df9bb6b

Please sign in to comment.