summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api
diff options
context:
space:
mode:
authortamaina <tamaina@hotmail.co.jp>2022-05-30 05:53:40 +0000
committertamaina <tamaina@hotmail.co.jp>2022-05-30 05:53:40 +0000
commit465531d56c5475f7d0be327950c33f715477c441 (patch)
tree223baf3bb26d44fadb105b851c2695f7644afd27 /packages/backend/src/server/api
parentchore: remove packages/sw/webpack.config.js (diff)
parentfix(client): import shared ESLint config in client package (#8761) (diff)
downloadmisskey-465531d56c5475f7d0be327950c33f715477c441.tar.gz
misskey-465531d56c5475f7d0be327950c33f715477c441.tar.bz2
misskey-465531d56c5475f7d0be327950c33f715477c441.zip
Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop
Diffstat (limited to 'packages/backend/src/server/api')
-rw-r--r--packages/backend/src/server/api/call.ts49
-rw-r--r--packages/backend/src/server/api/endpoints.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register.ts8
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.ts14
-rw-r--r--packages/backend/src/server/api/endpoints/users/search.ts9
-rw-r--r--packages/backend/src/server/api/limiter.ts26
-rw-r--r--packages/backend/src/server/api/openapi/gen-spec.ts16
-rw-r--r--packages/backend/src/server/api/private/signin.ts16
9 files changed, 97 insertions, 44 deletions
diff --git a/packages/backend/src/server/api/call.ts b/packages/backend/src/server/api/call.ts
index 9a85e4565b..fbe25e1732 100644
--- a/packages/backend/src/server/api/call.ts
+++ b/packages/backend/src/server/api/call.ts
@@ -2,10 +2,11 @@ import Koa from 'koa';
import { performance } from 'perf_hooks';
import { limiter } from './limiter.js';
import { CacheableLocalUser, User } from '@/models/entities/user.js';
-import endpoints, { IEndpoint } from './endpoints.js';
+import endpoints, { IEndpointMeta } from './endpoints.js';
import { ApiError } from './error.js';
import { apiLogger } from './logger.js';
import { AccessToken } from '@/models/entities/access-token.js';
+import IPCIDR from 'ip-cidr';
const accessDenied = {
message: 'Access denied.',
@@ -15,6 +16,7 @@ const accessDenied = {
export default async (endpoint: string, user: CacheableLocalUser | null | undefined, token: AccessToken | null | undefined, data: any, ctx?: Koa.Context) => {
const isSecure = user != null && token == null;
+ const isModerator = user != null && (user.isModerator || user.isAdmin);
const ep = endpoints.find(e => e.name === endpoint);
@@ -31,6 +33,37 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
throw new ApiError(accessDenied);
}
+ if (ep.meta.requireCredential && ep.meta.limit && !isModerator) {
+ // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
+ let limitActor: string;
+ if (user) {
+ limitActor = user.id;
+ } else {
+ // because a single person may control many IPv6 addresses,
+ // only a /64 subnet prefix of any IP will be taken into account.
+ // (this means for IPv4 the entire address is used)
+ const ip = IPCIDR.createAddress(ctx.ip).mask(64);
+
+ limitActor = 'ip-' + parseInt(ip, 2).toString(36);
+ }
+
+ const limit = Object.assign({}, ep.meta.limit);
+
+ if (!limit.key) {
+ limit.key = ep.name;
+ }
+
+ // Rate limit
+ await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(e => {
+ throw new ApiError({
+ message: 'Rate limit exceeded. Please try again later.',
+ code: 'RATE_LIMIT_EXCEEDED',
+ id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
+ httpStatusCode: 429,
+ });
+ });
+ }
+
if (ep.meta.requireCredential && user == null) {
throw new ApiError({
message: 'Credential required.',
@@ -53,7 +86,7 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
throw new ApiError(accessDenied, { reason: 'You are not the admin.' });
}
- if (ep.meta.requireModerator && !user!.isAdmin && !user!.isModerator) {
+ if (ep.meta.requireModerator && !isModerator) {
throw new ApiError(accessDenied, { reason: 'You are not a moderator.' });
}
@@ -65,18 +98,6 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
});
}
- if (ep.meta.requireCredential && ep.meta.limit && !user!.isAdmin && !user!.isModerator) {
- // Rate limit
- await limiter(ep as IEndpoint & { meta: { limit: NonNullable<IEndpoint['meta']['limit']> } }, user!).catch(e => {
- throw new ApiError({
- message: 'Rate limit exceeded. Please try again later.',
- code: 'RATE_LIMIT_EXCEEDED',
- id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
- httpStatusCode: 429,
- });
- });
- }
-
// Cast non JSON input
if (ep.meta.requireFile && ep.params.properties) {
for (const k of Object.keys(ep.params.properties)) {
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index e2db03f13a..1e7afd8cdd 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -654,7 +654,6 @@ export interface IEndpointMeta {
/**
* エンドポイントのリミテーションに関するやつ
* 省略した場合はリミテーションは無いものとして解釈されます。
- * また、withCredential が false の場合はリミテーションを行うことはできません。
*/
readonly limit?: {
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index b23ee9e3df..09e43301b7 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -27,7 +27,7 @@ export const paramDef = {
blockedHosts: { type: 'array', nullable: true, items: {
type: 'string',
} },
- themeColor: { type: 'string', nullable: true },
+ themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
mascotImageUrl: { type: 'string', nullable: true },
bannerUrl: { type: 'string', nullable: true },
errorImageUrl: { type: 'string', nullable: true },
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
index d5e1b19e54..33f5717728 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
@@ -2,8 +2,8 @@ import bcrypt from 'bcryptjs';
import * as speakeasy from 'speakeasy';
import * as QRCode from 'qrcode';
import config from '@/config/index.js';
-import define from '../../../define.js';
import { UserProfiles } from '@/models/index.js';
+import define from '../../../define.js';
export const meta = {
requireCredential: true,
@@ -40,15 +40,17 @@ export default define(meta, paramDef, async (ps, user) => {
});
// Get the data URL of the authenticator URL
- const dataUrl = await QRCode.toDataURL(speakeasy.otpauthURL({
+ const url = speakeasy.otpauthURL({
secret: secret.base32,
encoding: 'base32',
label: user.username,
issuer: config.host,
- }));
+ });
+ const dataUrl = await QRCode.toDataURL(url);
return {
qr: dataUrl,
+ url,
secret: secret.base32,
label: user.username,
issuer: config.host,
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 40a3ba73ca..a133294169 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -134,7 +134,7 @@ export const paramDef = {
{
// (re)note with text, files and poll are optional
properties: {
- text: { type: 'string', maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false },
+ text: { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false },
},
required: ['text'],
},
@@ -172,10 +172,14 @@ export default define(meta, paramDef, async (ps, user) => {
let files: DriveFile[] = [];
const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null;
if (fileIds != null) {
- files = await DriveFiles.findBy({
- userId: user.id,
- id: In(fileIds),
- });
+ files = await DriveFiles.createQueryBuilder('file')
+ .where('file.userId = :userId AND file.id IN (:...fileIds)', {
+ userId: user.id,
+ fileIds,
+ })
+ .orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
+ .setParameters({ fileIds })
+ .getMany();
}
let renote: Note | null = null;
diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts
index a72a58a843..f93d4f718b 100644
--- a/packages/backend/src/server/api/endpoints/users/search.ts
+++ b/packages/backend/src/server/api/endpoints/users/search.ts
@@ -61,7 +61,14 @@ export default define(meta, paramDef, async (ps, me) => {
.getMany();
} else {
const nameQuery = Users.createQueryBuilder('user')
- .where('user.name ILIKE :query', { query: '%' + ps.query + '%' })
+ .where(new Brackets(qb => {
+ qb.where('user.name ILIKE :query', { query: '%' + ps.query + '%' });
+
+ // Also search username if it qualifies as username
+ if (Users.validateLocalUsername(ps.query)) {
+ qb.orWhere('user.usernameLower LIKE :username', { username: '%' + ps.query.toLowerCase() + '%' });
+ }
+ }))
.andWhere(new Brackets(qb => { qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
diff --git a/packages/backend/src/server/api/limiter.ts b/packages/backend/src/server/api/limiter.ts
index e74db8466e..23430cf8b6 100644
--- a/packages/backend/src/server/api/limiter.ts
+++ b/packages/backend/src/server/api/limiter.ts
@@ -1,25 +1,17 @@
import Limiter from 'ratelimiter';
import { redisClient } from '../../db/redis.js';
-import { IEndpoint } from './endpoints.js';
-import * as Acct from '@/misc/acct.js';
+import { IEndpointMeta } from './endpoints.js';
import { CacheableLocalUser, User } from '@/models/entities/user.js';
import Logger from '@/services/logger.js';
const logger = new Logger('limiter');
-export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndpoint['meta']['limit']> } }, user: CacheableLocalUser) => new Promise<void>((ok, reject) => {
- const limitation = endpoint.meta.limit;
-
- const key = Object.prototype.hasOwnProperty.call(limitation, 'key')
- ? limitation.key
- : endpoint.name;
-
- const hasShortTermLimit =
- Object.prototype.hasOwnProperty.call(limitation, 'minInterval');
+export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => {
+ const hasShortTermLimit = typeof limitation.minInterval === 'number';
const hasLongTermLimit =
- Object.prototype.hasOwnProperty.call(limitation, 'duration') &&
- Object.prototype.hasOwnProperty.call(limitation, 'max');
+ typeof limitation.duration === 'number' &&
+ typeof limitation.max === 'number';
if (hasShortTermLimit) {
min();
@@ -32,7 +24,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
// Short-term limit
function min(): void {
const minIntervalLimiter = new Limiter({
- id: `${user.id}:${key}:min`,
+ id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval,
max: 1,
db: redisClient,
@@ -43,7 +35,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
return reject('ERR');
}
- logger.debug(`@${Acct.toString(user)} ${endpoint.name} min remaining: ${info.remaining}`);
+ logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
if (info.remaining === 0) {
reject('BRIEF_REQUEST_INTERVAL');
@@ -60,7 +52,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
// Long term limit
function max(): void {
const limiter = new Limiter({
- id: `${user.id}:${key}`,
+ id: `${actor}:${limitation.key}`,
duration: limitation.duration,
max: limitation.max,
db: redisClient,
@@ -71,7 +63,7 @@ export const limiter = (endpoint: IEndpoint & { meta: { limit: NonNullable<IEndp
return reject('ERR');
}
- logger.debug(`@${Acct.toString(user)} ${endpoint.name} max remaining: ${info.remaining}`);
+ logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) {
reject('RATE_LIMIT_EXCEEDED');
diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts
index c6e557aefb..3929fff3f7 100644
--- a/packages/backend/src/server/api/openapi/gen-spec.ts
+++ b/packages/backend/src/server/api/openapi/gen-spec.ts
@@ -59,6 +59,18 @@ export function genOpenapiSpec(lang = 'ja-JP') {
desc += ` / **Permission**: *${kind}*`;
}
+ const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json';
+ const schema = endpoint.params;
+
+ if (endpoint.meta.requireFile) {
+ schema.properties.file = {
+ type: 'string',
+ format: 'binary',
+ description: 'The file contents.',
+ };
+ schema.required.push('file');
+ }
+
const info = {
operationId: endpoint.name,
summary: endpoint.name,
@@ -78,8 +90,8 @@ export function genOpenapiSpec(lang = 'ja-JP') {
requestBody: {
required: true,
content: {
- 'application/json': {
- schema: endpoint.params,
+ [requestType]: {
+ schema,
},
},
},
diff --git a/packages/backend/src/server/api/private/signin.ts b/packages/backend/src/server/api/private/signin.ts
index 7b66657ad8..b304550e29 100644
--- a/packages/backend/src/server/api/private/signin.ts
+++ b/packages/backend/src/server/api/private/signin.ts
@@ -9,6 +9,7 @@ import { genId } from '@/misc/gen-id.js';
import { verifyLogin, hash } from '../2fa.js';
import { randomBytes } from 'node:crypto';
import { IsNull } from 'typeorm';
+import { limiter } from '../limiter.js';
export default async (ctx: Koa.Context) => {
ctx.set('Access-Control-Allow-Origin', config.url);
@@ -24,6 +25,21 @@ export default async (ctx: Koa.Context) => {
ctx.body = { error };
}
+ try {
+ // not more than 1 attempt per second and not more than 10 attempts per hour
+ await limiter({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, ctx.ip);
+ } catch (err) {
+ ctx.status = 429;
+ ctx.body = {
+ error: {
+ message: 'Too many failed attempts to sign in. Try again later.',
+ code: 'TOO_MANY_AUTHENTICATION_FAILURES',
+ id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
+ },
+ };
+ return;
+ }
+
if (typeof username !== 'string') {
ctx.status = 400;
return;