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:
+1
-1
@@ -23,7 +23,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
// Force password change
|
// Force password change
|
||||||
if (event.locals.user?.mustChangePassword) {
|
if (event.locals.user?.mustChangePassword) {
|
||||||
const path = event.url.pathname;
|
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');
|
throw redirect(302, '/change-password');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -1,5 +1,9 @@
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
function escHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
function getTransporter() {
|
function getTransporter() {
|
||||||
if (!process.env.SMTP_USER) return null;
|
if (!process.env.SMTP_USER) return null;
|
||||||
|
|
||||||
@@ -33,8 +37,8 @@ export async function sendApplicationNotification(
|
|||||||
const subject = `[${orgName}] New ${type} application from ${applicantName}`;
|
const subject = `[${orgName}] New ${type} application from ${applicantName}`;
|
||||||
const html = `
|
const html = `
|
||||||
<h2>New ${type} Application</h2>
|
<h2>New ${type} Application</h2>
|
||||||
<p><strong>Applicant:</strong> ${applicantName}</p>
|
<p><strong>Applicant:</strong> ${escHtml(applicantName)}</p>
|
||||||
${petName ? `<p><strong>Pet:</strong> ${petName}</p>` : ''}
|
${petName ? `<p><strong>Pet:</strong> ${escHtml(petName)}</p>` : ''}
|
||||||
<p>Log in to your admin dashboard to review this application.</p>
|
<p>Log in to your admin dashboard to review this application.</p>
|
||||||
`;
|
`;
|
||||||
await sendEmail(staffEmail, subject, html);
|
await sendEmail(staffEmail, subject, html);
|
||||||
@@ -51,8 +55,8 @@ export async function sendApplicationStatusUpdate(
|
|||||||
const subject = `[${orgName}] Your ${type} application has been ${status}`;
|
const subject = `[${orgName}] Your ${type} application has been ${status}`;
|
||||||
const html = `
|
const html = `
|
||||||
<h2>Application Update</h2>
|
<h2>Application Update</h2>
|
||||||
<p>Hi ${applicantName},</p>
|
<p>Hi ${escHtml(applicantName)},</p>
|
||||||
<p>Your ${type} application has been <strong>${status}</strong>.</p>
|
<p>Your ${escHtml(type)} application has been <strong>${escHtml(status)}</strong>.</p>
|
||||||
<p>Visit <a href="${siteUrl}">${siteUrl}</a> for more information.</p>
|
<p>Visit <a href="${siteUrl}">${siteUrl}</a> for more information.</p>
|
||||||
`;
|
`;
|
||||||
await sendEmail(to, subject, html);
|
await sendEmail(to, subject, html);
|
||||||
@@ -87,9 +91,9 @@ export async function sendRegistrationNotification(
|
|||||||
const subject = `[${orgName}] New registration request from ${name}`;
|
const subject = `[${orgName}] New registration request from ${name}`;
|
||||||
const html = `
|
const html = `
|
||||||
<h2>New Registration Request</h2>
|
<h2>New Registration Request</h2>
|
||||||
<p><strong>Name:</strong> ${name}</p>
|
<p><strong>Name:</strong> ${escHtml(name)}</p>
|
||||||
<p><strong>Email:</strong> ${email}</p>
|
<p><strong>Email:</strong> ${escHtml(email)}</p>
|
||||||
<p><strong>Reason:</strong> ${reason}</p>
|
<p><strong>Reason:</strong> ${escHtml(reason)}</p>
|
||||||
<p>Log in to your admin dashboard to approve or deny this request.</p>
|
<p>Log in to your admin dashboard to approve or deny this request.</p>
|
||||||
`;
|
`;
|
||||||
await sendEmail(staffEmail, subject, html);
|
await sendEmail(staffEmail, subject, html);
|
||||||
@@ -104,7 +108,7 @@ export async function sendRegistrationApproved(
|
|||||||
const subject = `[${orgName}] Your account has been approved!`;
|
const subject = `[${orgName}] Your account has been approved!`;
|
||||||
const html = `
|
const html = `
|
||||||
<h2>Account Approved</h2>
|
<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>
|
<p>Log in at: <a href="${loginUrl}">${loginUrl}</a></p>
|
||||||
`;
|
`;
|
||||||
await sendEmail(to, subject, html);
|
await sendEmail(to, subject, html);
|
||||||
@@ -118,7 +122,7 @@ export async function sendRegistrationDenied(
|
|||||||
const subject = `[${orgName}] Registration update`;
|
const subject = `[${orgName}] Registration update`;
|
||||||
const html = `
|
const html = `
|
||||||
<h2>Registration Update</h2>
|
<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>
|
<p>If you believe this is an error, please contact us.</p>
|
||||||
`;
|
`;
|
||||||
await sendEmail(to, subject, html);
|
await sendEmail(to, subject, html);
|
||||||
@@ -204,9 +208,9 @@ export async function sendContactNotification(
|
|||||||
const subject = `[${orgName}] Contact form: ${messageSubject || 'New message'}`;
|
const subject = `[${orgName}] Contact form: ${messageSubject || 'New message'}`;
|
||||||
const html = `
|
const html = `
|
||||||
<h2>Contact Form Message</h2>
|
<h2>Contact Form Message</h2>
|
||||||
<p><strong>From:</strong> ${senderName} (${senderEmail})</p>
|
<p><strong>From:</strong> ${escHtml(senderName)} (${escHtml(senderEmail)})</p>
|
||||||
<p><strong>Subject:</strong> ${messageSubject || 'N/A'}</p>
|
<p><strong>Subject:</strong> ${escHtml(messageSubject || 'N/A')}</p>
|
||||||
<p>${message}</p>
|
<p>${escHtml(message)}</p>
|
||||||
`;
|
`;
|
||||||
await sendEmail(staffEmail, subject, html);
|
await sendEmail(staffEmail, subject, html);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ export async function saveUpload(
|
|||||||
throw new Error('File content does not match declared type');
|
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 filename = `${crypto.randomUUID()}.${ext}`;
|
||||||
const dir = path.join(UPLOAD_DIR, subdir);
|
const dir = path.join(UPLOAD_DIR, subdir);
|
||||||
|
|
||||||
@@ -57,7 +60,9 @@ export async function saveUpload(
|
|||||||
|
|
||||||
export async function deleteUpload(url: string): Promise<void> {
|
export async function deleteUpload(url: string): Promise<void> {
|
||||||
if (!url.startsWith('/uploads/')) return;
|
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 {
|
try {
|
||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
var container = document.getElementById('fosterflow-pets');
|
var container = document.getElementById('fosterflow-pets');
|
||||||
if (!container) { console.warn('FosterFlow: #fosterflow-pets not found'); return; }
|
if (!container) { console.warn('FosterFlow: #fosterflow-pets not found'); return; }
|
||||||
|
|
||||||
|
function esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
var limit = parseInt(container.dataset.limit || '12');
|
var limit = parseInt(container.dataset.limit || '12');
|
||||||
var species = container.dataset.species || '';
|
var species = container.dataset.species || '';
|
||||||
var columns = parseInt(container.dataset.columns || '3');
|
var columns = parseInt(container.dataset.columns || '3');
|
||||||
@@ -72,13 +74,13 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
var card = document.createElement('div');
|
var card = document.createElement('div');
|
||||||
card.className = 'ff-card';
|
card.className = 'ff-card';
|
||||||
var photoHtml = pet.photoUrl
|
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>';
|
: '<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-img">' + photoHtml + '</div>' +
|
||||||
'<div class="ff-info">' +
|
'<div class="ff-info">' +
|
||||||
'<p class="ff-name">' + pet.name + '</p>' +
|
'<p class="ff-name">' + esc(pet.name) + '</p>' +
|
||||||
'<p class="ff-meta">' + (pet.breed || pet.species) + (pet.age ? ' · ' + pet.age : '') + ' · ' + pet.sex + '</p>' +
|
'<p class="ff-meta">' + esc(pet.breed || pet.species) + (pet.age ? ' · ' + esc(pet.age) : '') + ' · ' + esc(pet.sex) + '</p>' +
|
||||||
'</div></a>';
|
'</div></a>';
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,20 +30,6 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const user = await authenticateUser(email, password);
|
const user = await authenticateUser(email, password);
|
||||||
if (!user) {
|
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 });
|
return fail(401, { error: 'Invalid email or password', email });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user