security hardening
This commit is contained in:
@@ -12,6 +12,8 @@ import {
|
||||
} from "./emailTemplates";
|
||||
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
|
||||
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
|
||||
import { decryptField } from "../crypto/fieldCrypto.server";
|
||||
import { optionalEnv } from "../config/env.server";
|
||||
|
||||
export interface SendInvoiceEmailArgs {
|
||||
shopDomain: string;
|
||||
@@ -86,7 +88,7 @@ export async function sendInvoiceEmail(
|
||||
const customSubject =
|
||||
(language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe) ||
|
||||
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
|
||||
const subject = renderTemplate(customSubject, vars);
|
||||
const subject = renderTemplate(customSubject, vars, { html: false });
|
||||
|
||||
const customBodyHtml =
|
||||
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
|
||||
@@ -124,10 +126,13 @@ export async function sendInvoiceEmail(
|
||||
}
|
||||
|
||||
try {
|
||||
// Optional archival BCC. Off by default for privacy/GDPR; set INVOICE_BCC
|
||||
// to a comma-separated address list to opt in.
|
||||
const bcc = optionalEnv("INVOICE_BCC");
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to,
|
||||
bcc: "shop@linumiq.com",
|
||||
...(bcc ? { bcc } : {}),
|
||||
replyTo: settings.smtpReplyTo || undefined,
|
||||
subject,
|
||||
text: body.text,
|
||||
@@ -180,7 +185,7 @@ function buildTransport(settings: ShopSettings): Transporter {
|
||||
port: settings.smtpPort,
|
||||
secure: settings.smtpSecure,
|
||||
auth: settings.smtpUser
|
||||
? { user: settings.smtpUser, pass: settings.smtpPassword }
|
||||
? { user: settings.smtpUser, pass: decryptField(settings.smtpPassword) }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
@@ -286,13 +291,49 @@ function buildTemplateVars(args: {
|
||||
|
||||
/**
|
||||
* Substitutes {{token}} placeholders in `template`. Unknown tokens are left
|
||||
* in place so the user notices typos instead of silent blanks. Values are
|
||||
* inserted verbatim — callers are responsible for HTML-escaping if needed.
|
||||
* in place so the user notices typos instead of silent blanks.
|
||||
*
|
||||
* For HTML output (the default), every interpolated value is HTML-escaped to
|
||||
* prevent stored-XSS from merchant- or customer-derived data bleeding into the
|
||||
* email body. URL-valued tokens that land inside `href` attributes are scheme-
|
||||
* validated first: `shopWebsite` must be an `https:` URL and `shopEmail` must
|
||||
* look like a bare email address (rendered after a `mailto:` prefix); anything
|
||||
* else renders empty so a hostile `javascript:`/`data:` value can't be planted.
|
||||
*
|
||||
* Pass `{ html: false }` for plain-text contexts (e.g. the subject line), where
|
||||
* the raw value is substituted without HTML entity encoding.
|
||||
*/
|
||||
function renderTemplate(template: string, vars: TemplateVars): string {
|
||||
const URL_TOKENS = new Set(["shopWebsite"]);
|
||||
const EMAIL_TOKENS = new Set(["shopEmail"]);
|
||||
|
||||
function safeHttpsUrl(value: string): string {
|
||||
const v = value.trim();
|
||||
try {
|
||||
return new URL(v).protocol === "https:" ? v : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function safeEmailAddress(value: string): string {
|
||||
const v = value.trim();
|
||||
return /^[^\s@<>"'/\\]+@[^\s@<>"'/\\]+\.[^\s@<>"'/\\]+$/.test(v) ? v : "";
|
||||
}
|
||||
|
||||
function renderTemplate(
|
||||
template: string,
|
||||
vars: TemplateVars,
|
||||
opts: { html?: boolean } = {},
|
||||
): string {
|
||||
const html = opts.html !== false; // HTML-escape by default
|
||||
return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => {
|
||||
const v = (vars as unknown as Record<string, string | undefined>)[key];
|
||||
return v === undefined ? full : v;
|
||||
const raw = (vars as unknown as Record<string, string | undefined>)[key];
|
||||
if (raw === undefined) return full;
|
||||
if (!html) return raw;
|
||||
let value = raw;
|
||||
if (URL_TOKENS.has(key)) value = safeHttpsUrl(raw);
|
||||
else if (EMAIL_TOKENS.has(key)) value = safeEmailAddress(raw);
|
||||
return escapeHtml(value);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user