-
-
Notifications
You must be signed in to change notification settings - Fork 0
fix(security): prevent fail-open auth in provision/webhooks and add ticket replay protection #14
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,7 @@ import { | |||||||||||||||||||||||||||||||
| } from '@nestjs/common'; | ||||||||||||||||||||||||||||||||
| import { ApiTags } from '@nestjs/swagger'; | ||||||||||||||||||||||||||||||||
| import { Request, Response } from 'express'; | ||||||||||||||||||||||||||||||||
| import { timingSafeEqual } from 'crypto'; | ||||||||||||||||||||||||||||||||
| import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; | ||||||||||||||||||||||||||||||||
| import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; | ||||||||||||||||||||||||||||||||
| import { AuthService } from '@gitroom/backend/services/auth/auth.service'; | ||||||||||||||||||||||||||||||||
|
|
@@ -19,6 +20,7 @@ import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.man | |||||||||||||||||||||||||||||||
| import { ProvisionUserDto } from '@gitroom/nestjs-libraries/dtos/provision/provision-user.dto'; | ||||||||||||||||||||||||||||||||
| import { ConsumeTicketDto } from '@gitroom/nestjs-libraries/dtos/provision/consume-ticket.dto'; | ||||||||||||||||||||||||||||||||
| import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; | ||||||||||||||||||||||||||||||||
| import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; | ||||||||||||||||||||||||||||||||
| import { Provider } from '@prisma/client'; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @ApiTags('Provisioning') | ||||||||||||||||||||||||||||||||
|
|
@@ -35,19 +37,24 @@ export class ProvisionController { | |||||||||||||||||||||||||||||||
| process.env.PROVISIONING_SECRET_KEY || | ||||||||||||||||||||||||||||||||
| process.env.DOS_PROVISIONING_SECRET || | ||||||||||||||||||||||||||||||||
| process.env.DOS_SYNC_WEBHOOK_SECRET || | ||||||||||||||||||||||||||||||||
| process.env.INTERNAL_API_KEY || | ||||||||||||||||||||||||||||||||
| process.env.JWT_SECRET; | ||||||||||||||||||||||||||||||||
| process.env.INTERNAL_API_KEY; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (!secret) { | ||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||
| if (!secret || !authHeader) { | ||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (!authHeader) { | ||||||||||||||||||||||||||||||||
| const token = authHeader.replace(/^Bearer\s+/i, '').trim(); | ||||||||||||||||||||||||||||||||
| if (!token) { | ||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const token = authHeader.replace(/^Bearer\s+/i, '').trim(); | ||||||||||||||||||||||||||||||||
| return token === secret; | ||||||||||||||||||||||||||||||||
| const expected = Buffer.from(secret); | ||||||||||||||||||||||||||||||||
| const provided = Buffer.from(token); | ||||||||||||||||||||||||||||||||
| if (expected.length !== provided.length) { | ||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| return timingSafeEqual(provided, expected); | ||||||||||||||||||||||||||||||||
|
Comment on lines
+51
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of To prevent this, hash both the expected and provided tokens using a fixed-length hashing function (like SHA-256) before performing the constant-time comparison. const expected = createHash('sha256').update(secret).digest();
const provided = createHash('sha256').update(token).digest();
return timingSafeEqual(provided, expected); |
||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @Post('/provision') | ||||||||||||||||||||||||||||||||
|
|
@@ -137,14 +144,24 @@ export class ProvisionController { | |||||||||||||||||||||||||||||||
| targetOrg = userOrgs[0] || { id: orgId || makeId(10), name: effectiveOrgName }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // 3. Issue one-time login ticket (valid for 5 minutes) | ||||||||||||||||||||||||||||||||
| // 3. Issue one-time login ticket (single-use, valid for 5 minutes) | ||||||||||||||||||||||||||||||||
| const ticketId = makeId(32); | ||||||||||||||||||||||||||||||||
| const ticket = AuthChecker.signJWT({ | ||||||||||||||||||||||||||||||||
| jti: ticketId, | ||||||||||||||||||||||||||||||||
| userId: user.id, | ||||||||||||||||||||||||||||||||
| orgId: targetOrg.id, | ||||||||||||||||||||||||||||||||
| type: 'one_time_ticket', | ||||||||||||||||||||||||||||||||
| exp: Math.floor(Date.now() / 1000) + 300, | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Store in Redis with 300s TTL for single-use / replay protection | ||||||||||||||||||||||||||||||||
| await ioRedis.set( | ||||||||||||||||||||||||||||||||
| `ticket:${ticketId}`, | ||||||||||||||||||||||||||||||||
| JSON.stringify({ userId: user.id, orgId: targetOrg.id }), | ||||||||||||||||||||||||||||||||
| 'EX', | ||||||||||||||||||||||||||||||||
| 300 | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const loginUrl = `${process.env.FRONTEND_URL}/auth/ticket?ticket=${ticket}`; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||
|
|
@@ -180,10 +197,21 @@ export class ProvisionController { | |||||||||||||||||||||||||||||||
| throw new HttpException('Invalid or expired ticket', HttpStatus.BAD_REQUEST); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (payload?.type !== 'one_time_ticket' || !payload?.userId) { | ||||||||||||||||||||||||||||||||
| if (payload?.type !== 'one_time_ticket' || !payload?.userId || !payload?.jti) { | ||||||||||||||||||||||||||||||||
| throw new HttpException('Invalid ticket type', HttpStatus.BAD_REQUEST); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Atomic consume & replay protection: verify ticket exists in Redis then delete immediately | ||||||||||||||||||||||||||||||||
| const ticketKey = `ticket:${payload.jti}`; | ||||||||||||||||||||||||||||||||
| const storedTicket = await ioRedis.get(ticketKey); | ||||||||||||||||||||||||||||||||
| if (!storedTicket) { | ||||||||||||||||||||||||||||||||
| throw new HttpException( | ||||||||||||||||||||||||||||||||
| 'Ticket has already been used or has expired', | ||||||||||||||||||||||||||||||||
| HttpStatus.BAD_REQUEST | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| await ioRedis.del(ticketKey); | ||||||||||||||||||||||||||||||||
|
Comment on lines
+206
to
+213
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of the one-time ticket consumption is not atomic and is vulnerable to a race condition (Time-of-Check to Time-of-Use). If multiple concurrent requests are made with the same ticket, both could pass the Since the
Suggested change
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const user = await this._userService.getUserById(payload.userId); | ||||||||||||||||||||||||||||||||
| if (!user || !user.activated) { | ||||||||||||||||||||||||||||||||
| throw new HttpException('User not found or inactive', HttpStatus.NOT_FOUND); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,4 +5,13 @@ export class RootController { | |||||||||||||||||||||||||||||||||
| getRoot(): string { | ||||||||||||||||||||||||||||||||||
| return 'App is running!'; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @Get('/health') | ||||||||||||||||||||||||||||||||||
| getHealth() { | ||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||
| status: 'ok', | ||||||||||||||||||||||||||||||||||
| timestamp: new Date().toISOString(), | ||||||||||||||||||||||||||||||||||
| service: 'crove-post', | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+9
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The health check endpoint returns a hardcoded service name
Suggested change
|
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import
createHashfrom thecryptomodule to support timing-safe comparison of secrets of arbitrary lengths.