Files
OpenFrame/app/api/auth/register/route.ts
T
yusufipk 33c845636c fix(analytics): sign the acquisition cookies and bound what they can write
Both cookies were read straight into database columns after nothing more than a
format check. httpOnly keeps JavaScript out of them and does nothing about curl,
so the anonymous id was a string the caller picked: enough to write a first-touch
row for a visitor who never existed, to file it under a channel of their
choosing, and to claim that id's events at signup, since the backfill matches on
the id alone.

They are now signed with an HMAC over AUTH_SECRET, through Web Crypto rather than
node:crypto because the proxy runs on the edge and the pages that read the
cookies back run in Node. The first-touch body moved to base64url on the way:
cookie values are percent-encoded and decoded by several layers that do not agree
on how many times, and a payload carrying its own percent escapes comes back
subtly different and takes the signature with it.

Signing stops a caller choosing an id, not collecting one, since dropping the
cookie and asking for the landing page again mints another. So the bot and
prefetch filters moved to where the rows are written rather than only where the
cookies are issued, which also fixes a returning visitor's prefetch of /register
recording a signup start, and a per-client hourly ceiling now sits in front of
the write. The ceiling is skipped when TRUSTED_PROXY_MODE is unset, where every
caller resolves to 127.0.0.1 and the bucket would empty on real traffic long
before it emptied on a flood.

Four smaller things around it:

- /api/events checked the flag and the origin after paying for a rate-limit
  write, so a host who never turned analytics on was still writing a row per
  anonymous POST. Both checks are free and now come first, and the limiter
  answers 204 rather than 429: a beacon has nobody to tell, and a flooder should
  not be handed the reset time.
- /api/onboarding/source was keyed by IP on an authenticated route. Without
  TRUSTED_PROXY_MODE that is five answers an hour for the whole deployment, and
  with it a shared office address locks out everyone after one colleague
  answered. Keyed by account, like /api/onboarding/complete beside it.
- The cookies took their Secure flag from request.nextUrl.protocol, which behind
  a TLS-terminating reverse proxy is the container-internal http address. It
  comes off the configured public origin now.
- sanitizeLandingPath took anything that started with a slash, including from the
  cookie, so a hand-written one could put newlines and markup into a column an
  admin table may render one day.

Also: the paid-account query had no LIMIT and returned every active account's
name and email, the growth route answered 403 where it meant 401, and the schema
claimed no free text is stored when self_reported_note holds 200 characters of it.
2026-08-01 20:29:28 +03:00

175 lines
6.4 KiB
TypeScript

import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { acceptInvitationTokenForUser, getValidInvitationByToken } from '@/lib/invitations';
import {
checkRateLimit,
getClientIp,
rateLimitHeaders,
RATE_LIMIT_CONFIGS,
} from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { isInviteCodeRequired } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
import {
createVerificationToken,
isEmailVerificationEnabled,
sendVerificationEmail,
} from '@/lib/email-verification';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
import { recordSignupCompleted } from '@/lib/analytics/signup';
import { readRequestVisitor } from '@/lib/analytics/visitor';
export async function POST(request: NextRequest) {
try {
// Rate limiting by IP
const clientIp = getClientIp(request);
const rateLimitKey = `register:${clientIp}`;
const rateLimit = await checkRateLimit(rateLimitKey, 'register');
if (!rateLimit.allowed) {
return apiErrors.rateLimited('Too many registration attempts. Please try again later.');
}
const body = await request.json();
const { name, email, password, inviteCode, invitationToken } = body;
// Validate required fields
if (!name || typeof name !== 'string' || name.trim().length < 2 || name.trim().length > 100) {
return apiErrors.badRequest('Name must be between 2 and 100 characters');
}
if (!email || typeof email !== 'string') {
return apiErrors.badRequest('Email is required');
}
const normalizedEmail = normalizeEmail(email);
// Basic email validation
if (!isValidEmailAddress(normalizedEmail)) {
return apiErrors.validationError('Invalid email format');
}
// Allow registration via a valid invitation token OR global invite code.
let invitationIsValid = false;
let validatedInvitationToken: string | null = null;
if (typeof invitationToken === 'string' && invitationToken.trim()) {
const normalizedToken = invitationToken.trim();
const invitation = await getValidInvitationByToken(normalizedToken);
if (invitation && invitation.email === normalizedEmail) {
invitationIsValid = true;
validatedInvitationToken = normalizedToken;
} else {
return apiErrors.forbidden('Invalid or expired invitation token');
}
}
if (!invitationIsValid && isInviteCodeRequired()) {
// Validate invite code using constant-time comparison to prevent timing attacks
const validInviteCode = process.env.INVITE_CODE;
if (!validInviteCode || !inviteCode) {
return apiErrors.forbidden('Invalid invite code');
}
// Constant-time comparison
const { timingSafeEqual } = await import('crypto');
const validBuffer = Buffer.from(validInviteCode);
const providedBuffer = Buffer.from(String(inviteCode));
// Ensure same length for comparison (prevents length-based timing leak)
const isValidLength = validBuffer.length === providedBuffer.length;
const compareBuffer = isValidLength ? providedBuffer : validBuffer;
const isValidCode = isValidLength && timingSafeEqual(validBuffer, compareBuffer);
if (!isValidCode) {
return apiErrors.forbidden('Invalid invite code');
}
}
if (!password || typeof password !== 'string' || password.length < 8 || password.length > 128) {
return apiErrors.badRequest('Password must be between 8 and 128 characters');
}
// Check if email already exists
const existingUser = await db.user.findUnique({
where: { email: normalizedEmail },
});
if (existingUser) {
return apiErrors.conflict('An account with this email already exists');
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// If SMTP is not configured, auto-verify the email so users aren't locked out
const emailVerificationRequired = isEmailVerificationEnabled();
// Create user
const user = await db.user.create({
data: {
name: name.trim(),
email: normalizedEmail,
password: hashedPassword,
emailVerified: emailVerificationRequired ? null : new Date(),
},
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
});
if (validatedInvitationToken) {
const result = await acceptInvitationTokenForUser({
token: validatedInvitationToken,
userId: user.id,
email: normalizedEmail,
});
if (result !== 'accepted') {
await db.user.delete({ where: { id: user.id } });
return apiErrors.conflict(
'Invitation could not be accepted. Please request a new invitation.'
);
}
}
// Ties the account to the first touch stored in this browser's cookie and
// claims the visitor events that led here. Recorded after the invitation has
// been accepted, so an account that gets rolled back never leaves a signup.
await recordSignupCompleted({
userId: user.id,
visitor: await readRequestVisitor(request),
});
// Send verification email if SMTP is configured
if (emailVerificationRequired) {
const verificationToken = await createVerificationToken(normalizedEmail);
// Invited users are sent back to the invitation after verifying, which forwards them
// to the workspace/project they joined instead of the generic dashboard.
await sendVerificationEmail(normalizedEmail, verificationToken, {
next: validatedInvitationToken
? `/invitations/accept?token=${encodeURIComponent(validatedInvitationToken)}`
: undefined,
});
}
const message = emailVerificationRequired
? 'Account created. Please check your email to verify your address before signing in.'
: 'Account created successfully';
const response = successResponse({ message, user, emailVerificationRequired }, 201);
// Add rate limit headers to successful response
const headers = rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests);
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Registration error:', error);
return apiErrors.internalError('Failed to create account');
}
}