Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,17 @@ CREATE TABLE page_media (

CREATE INDEX idx_page_media_page ON page_media(page);

-- Password reset tokens
CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(128) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
Comment on lines +189 to +190

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used and created_at are nullable as defined here; that can lead to ambiguous semantics (NULL vs FALSE) and makes queries more error-prone. Consider used BOOLEAN NOT NULL DEFAULT FALSE and created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP (and optionally a UNIQUE constraint/index on token_hash since the API assumes one row per hash).

Suggested change
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,

Copilot uses AI. Check for mistakes.
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);

Comment on lines 182 to +196

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change updates the root schema.sql but doesn’t add a numbered migration in src/services/database/migrations/ (used by the migration runner) and also leaves src/services/database/schema.sql without this table, so schema sources will diverge. Add the next migration (e.g. 009_add_password_reset_tokens.sql) and keep both schema files in sync per the DB docs/README.

Suggested change
-- Password reset tokens
CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(128) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);

Copilot uses AI. Check for mistakes.
131 changes: 131 additions & 0 deletions src/pages/api/auth/forgot-password.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import crypto from 'crypto'
import { dbOneOrNone, dbNone } from 'src/lib/database'
import { sendMail } from 'src/lib/mailer'
import rateLimit from 'src/lib/rateLimit'

const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 })

/**
* POST /api/auth/forgot-password
*
* Generates a secure password-reset token, stores its SHA-256 hash,
* and emails the raw token to the user. Always returns 200 to avoid
* leaking whether an email exists.
*
* Body: { email: string }
*/
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}

// Rate-limit by IP
const { ok } = limiter.check(req)
if (!ok) {
return res.status(429).json({ error: 'Too many requests. Please try again later.' })
}

const { email } = req.body

if (!email || typeof email !== 'string') {
return res.status(400).json({ error: 'Email is required' })
}

const trimmedEmail = email.trim().toLowerCase()
const emailRegex = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
if (trimmedEmail.length > 254 || !emailRegex.test(trimmedEmail)) {
return res.status(400).json({ error: 'Invalid email format' })
}

try {
// Always respond with success to avoid email enumeration
const successMsg = 'If an account with that email exists, a password reset link has been sent.'

const user = await dbOneOrNone(
'SELECT id, name FROM users WHERE LOWER(email) = $1',
[trimmedEmail]
)

if (!user) {
return res.status(200).json({ message: successMsg })
}

// Invalidate any existing unused tokens for this user
await dbNone(
'UPDATE password_reset_tokens SET used = TRUE WHERE user_id = $1 AND used = FALSE',
[user.id]
)

// Generate a cryptographically secure token
const rawToken = crypto.randomBytes(32).toString('hex')

// Store only the SHA-256 hash in DB
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex')

// Token expires in 1 hour
const expiresAt = new Date(Date.now() + 60 * 60 * 1000)

await dbNone(
'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, tokenHash, expiresAt]
)
Comment on lines +53 to +71

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Token invalidation + insert isn’t atomic: concurrent forgot-password requests for the same user can both mark old tokens used and then each insert a new unused token, leaving multiple valid reset links. Wrap the invalidate+insert in a single dbTx with row locking, or enforce a partial unique constraint (e.g., one unused token per user) and handle conflicts deterministically.

Copilot uses AI. Check for mistakes.

// Build reset URL
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const resetUrl = `${baseUrl}/reset-password?token=${rawToken}`

// Send email
await sendMail({
to: trimmedEmail,
subject: 'Reset your Citronics 2026 password',
html: buildResetEmailHtml(user.name, resetUrl),
text: `Hi ${user.name},\n\nYou requested a password reset. Use this link (valid for 1 hour):\n\n${resetUrl}\n\nIf you didn't request this, ignore this email.\n\nCitronics 2026`
})

return res.status(200).json({ message: successMsg })
} catch (err) {
console.error('[ForgotPassword] Error:', err)

return res.status(500).json({ error: 'Something went wrong. Please try again later.' })
}
Comment on lines +86 to +90

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch path returns a 500, which breaks the “always returns 200 to avoid email enumeration” guarantee. If SMTP/sendMail fails only when a user exists (because unknown emails return early), an attacker can infer account existence via 200 vs 500; consider always returning the generic 200 response after validation and logging/queuing the email send failure instead.

Copilot uses AI. Check for mistakes.
}

function buildResetEmailHtml(name, resetUrl) {
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width" /></head>
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:40px 20px;">
<tr><td align="center">
<table width="100%" style="max-width:480px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.06);">
<tr><td style="background:linear-gradient(135deg,#7c3aed,#4f46e5);padding:28px 32px;">
<h1 style="margin:0;color:#ffffff;font-size:20px;font-weight:700;">Citronics 2026</h1>
</td></tr>
<tr><td style="padding:32px;">
<h2 style="margin:0 0 12px;font-size:18px;color:#1a1a2e;">Password Reset</h2>
<p style="margin:0 0 20px;font-size:14px;line-height:1.6;color:#555;">
Hi <strong>${name}</strong>, we received a request to reset your password.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Escape user-provided name in HTML email template.

The name value is interpolated directly into HTML without escaping. If a user registered with a name containing HTML characters (e.g., <script> or &), it could cause rendering issues or, in edge cases, be exploited in email clients that don't fully sanitize HTML.

Proposed fix: Add HTML escaping helper
+function escapeHtml(str) {
+  return str
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+}
+
 function buildResetEmailHtml(name, resetUrl) {
+  const safeName = escapeHtml(name)
   return `
   ...
-              Hi <strong>${name}</strong>, we received a request to reset your password.
+              Hi <strong>${safeName}</strong>, we received a request to reset your password.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/auth/forgot-password.js` at line 108, Escape the user-provided
name before inserting it into the HTML email template to prevent HTML injection:
add or reuse a small HTML-escaping helper (e.g., escapeHtml) and call it when
building the template instead of interpolating raw name; update the email
creation/site-mailing code in src/pages/api/auth/forgot-password.js (the
template that contains Hi <strong>${name}</strong>) to use escapeHtml(name) and
ensure the helper properly replaces &, <, >, ", and ' characters.

Click the button below to choose a new one. This link expires in <strong>1 hour</strong>.
</p>
Comment on lines +106 to +110

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTML email template interpolates name directly into HTML. Since users.name can contain arbitrary characters, this can break markup or enable HTML injection in email clients; escape the name (and any other user-controlled fields) before embedding in HTML (see existing HTML escaping patterns in the repo).

Copilot uses AI. Check for mistakes.
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr><td style="background:#7c3aed;border-radius:8px;">
<a href="${resetUrl}" target="_blank"
style="display:inline-block;padding:12px 28px;color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;">
Reset Password
</a>
</td></tr>
</table>
<p style="margin:0 0 8px;font-size:12px;color:#888;">If the button doesn&rsquo;t work, copy and paste this URL:</p>
<p style="margin:0 0 24px;font-size:12px;color:#7c3aed;word-break:break-all;">${resetUrl}</p>
<hr style="border:none;border-top:1px solid #eee;margin:0 0 16px;" />
<p style="margin:0;font-size:12px;color:#999;line-height:1.5;">
If you didn&rsquo;t request this, you can safely ignore this email. Your password won&rsquo;t change.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`
}
4 changes: 2 additions & 2 deletions src/pages/api/auth/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ export default async function handler(req, res) {
if (!college?.trim()) return res.status(400).json({ error: 'College name is required' })
if (!city?.trim()) return res.status(400).json({ error: 'City is required' })

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) return res.status(400).json({ error: 'Invalid email format' })
const emailRegex = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
if (email.length > 254 || !emailRegex.test(email)) return res.status(400).json({ error: 'Invalid email format' })

if (phone && !/^\+?[\d\s-]{7,20}$/.test(phone)) {
return res.status(400).json({ error: 'Invalid phone number' })
Expand Down
85 changes: 85 additions & 0 deletions src/pages/api/auth/reset-password.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import crypto from 'crypto'
import bcrypt from 'bcryptjs'
import { dbOneOrNone, dbTx } from 'src/lib/database'
import rateLimit from 'src/lib/rateLimit'

const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 })

/**
* POST /api/auth/reset-password
*
* Validates the reset token, checks expiry, and updates the user's password.
* The raw token is hashed with SHA-256 and compared against the stored hash.
*
* Body: { token: string, password: string }
*/
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}

const { ok } = limiter.check(req)
if (!ok) {
return res.status(429).json({ error: 'Too many requests. Please try again later.' })
}

const { token, password } = req.body

if (!token || typeof token !== 'string') {
return res.status(400).json({ error: 'Reset token is required' })
}

if (!password || password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' })
}

try {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex')

const resetRecord = await dbOneOrNone(
`SELECT prt.id, prt.user_id, prt.expires_at, prt.used, u.email
FROM password_reset_tokens prt
JOIN users u ON u.id = prt.user_id
WHERE prt.token_hash = $1`,
[tokenHash]
)

if (!resetRecord) {
return res.status(400).json({ error: 'Invalid or expired reset link. Please request a new one.' })
}

if (resetRecord.used) {
return res.status(400).json({ error: 'This reset link has already been used. Please request a new one.' })
}

if (new Date(resetRecord.expires_at) < new Date()) {
return res.status(400).json({ error: 'This reset link has expired. Please request a new one.' })
}

// Hash the new password
const passwordHash = await bcrypt.hash(password, 12)

// Update password & mark token used in a transaction
await dbTx(async t => {
await t.none(
'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
[passwordHash, resetRecord.user_id]
)
await t.none(
'UPDATE password_reset_tokens SET used = TRUE WHERE id = $1',
[resetRecord.id]
)
// Invalidate all other unused tokens for this user
await t.none(
'UPDATE password_reset_tokens SET used = TRUE WHERE user_id = $1 AND id != $2 AND used = FALSE',
[resetRecord.user_id, resetRecord.id]
)
Comment on lines +39 to +76

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reset token validation isn’t atomic: the code reads used/expires_at outside the transaction and then unconditionally updates the token row, so two concurrent requests can both pass validation and both reset the password before either marks the token used. Make the update conditional (WHERE used = FALSE AND expires_at > NOW()) and verify rowCount/RETURNING, or SELECT … FOR UPDATE inside the tx.

Copilot uses AI. Check for mistakes.
})

return res.status(200).json({ message: 'Password has been reset successfully. You can now sign in.' })
} catch (err) {
console.error('[ResetPassword] Error:', err)

return res.status(500).json({ error: 'Something went wrong. Please try again later.' })
}
}
Loading
Loading