Skip to content

Commit f6bc723

Browse files
authored
fix(security): close fail-open paths found by the integration suite (#7886)
- Keep an organization's permission groups governing while its payment is failing: enforcement read the usable-subscription set, so a past-due card resolved to "no permission group", which denies nothing and lifted every restriction the organization had configured - Shorten the one-time token lifetime from 24 hours to 2 minutes; the token redeems for a session cookie, so an unredeemed one was a bearer credential for that session until it expired - Refuse the plugin's password-reset endpoints by shape, and refuse the verification sender when it is asked for a reset: both reach the same mailer as the application route without its per-recipient budget - Answer an expired or reused reset link with a 400 and fixed copy rather than a 500 carrying the library's wording, while keeping the request half indistinguishable from a success so it discloses no addresses - Return an invitation token only to callers who may manage the workspace, and stop sending terminal invitations to the client at all - Validate usage dates with the calendar check zod already ships, which cannot throw out of validation the way the hand-rolled round trip did
1 parent 2ee0708 commit f6bc723

28 files changed

Lines changed: 563 additions & 132 deletions

File tree

apps/sim/app/api/auth/[...all]/route.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,76 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
190190
})
191191
})
192192

193+
describe('auth catch-all route password-reset mail', () => {
194+
beforeEach(() => {
195+
vi.clearAllMocks()
196+
})
197+
198+
it.each([
199+
'request-password-reset',
200+
'email-otp/request-password-reset',
201+
'forget-password/email-otp',
202+
/** Matched by shape, so a plugin version that renames or adds an alias cannot reopen it. */
203+
'request-password-reset/v2',
204+
'some-plugin/forget-password',
205+
])('blocks %s, which reaches the mailer without the per-recipient budget', async (path) => {
206+
const req = createMockRequest('POST', undefined, {}, `http://localhost:3000/api/auth/${path}`)
207+
208+
const res = await POST(req)
209+
210+
expect(res.status).toBe(404)
211+
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
212+
await expect(res.json()).resolves.toEqual({
213+
error: 'Password reset is handled by application API routes.',
214+
})
215+
})
216+
217+
/** The resend button on /verify calls this directly, so blocking it would break verification. */
218+
it('leaves the verification-code sender reachable for the purpose the product sends', async () => {
219+
const req = createMockRequest(
220+
'POST',
221+
{ email: 'someone@example.com', type: 'email-verification' },
222+
{},
223+
'http://localhost:3000/api/auth/email-otp/send-verification-otp'
224+
)
225+
226+
await POST(req)
227+
228+
expect(handlerMocks.betterAuthPOST).toHaveBeenCalled()
229+
})
230+
231+
/**
232+
* The same endpoint takes the OTP purpose from the body, and `forget-password` there sends reset
233+
* mail to any address named — blocking the reset paths while leaving this open renames the hole.
234+
*/
235+
it.each(['forget-password', 'sign-in', 'change-email'])(
236+
'refuses the verification sender asked for %s',
237+
async (type) => {
238+
const req = createMockRequest(
239+
'POST',
240+
{ email: 'victim@example.com', type },
241+
{},
242+
'http://localhost:3000/api/auth/email-otp/send-verification-otp'
243+
)
244+
245+
expect((await POST(req)).status).toBe(404)
246+
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
247+
}
248+
)
249+
250+
it('refuses the verification sender when the body cannot be read', async () => {
251+
const req = createMockRequest(
252+
'POST',
253+
undefined,
254+
{},
255+
'http://localhost:3000/api/auth/email-otp/send-verification-otp'
256+
)
257+
258+
expect((await POST(req)).status).toBe(404)
259+
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
260+
})
261+
})
262+
193263
describe('auth catch-all route organization mutations', () => {
194264
beforeEach(() => {
195265
vi.clearAllMocks()

apps/sim/app/api/auth/[...all]/route.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,42 @@ export const dynamic = 'force-dynamic'
1515

1616
const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
1717
const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active'])
18+
/**
19+
* Password-reset mail the plugin would send under a name Sim does not own.
20+
*
21+
* `/api/auth/forget-password` is an application route that owns the per-recipient budget (5 per 15
22+
* minutes, keyed on the address) and writes the `PASSWORD_RESET_REQUESTED` audit record. Every
23+
* plugin alias reaches the same mailer with only a per-IP default in front of it, which a caller
24+
* spread across addresses walks straight past, at one victim's mailbox. Matched rather than listed,
25+
* like the SSO and OAuth guards below, so a plugin version that renames or adds an alias cannot
26+
* quietly reopen the path.
27+
*/
28+
function isBlockedPasswordResetPath(path: string): boolean {
29+
return /(^|\/)(request-password-reset|forget-password)(\/|$)/.test(path)
30+
}
31+
32+
/** The one OTP purpose a Sim surface sends: the resend button on `/verify`. */
33+
const ALLOWED_VERIFICATION_OTP_TYPE = 'email-verification'
34+
const VERIFICATION_OTP_SENDER_PATH = 'email-otp/send-verification-otp'
35+
36+
/**
37+
* The same mailer again, reached by asking the verification sender for a different purpose.
38+
*
39+
* `email-otp/send-verification-otp` takes the OTP `type` from the request body, and
40+
* `forget-password` there sends reset mail to any address named — so blocking the reset paths
41+
* above while leaving this one open would only rename the hole. The endpoint stays reachable for
42+
* the purpose the product actually sends, and an unreadable body is refused rather than forwarded.
43+
*/
44+
async function isBlockedVerificationOtpSend(request: NextRequest, path: string): Promise<boolean> {
45+
if (path !== VERIFICATION_OTP_SENDER_PATH) return false
46+
// boundary-raw-json: the plugin owns this endpoint's schema; the guard reads one field to decide whether to forward the request at all
47+
const body = await request
48+
.clone()
49+
.json()
50+
.catch(() => null)
51+
return (body as { type?: unknown } | null)?.type !== ALLOWED_VERIFICATION_OTP_TYPE
52+
}
53+
1854
const OAUTH_CALLBACK_PATH_PREFIX = 'oauth2/callback/'
1955
const UNSUPPORTED_OIDC_PATHS = new Set([
2056
'.well-known/openid-configuration',
@@ -179,6 +215,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
179215
)
180216
}
181217

218+
if (isBlockedPasswordResetPath(path) || (await isBlockedVerificationOtpSend(request, path))) {
219+
return NextResponse.json(
220+
{ error: 'Password reset is handled by application API routes.' },
221+
{ status: 404 }
222+
)
223+
}
224+
182225
if (isBlockedOAuthProviderMutationPath(path)) {
183226
return NextResponse.json(
184227
{ error: 'OAuth client registration is not available.' },

apps/sim/app/api/auth/forget-password/route.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ vi.mock('@sim/logger', () => ({
6464
setRequestAuth: vi.fn(),
6565
}))
6666

67+
import { APIError } from 'better-auth/api'
6768
import { POST } from '@/app/api/auth/forget-password/route'
6869

6970
describe('Forget Password API Route', () => {
@@ -210,6 +211,24 @@ describe('Forget Password API Route', () => {
210211
expect(mockRequestPasswordReset).not.toHaveBeenCalled()
211212
})
212213

214+
/**
215+
* The route answers identically whether or not an account exists, so a refusal must not become a
216+
* status the success path never produces — that alone would tell a caller which addresses are
217+
* registered. It is logged rather than surfaced.
218+
*/
219+
it('answers a refusal Better Auth raises the way it answers a success', async () => {
220+
mockRequestPasswordReset.mockRejectedValue(
221+
new APIError('BAD_REQUEST', { message: 'invalid email' })
222+
)
223+
224+
const response = await POST(createMockRequest('POST', { email: 'someone@example.com' }))
225+
226+
expect(response.status).toBe(200)
227+
await expect(response.json()).resolves.toEqual({ success: true })
228+
expect(mockLogger.error).not.toHaveBeenCalled()
229+
expect(mockLogger.warn).toHaveBeenCalled()
230+
})
231+
213232
it('should handle auth service error with message', async () => {
214233
const errorMessage = 'User not found'
215234

@@ -223,7 +242,9 @@ describe('Forget Password API Route', () => {
223242
const data = await response.json()
224243

225244
expect(response.status).toBe(500)
226-
expect(data.message).toBe(errorMessage)
245+
/** An unrecognized failure is ours, and its wording is not for an unauthenticated caller. */
246+
expect(data.message).toBe('Failed to send password reset email. Please try again later.')
247+
expect(data.message).not.toContain(errorMessage)
227248

228249
expect(mockLogger.error).toHaveBeenCalledWith('Error requesting password reset:', {
229250
error: expect.any(Error),

apps/sim/app/api/auth/forget-password/route.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server'
77
import { forgetPasswordContract } from '@/lib/api/contracts'
88
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
99
import { auth } from '@/lib/auth'
10+
import { getBetterAuthClientErrorStatus } from '@/lib/auth/better-auth-error'
1011
import {
1112
enforceIpRateLimitWithIndependentBackstop,
1213
enforceRecipientRateLimit,
@@ -92,18 +93,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9293

9394
return NextResponse.json({ success: true })
9495
} catch (error) {
96+
/**
97+
* A refusal Better Auth raises is not a server fault, but it must not become a distinguishable
98+
* answer either: this route replies identically whether or not an account exists, and a status
99+
* the success path never produces would tell a caller which addresses are registered. So it is
100+
* logged and answered like a success — only the reset half, where the caller already holds the
101+
* token and has nothing left to enumerate, surfaces the refusal.
102+
*/
103+
const clientStatus = getBetterAuthClientErrorStatus(error)
104+
if (clientStatus !== undefined) {
105+
logger.warn('Rejected a password reset request', { status: clientStatus })
106+
return NextResponse.json({ success: true })
107+
}
108+
95109
logger.error('Error requesting password reset:', { error })
96110

97111
return NextResponse.json(
98-
{
99-
message:
100-
// utils-lint-allow: returned to an unauthenticated caller, so a non-Error throw
101-
// must surface the fixed copy rather than its own text — getErrorMessage would
102-
// pass a thrown string straight through.
103-
error instanceof Error
104-
? error.message
105-
: 'Failed to send password reset email. Please try again later.',
106-
},
112+
{ message: 'Failed to send password reset email. Please try again later.' },
107113
{ status: 500 }
108114
)
109115
}

apps/sim/app/api/auth/reset-password/route.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ vi.mock('@sim/logger', () => ({
4646
setRequestAuth: vi.fn(),
4747
}))
4848

49+
import { APIError } from 'better-auth/api'
4950
import { POST } from '@/app/api/auth/reset-password/route'
5051

5152
describe('Reset Password API Route', () => {
@@ -160,6 +161,22 @@ describe('Reset Password API Route', () => {
160161
expect(mockResetPassword).not.toHaveBeenCalled()
161162
})
162163

164+
it('refuses an invalid or expired token with a 400, not a server error', async () => {
165+
// Better Auth reports a consumed, expired, or fabricated token as a 400-class APIError.
166+
// Re-emitting that as a 500 paged on a routine click of a stale reset link.
167+
mockResetPassword.mockRejectedValue(new APIError('BAD_REQUEST', { message: 'invalid token' }))
168+
169+
const response = await POST(
170+
createMockRequest('POST', { token: 'expired-token', newPassword: 'newSecurePassword123!' })
171+
)
172+
173+
expect(response.status).toBe(400)
174+
await expect(response.json()).resolves.toMatchObject({
175+
message: 'This reset link is invalid or has expired. Please request a new one.',
176+
})
177+
expect(mockLogger.error).not.toHaveBeenCalled()
178+
})
179+
163180
it('should handle auth service error with message', async () => {
164181
const errorMessage = 'Invalid or expired token'
165182

@@ -174,7 +191,11 @@ describe('Reset Password API Route', () => {
174191
const data = await response.json()
175192

176193
expect(response.status).toBe(500)
177-
expect(data.message).toBe(errorMessage)
194+
/** An unrecognized failure is ours, and its wording is not for an unauthenticated caller. */
195+
expect(data.message).toBe(
196+
'Failed to reset password. Please try again or request a new reset link.'
197+
)
198+
expect(data.message).not.toContain(errorMessage)
178199

179200
expect(mockLogger.error).toHaveBeenCalledWith('Error during password reset:', {
180201
error: expect.any(Error),

apps/sim/app/api/auth/reset-password/route.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { resetPasswordContract } from '@/lib/api/contracts'
44
import { parseRequest } from '@/lib/api/server'
55
import { auth } from '@/lib/auth'
6+
import { getBetterAuthClientErrorStatus } from '@/lib/auth/better-auth-error'
67
import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89

@@ -55,18 +56,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5556

5657
return NextResponse.json({ success: true })
5758
} catch (error) {
59+
/** An expired or reused token is the caller's to fix; the fixed copy names the recovery. */
60+
const clientStatus = getBetterAuthClientErrorStatus(error)
61+
if (clientStatus !== undefined) {
62+
logger.warn('Rejected a password reset', { status: clientStatus })
63+
return NextResponse.json(
64+
{ message: 'This reset link is invalid or has expired. Please request a new one.' },
65+
{ status: 400 }
66+
)
67+
}
68+
5869
logger.error('Error during password reset:', { error })
5970

6071
return NextResponse.json(
61-
{
62-
message:
63-
// utils-lint-allow: returned to an unauthenticated caller, so a non-Error throw
64-
// must surface the fixed copy rather than its own text — getErrorMessage would
65-
// pass a thrown string straight through.
66-
error instanceof Error
67-
? error.message
68-
: 'Failed to reset password. Please try again or request a new reset link.',
69-
},
72+
{ message: 'Failed to reset password. Please try again or request a new reset link.' },
7073
{ status: 500 }
7174
)
7275
}

apps/sim/app/api/auth/socket-token/route.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
33
import { headers } from 'next/headers'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import { auth } from '@/lib/auth'
6+
import { getBetterAuthClientErrorStatus } from '@/lib/auth/better-auth-error'
67
import { isAuthDisabled } from '@/lib/core/config/env-flags'
78
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
89
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -40,14 +41,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4041

4142
return NextResponse.json({ token: response.token })
4243
} catch (error) {
43-
// better-auth's sessionMiddleware throws APIError("UNAUTHORIZED") with no message
44-
// when the session is missing/expired — surface this as a 401, not a 500.
45-
if (
46-
error instanceof Error &&
47-
('statusCode' in error || 'status' in error) &&
48-
((error as Record<string, unknown>).statusCode === 401 ||
49-
(error as Record<string, unknown>).status === 'UNAUTHORIZED')
50-
) {
44+
/**
45+
* better-auth's sessionMiddleware throws `APIError("UNAUTHORIZED")` with no message when the
46+
* session is missing or expired — surface that as a 401, not a 500.
47+
*/
48+
if (getBetterAuthClientErrorStatus(error) === 401) {
5149
logger.warn('Socket token request with invalid/expired session')
5250
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
5351
}

apps/sim/app/api/desktop/auth/handoff/route.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
33
import { headers } from 'next/headers'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import { auth } from '@/lib/auth'
6+
import { getBetterAuthClientErrorStatus } from '@/lib/auth/better-auth-error'
67
import { createDesktopHandoffToken } from '@/lib/auth/desktop-handoff'
78
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
89
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -43,15 +44,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4344
const token = await createDesktopHandoffToken(session.user.id)
4445
return NextResponse.json({ token })
4546
} catch (error) {
46-
// Session creation runs the app's own `session.create.before` hook, which
47-
// rejects access-controlled accounts with a Better Auth APIError. That is a
48-
// permanent refusal, not a server fault — a 500 would tell the user to try
49-
// again forever.
50-
if (
51-
error instanceof Error &&
52-
'statusCode' in error &&
53-
(error as Record<string, unknown>).statusCode === 403
54-
) {
47+
/**
48+
* Session creation runs the app's own `session.create.before` hook, which rejects
49+
* access-controlled accounts with a Better Auth `APIError`. That is a permanent refusal, not a
50+
* server fault — a 500 would tell the user to try again forever.
51+
*/
52+
if (getBetterAuthClientErrorStatus(error) === 403) {
5553
logger.warn('Desktop handoff refused for this account', { userId: session.user.id })
5654
return NextResponse.json(
5755
{ error: getErrorMessage(error, 'Access restricted') },

apps/sim/app/api/organizations/[id]/permission-groups/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export async function authorizeOrgAccessControl(
3131
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
3232
}
3333

34+
/**
35+
* The feature gate, deliberately, not the governance reader: the Access Control settings page is
36+
* gated on the same plan check, so reading governance here would open the API for a past-due
37+
* organization whose page still 404s. Restrictions keep applying through a dunning window —
38+
* that is what the governance reader is for — but managing them follows the page.
39+
*/
3440
const entitled = await isOrganizationOnEnterprisePlan(organizationId)
3541
if (!entitled) {
3642
return NextResponse.json({ error: 'Access Control is an Enterprise feature' }, { status: 403 })

apps/sim/app/api/permission-groups/user/route.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({
2121
vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwner: mocks.admin }))
2222
vi.mock('@/lib/billing/core/subscription', () => ({
2323
isOrganizationOnEnterprisePlan: mocks.enterprise,
24+
/** Permission resolution reads the governance axis; these tests drive both from one knob. */
25+
isOrganizationGovernanceActive: mocks.enterprise,
2426
}))
2527
vi.mock('@/lib/permission-groups/resolve.server', async (importOriginal) => ({
2628
...(await importOriginal<typeof import('@/lib/permission-groups/resolve.server')>()),
@@ -167,10 +169,11 @@ describe('user permission policy shared read', () => {
167169
expect(await (await get()).json()).toEqual({ ...unrestricted, entitled: true })
168170
})
169171
it('does not turn policy infrastructure failures into unrestricted access', async () => {
170-
mocks.enterprise.mockImplementation(async (_organizationId, onError) => {
171-
if (onError === 'throw') throw new Error('unavailable')
172-
return false
173-
})
172+
/**
173+
* The governance reader has no lenient mode — answering `false` on a failed read would mean
174+
* "no permission group", which denies nothing — so a failure here is simply a rejection.
175+
*/
176+
mocks.enterprise.mockRejectedValue(new Error('unavailable'))
174177
expect((await get()).status).toBe(500)
175178
await expect(
176179
readUserPermissionConfig.execute({ principal, input: { workspaceId: 'workspace' } })

0 commit comments

Comments
 (0)