security: fix XSS, path traversal, account oracle, email injection

- Escape all DB values in embed widget innerHTML (XSS via pet names)
- Remove account existence oracle from login error messages
- Derive upload extension from MIME type, not client filename
- Add path traversal guard in deleteUpload
- Restrict mustChangePassword bypass to /api/worker/ only
- HTML-escape all user input in outbound email templates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 12:57:52 -05:00
co-authored by Claude Opus 4.6
parent dbb7a0348b
commit b14c8c6436
5 changed files with 30 additions and 33 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ export const handle: Handle = async ({ event, resolve }) => {
// Force password change
if (event.locals.user?.mustChangePassword) {
const path = event.url.pathname;
if (path !== '/change-password' && path !== '/logout' && !path.startsWith('/api/')) {
if (path !== '/change-password' && path !== '/logout' && !path.startsWith('/api/worker/')) {
throw redirect(302, '/change-password');
}
}
+16 -12
View File
@@ -1,5 +1,9 @@
import nodemailer from 'nodemailer';
function escHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function getTransporter() {
if (!process.env.SMTP_USER) return null;
@@ -33,8 +37,8 @@ export async function sendApplicationNotification(
const subject = `[${orgName}] New ${type} application from ${applicantName}`;
const html = `
<h2>New ${type} Application</h2>
<p><strong>Applicant:</strong> ${applicantName}</p>
${petName ? `<p><strong>Pet:</strong> ${petName}</p>` : ''}
<p><strong>Applicant:</strong> ${escHtml(applicantName)}</p>
${petName ? `<p><strong>Pet:</strong> ${escHtml(petName)}</p>` : ''}
<p>Log in to your admin dashboard to review this application.</p>
`;
await sendEmail(staffEmail, subject, html);
@@ -51,8 +55,8 @@ export async function sendApplicationStatusUpdate(
const subject = `[${orgName}] Your ${type} application has been ${status}`;
const html = `
<h2>Application Update</h2>
<p>Hi ${applicantName},</p>
<p>Your ${type} application has been <strong>${status}</strong>.</p>
<p>Hi ${escHtml(applicantName)},</p>
<p>Your ${escHtml(type)} application has been <strong>${escHtml(status)}</strong>.</p>
<p>Visit <a href="${siteUrl}">${siteUrl}</a> for more information.</p>
`;
await sendEmail(to, subject, html);
@@ -87,9 +91,9 @@ export async function sendRegistrationNotification(
const subject = `[${orgName}] New registration request from ${name}`;
const html = `
<h2>New Registration Request</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Reason:</strong> ${reason}</p>
<p><strong>Name:</strong> ${escHtml(name)}</p>
<p><strong>Email:</strong> ${escHtml(email)}</p>
<p><strong>Reason:</strong> ${escHtml(reason)}</p>
<p>Log in to your admin dashboard to approve or deny this request.</p>
`;
await sendEmail(staffEmail, subject, html);
@@ -104,7 +108,7 @@ export async function sendRegistrationApproved(
const subject = `[${orgName}] Your account has been approved!`;
const html = `
<h2>Account Approved</h2>
<p>Hi ${name}, your account at ${orgName} has been approved.</p>
<p>Hi ${escHtml(name)}, your account at ${escHtml(orgName)} has been approved.</p>
<p>Log in at: <a href="${loginUrl}">${loginUrl}</a></p>
`;
await sendEmail(to, subject, html);
@@ -118,7 +122,7 @@ export async function sendRegistrationDenied(
const subject = `[${orgName}] Registration update`;
const html = `
<h2>Registration Update</h2>
<p>Hi ${name}, unfortunately your registration request at ${orgName} was not approved at this time.</p>
<p>Hi ${escHtml(name)}, unfortunately your registration request at ${escHtml(orgName)} was not approved at this time.</p>
<p>If you believe this is an error, please contact us.</p>
`;
await sendEmail(to, subject, html);
@@ -204,9 +208,9 @@ export async function sendContactNotification(
const subject = `[${orgName}] Contact form: ${messageSubject || 'New message'}`;
const html = `
<h2>Contact Form Message</h2>
<p><strong>From:</strong> ${senderName} (${senderEmail})</p>
<p><strong>Subject:</strong> ${messageSubject || 'N/A'}</p>
<p>${message}</p>
<p><strong>From:</strong> ${escHtml(senderName)} (${escHtml(senderEmail)})</p>
<p><strong>Subject:</strong> ${escHtml(messageSubject || 'N/A')}</p>
<p>${escHtml(message)}</p>
`;
await sendEmail(staffEmail, subject, html);
}
+7 -2
View File
@@ -40,7 +40,10 @@ export async function saveUpload(
throw new Error('File content does not match declared type');
}
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
const mimeToExt: Record<string, string> = {
'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif'
};
const ext = mimeToExt[file.type] ?? 'jpg';
const filename = `${crypto.randomUUID()}.${ext}`;
const dir = path.join(UPLOAD_DIR, subdir);
@@ -57,7 +60,9 @@ export async function saveUpload(
export async function deleteUpload(url: string): Promise<void> {
if (!url.startsWith('/uploads/')) return;
const filePath = path.join(UPLOAD_DIR, url.replace('/uploads/', ''));
const filePath = path.resolve(path.join(UPLOAD_DIR, url.replace('/uploads/', '')));
const base = path.resolve(UPLOAD_DIR);
if (!filePath.startsWith(base + path.sep)) return;
try {
await fs.unlink(filePath);
} catch {
+6 -4
View File
@@ -29,6 +29,8 @@ export const GET: RequestHandler = async ({ url }) => {
var container = document.getElementById('fosterflow-pets');
if (!container) { console.warn('FosterFlow: #fosterflow-pets not found'); return; }
function esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
var limit = parseInt(container.dataset.limit || '12');
var species = container.dataset.species || '';
var columns = parseInt(container.dataset.columns || '3');
@@ -72,13 +74,13 @@ export const GET: RequestHandler = async ({ url }) => {
var card = document.createElement('div');
card.className = 'ff-card';
var photoHtml = pet.photoUrl
? '<img src="' + '${siteUrl}' + pet.photoUrl + '" alt="' + pet.name + '">'
? '<img src="' + '${siteUrl}' + encodeURI(pet.photoUrl) + '" alt="' + esc(pet.name) + '">'
: '<span class="ff-placeholder">🐾</span>';
card.innerHTML = '<a href="${siteUrl}/pets/' + pet.slug + '" target="_blank">' +
card.innerHTML = '<a href="${siteUrl}/pets/' + encodeURIComponent(pet.slug) + '" target="_blank">' +
'<div class="ff-img">' + photoHtml + '</div>' +
'<div class="ff-info">' +
'<p class="ff-name">' + pet.name + '</p>' +
'<p class="ff-meta">' + (pet.breed || pet.species) + (pet.age ? ' · ' + pet.age : '') + ' · ' + pet.sex + '</p>' +
'<p class="ff-name">' + esc(pet.name) + '</p>' +
'<p class="ff-meta">' + esc(pet.breed || pet.species) + (pet.age ? ' · ' + esc(pet.age) : '') + ' · ' + esc(pet.sex) + '</p>' +
'</div></a>';
grid.appendChild(card);
});
-14
View File
@@ -30,20 +30,6 @@ export const actions: Actions = {
const user = await authenticateUser(email, password);
if (!user) {
// Check if account exists but is inactive (pending approval)
if (email) {
const [existing] = await db
.select({ id: users.id, active: users.active })
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
if (existing && !existing.active) {
return fail(401, {
error: "Your account is pending approval. You'll receive an email when it's been reviewed.",
email
});
}
}
return fail(401, { error: 'Invalid email or password', email });
}