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

feat(server): client version check #9205

Open
wants to merge 7 commits into
base: canary
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions oxlint.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"**/*.d.ts",
"tools/cli/src/webpack/error-handler.js",
"packages/backend/native/index.d.ts",
"packages/backend/server/src/__tests__/__snapshots__",
"packages/frontend/native/index.d.ts",
"packages/frontend/native/index.js",
"packages/frontend/graphql/src/graphql/index.ts",
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"react": "19.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"semver": "^7.6.3",
"ses": "^1.10.0",
"socket.io": "^4.8.1",
"stripe": "^17.4.0",
Expand All @@ -116,6 +117,7 @@
"@types/on-headers": "^1.0.3",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@types/semver": "^7.5.8",
"@types/sinon": "^17.0.3",
"@types/supertest": "^6.0.2",
"ava": "^6.2.0",
Expand Down
217 changes: 217 additions & 0 deletions packages/backend/server/src/__tests__/version.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import '../core/version/config';

import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import request from 'supertest';

import { AppModule } from '../app.module';
import { Runtime, UseNamedGuard } from '../base';
import { Public } from '../core/auth/guard';
import { createTestingApp, initTestingDB } from './utils';

const test = ava as TestFn<{
runtime: Runtime;
cookie: string;
app: INestApplication;
}>;

@Public()
@Controller('/guarded')
class GuardedController {
@UseNamedGuard('version')
@Get('/test')
test() {
return 'test';
}
}

test.before(async t => {
const { app } = await createTestingApp({
imports: [AppModule],
controllers: [GuardedController],
});

t.context.runtime = app.get(Runtime);
t.context.app = app;
});

test.beforeEach(async t => {
await initTestingDB(t.context.app.get(PrismaClient));
// reset runtime
await t.context.runtime.loadDb('version/enable');
await t.context.runtime.loadDb('version/allowedVersion');
await t.context.runtime.set('version/enable', false);
await t.context.runtime.set('version/allowedVersion', '>=0.0.1');
});

test.after.always(async t => {
await t.context.app.close();
});

async function fetchWithVersion(
server: any,
version: string | undefined,
status: number
) {
let req = request(server).get('/guarded/test');
if (version) {
req = req.set({ 'x-affine-version': version });
}
const res = await req.expect(status);
if (res.body.message) {
throw new Error(res.body.message);
}
return res;
}

test('should be able to prevent requests if version outdated', async t => {
const { app, runtime } = t.context;

{
await runtime.set('version/enable', false);
await t.notThrowsAsync(
fetchWithVersion(app.getHttpServer(), undefined, HttpStatus.OK),
'should not check version if disabled'
);
}

{
await runtime.set('version/enable', true);
await t.throwsAsync(
fetchWithVersion(app.getHttpServer(), undefined, HttpStatus.FORBIDDEN),
{
message:
'Unsupported client version: [Not Provided], please upgrade to 0.0.1.',
},
'should check version exists'
);
await t.throwsAsync(
fetchWithVersion(
app.getHttpServer(),
'not_a_version',
HttpStatus.FORBIDDEN
),
{
message:
'Unsupported client version: not_a_version, please upgrade to 0.0.1.',
},
'should check version exists'
);
await t.notThrowsAsync(
fetchWithVersion(app.getHttpServer(), '0.0.1', HttpStatus.OK),
'should check version exists'
);
}

{
await runtime.set('version/allowedVersion', 'unknownVersion');
await t.notThrowsAsync(
fetchWithVersion(app.getHttpServer(), undefined, HttpStatus.OK),
'should not check version if invalid allowedVersion provided'
);
await t.notThrowsAsync(
fetchWithVersion(app.getHttpServer(), '0.0.1', HttpStatus.OK),
'should not check version if invalid allowedVersion provided'
);

await runtime.set('version/allowedVersion', '0.0.1');
await t.throwsAsync(
fetchWithVersion(app.getHttpServer(), '0.0.0', HttpStatus.FORBIDDEN),
{
message: 'Unsupported client version: 0.0.0, please upgrade to 0.0.1.',
},
'should reject version if valid allowedVersion provided'
);

await runtime.set(
'version/allowedVersion',
'0.17.5 || >=0.18.0-nightly || >=0.18.0'
);
await t.notThrowsAsync(
fetchWithVersion(app.getHttpServer(), '0.17.5', HttpStatus.OK),
'should pass version if version satisfies allowedVersion'
);
await t.throwsAsync(
fetchWithVersion(app.getHttpServer(), '0.17.4', HttpStatus.FORBIDDEN),
{
message:
'Unsupported client version: 0.17.4, please upgrade to 0.18.0.',
},
'should reject version if valid allowedVersion provided'
);
await t.throwsAsync(
fetchWithVersion(
app.getHttpServer(),
'0.17.6-nightly-f0d99f4',
HttpStatus.FORBIDDEN
),
{
message:
'Unsupported client version: 0.17.6-nightly-f0d99f4, please upgrade to 0.18.0.',
},
'should reject version if valid allowedVersion provided'
);
await t.notThrowsAsync(
fetchWithVersion(
app.getHttpServer(),
'0.18.0-nightly-cc9b38c',
HttpStatus.OK
),
'should pass version if version satisfies allowedVersion'
);
await t.notThrowsAsync(
fetchWithVersion(app.getHttpServer(), '0.18.1', HttpStatus.OK),
'should pass version if version satisfies allowedVersion'
);
}

{
await runtime.set(
'version/allowedVersion',
'>=0.0.1 <=0.1.2 || ^0.2.0-nightly <0.2.0 || 0.3.0'
);

await t.notThrowsAsync(
fetchWithVersion(app.getHttpServer(), '0.0.1', HttpStatus.OK),
'should pass version if version satisfies allowedVersion'
);
await t.notThrowsAsync(
fetchWithVersion(app.getHttpServer(), '0.1.2', HttpStatus.OK),
'should pass version if version satisfies allowedVersion'
);
await t.throwsAsync(
fetchWithVersion(app.getHttpServer(), '0.1.3', HttpStatus.FORBIDDEN),
{
message: 'Unsupported client version: 0.1.3, please upgrade to 0.3.0.',
},
'should reject version if valid allowedVersion provided'
);

await t.notThrowsAsync(
fetchWithVersion(
app.getHttpServer(),
'0.2.0-nightly-cc9b38c',
HttpStatus.OK
),
'should pass version if version satisfies allowedVersion'
);

await t.throwsAsync(
fetchWithVersion(app.getHttpServer(), '0.2.0', HttpStatus.FORBIDDEN),
{
message: 'Unsupported client version: 0.2.0, please upgrade to 0.3.0.',
},
'should reject version if valid allowedVersion provided'
);

await t.throwsAsync(
fetchWithVersion(app.getHttpServer(), '0.3.1', HttpStatus.FORBIDDEN),
{
message:
'Unsupported client version: 0.3.1, please downgrade to 0.3.0.',
},
'should reject version if valid allowedVersion provided'
);
}
});
2 changes: 2 additions & 0 deletions packages/backend/server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { SelfhostModule } from './core/selfhost';
import { StorageModule } from './core/storage';
import { SyncModule } from './core/sync';
import { UserModule } from './core/user';
import { VersionModule } from './core/version';
import { WorkspaceModule } from './core/workspaces';
import { ModelsModule } from './models';
import { REGISTERED_PLUGINS } from './plugins';
Expand Down Expand Up @@ -200,6 +201,7 @@ export function buildAppModule() {
.useIf(
config => config.flavor.graphql,
ScheduleModule.forRoot(),
VersionModule,
GqlModule,
StorageModule,
ServerConfigModule,
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/server/src/base/error/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,4 +603,15 @@ export const USER_FRIENDLY_ERRORS = {
type: 'bad_request',
message: 'Captcha verification failed.',
},
// version errors
unsupported_client_version: {
type: 'action_forbidden',
args: {
clientVersion: 'string',
recommendedVersion: 'string',
action: 'string',
},
message: ({ clientVersion, recommendedVersion, action }) =>
`Unsupported client version: ${clientVersion}, please ${action} to ${recommendedVersion}.`,
},
} satisfies Record<string, UserFriendlyErrorOptions>;
17 changes: 15 additions & 2 deletions packages/backend/server/src/base/error/errors.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,18 @@ export class CaptchaVerificationFailed extends UserFriendlyError {
super('bad_request', 'captcha_verification_failed', message);
}
}
@ObjectType()
class UnsupportedClientVersionDataType {
@Field() clientVersion!: string
@Field() recommendedVersion!: string
@Field() action!: string
}

export class UnsupportedClientVersion extends UserFriendlyError {
constructor(args: UnsupportedClientVersionDataType, message?: string | ((args: UnsupportedClientVersionDataType) => string)) {
super('action_forbidden', 'unsupported_client_version', message, args);
}
}
export enum ErrorNames {
INTERNAL_SERVER_ERROR,
TOO_MANY_REQUEST,
Expand Down Expand Up @@ -669,7 +681,8 @@ export enum ErrorNames {
MAILER_SERVICE_IS_NOT_CONFIGURED,
CANNOT_DELETE_ALL_ADMIN_ACCOUNT,
CANNOT_DELETE_OWN_ACCOUNT,
CAPTCHA_VERIFICATION_FAILED
CAPTCHA_VERIFICATION_FAILED,
UNSUPPORTED_CLIENT_VERSION
}
registerEnumType(ErrorNames, {
name: 'ErrorNames'
Expand All @@ -678,5 +691,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
[WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const,
[WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, UnsupportedClientVersionDataType] as const,
});
15 changes: 10 additions & 5 deletions packages/backend/server/src/base/guard/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@ export class BasicGuard implements CanActivate {

async canActivate(context: ExecutionContext) {
// get registered guard name
const providerName = this.reflector.get<string>(
const providerName = this.reflector.get<string[]>(
BasicGuardSymbol,
context.getHandler()
);

const provider = GUARD_PROVIDER[providerName as NamedGuards];
if (provider) {
return await provider.canActivate(context);
if (Array.isArray(providerName) && providerName.length > 0) {
for (const name of providerName) {
const provider = GUARD_PROVIDER[name as NamedGuards];
if (provider) {
const ret = await provider.canActivate(context);
if (!ret) return false;
}
}
}

return true;
Expand All @@ -46,5 +51,5 @@ export class BasicGuard implements CanActivate {
* }
* ```
*/
export const UseNamedGuard = (name: NamedGuards) =>
export const UseNamedGuard = (...name: NamedGuards[]) =>
applyDecorators(UseGuards(BasicGuard), SetMetadata(BasicGuardSymbol, name));
4 changes: 3 additions & 1 deletion packages/backend/server/src/core/auth/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class AuthController {
}

@Public()
@UseNamedGuard('version')
@Post('/preflight')
async preflight(
@Body() params?: { email: string }
Expand Down Expand Up @@ -99,7 +100,7 @@ export class AuthController {
}

@Public()
@UseNamedGuard('captcha')
@UseNamedGuard('version', 'captcha')
@Post('/sign-in')
@Header('content-type', 'application/json')
async signIn(
Expand Down Expand Up @@ -235,6 +236,7 @@ export class AuthController {
}

@Public()
@UseNamedGuard('version')
@Post('/magic-link')
async magicLinkSignIn(
@Req() req: Request,
Expand Down
29 changes: 29 additions & 0 deletions packages/backend/server/src/core/version/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { defineRuntimeConfig, ModuleConfig } from '../../base/config';

export interface VersionConfig {
enable: boolean;
allowedVersion: string;
}

declare module '../../base/config' {
interface AppConfig {
version: ModuleConfig<never, VersionConfig>;
}
}

declare module '../../base/guard' {
interface RegisterGuardName {
version: 'version';
}
}

defineRuntimeConfig('version', {
enable: {
desc: 'Check version of the app',
default: false,
},
allowedVersion: {
desc: 'Allowed version range of the app that can access the server',
default: '>=0.0.1',
},
});
Loading
Loading