Skip to content

Commit

Permalink
add rate limiting to web input deletion mutation resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
capaj committed Sep 2, 2024
1 parent 4ce2083 commit d36b39a
Show file tree
Hide file tree
Showing 7 changed files with 110 additions and 10 deletions.
2 changes: 1 addition & 1 deletion backend/gqlSchemas/authier.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ type WebInputMutation {
addedByUserId: String
addedByUser: UserGQL
UsageEvents: [SecretUsageEventGQL!]!
delete: Int!
delete: WebInputGQLScalars
}

input WebInputElement {
Expand Down
29 changes: 29 additions & 0 deletions backend/lib/RedisBasicRateLimiter.fn.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { redisClient } from './redisClient'

import { RedisBasicRateLimiter } from './RedisBasicRateLimiter'

describe('RedisBasicRateLimiter', () => {
const limiter = new RedisBasicRateLimiter(redisClient, {
maxHits: 2,
intervalSeconds: 10,
limiterPrefix: 'test' // hour,
})

const reset = async () => {
await limiter.reset('127.0.0.1')
}
beforeAll(reset)
afterAll(reset)
it('should not throw when hits are not exceeded', async () => {
await limiter.increment('127.0.0.1')
await limiter.increment('127.0.0.1')
})
it('should throw when max count of hits is exceeded', async () => {
await expect(
limiter.increment('127.0.0.1')

Check failure on line 24 in backend/lib/RedisBasicRateLimiter.fn.spec.ts

View workflow job for this annotation

GitHub Actions / runs typescript, tests, and deploys

lib/RedisBasicRateLimiter.fn.spec.ts > RedisBasicRateLimiter > should throw when max count of hits is exceeded

AssertionError: promise resolved "undefined" instead of rejecting - Expected: [Error: rejected promise] + Received: undefined ❯ lib/RedisBasicRateLimiter.fn.spec.ts:24:25
).rejects.toThrowErrorMatchingInlineSnapshot(
'"rate limit exceeded, try in 10 seconds"'
)
})
})
57 changes: 57 additions & 0 deletions backend/lib/RedisBasicRateLimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import ms from 'ms'

import { GraphQLError } from 'graphql'
import { Redis } from '@upstash/redis'

export class RateLimitedError extends GraphQLError {
constructor(message: string, errorCode: string) {
super(message, {
extensions: {
code: errorCode
}
})
}
}

export class RedisBasicRateLimiter {
duration: string
constructor(
private redisClient: Redis,
private options: {
limiterPrefix: string
maxHits: number
intervalSeconds: number
}
) {
this.duration = ms(this.options.intervalSeconds * 1000, {
long: true
})
}
getKey(ip: string) {
return `${this.options.limiterPrefix}_rate_limit_counter:${ip}`
}

/**
* @param resourceKey can be an ip address or a user idjk
*/
async increment(resourceKey: string) {
const key = this.getKey(resourceKey)
const res = await this.redisClient
.multi()
.incr(key)
.expire(key, this.options.intervalSeconds)
.exec()

if (res && res[0] && (res[0][1] as number) > this.options.maxHits) {
throw new RateLimitedError(
`rate limit exceeded, try in ${this.duration}`,
'RATE_LIMIT_EXCEEDED'
)
}
}

async reset(ip: string) {
const key = this.getKey(ip)
await this.redisClient.del(key)
}
}
20 changes: 15 additions & 5 deletions backend/models/WebInput.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import { Field, ObjectType, Int, GraphQLISODateTime, Ctx } from 'type-graphql'
import { WebInputGQL } from './generated/WebInputGQL'
import { Field, ObjectType, Ctx } from 'type-graphql'
import { WebInputGQL, WebInputGQLScalars } from './generated/WebInputGQL'
import debug from 'debug'
import { IContextAuthenticated } from '../schemas/RootResolver'
import { RedisBasicRateLimiter } from '../lib/RedisBasicRateLimiter'
import { redisClient } from '../lib/redisClient'

const log = debug('au:WebInput')

const rateLimiter = new RedisBasicRateLimiter(redisClient, {
limiterPrefix: 'web_input_delete',
maxHits: 1,
intervalSeconds: 3600
})

@ObjectType()
export class WebInputMutation extends WebInputGQL {
@Field(() => Int)
@Field(() => WebInputGQLScalars, { nullable: true })
async delete(@Ctx() ctx: IContextAuthenticated) {
await rateLimiter.increment(ctx.jwtPayload.userId)
log('delete of WebInput id: ', this.id)
// TODO rate limit this to like 1 per hour

return ctx.prisma.webInput.delete({
const res = await ctx.prisma.webInput.delete({
where: { id: this.id }
})

return res
}
}
2 changes: 1 addition & 1 deletion shared/generated/graphqlBaseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,7 @@ export type WebInputMutation = {
addedByUser?: Maybe<UserGql>;
addedByUserId?: Maybe<Scalars['String']['output']>;
createdAt: Scalars['DateTime']['output'];
delete: Scalars['Int']['output'];
delete?: Maybe<WebInputGqlScalars>;
domOrdinal: Scalars['Int']['output'];
domPath: Scalars['String']['output'];
host: Scalars['String']['output'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ export type RemoveWebInputMutationVariables = Types.Exact<{
}>;


export type RemoveWebInputMutation = { __typename?: 'Mutation', webInput?: { __typename?: 'WebInputMutation', delete: number } | null };
export type RemoveWebInputMutation = { __typename?: 'Mutation', webInput?: { __typename?: 'WebInputMutation', delete?: { __typename?: 'WebInputGQLScalars', id: number } | null } | null };


export const RemoveWebInputDocument = gql`
mutation removeWebInput($id: Int!) {
webInput(id: $id) {
delete
delete {
id
}
}
}
`;
Expand Down
4 changes: 3 additions & 1 deletion web-extension/src/components/vault/VaultItemSettings.gql
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mutation removeWebInput($id: Int!) {
webInput(id: $id) {
delete
delete {
id
}
}
}

0 comments on commit d36b39a

Please sign in to comment.