Encode sign-in mail as base64 so webmail cannot mangle the verify token. #71
@@ -4,7 +4,7 @@
|
||||
"title": "Community Rule",
|
||||
"author": "MEDLab",
|
||||
"description": "Community governance and rule-building app",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.13",
|
||||
"httpPort": 3000,
|
||||
"healthCheckPath": "/api/health",
|
||||
"memoryLimit": 805306368,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
|
||||
import { magicLinkRequestBodySchema } from "../../../../../lib/server/validation/createFlowSchemas";
|
||||
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
|
||||
|
||||
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
||||
const MAGIC_LINK_TTL_MS = 60 * 60 * 1000;
|
||||
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
||||
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
||||
const SCOPE = "auth.magicLink.request";
|
||||
|
||||
@@ -54,7 +54,15 @@ export async function GET(request: NextRequest) {
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
if (!row || row.expiresAt < new Date()) {
|
||||
if (!row) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=invalid_link",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
if (row.expiresAt < new Date()) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=expired_link",
|
||||
|
||||
@@ -25,7 +25,7 @@ import { readLimitedJson } from "../../../../../lib/server/validation/requestBod
|
||||
import { emailChangeRequestBodySchema } from "../../../../../lib/server/validation/userEmailChangeSchemas";
|
||||
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
|
||||
|
||||
const EMAIL_CHANGE_TTL_MS = 15 * 60 * 1000;
|
||||
const EMAIL_CHANGE_TTL_MS = 60 * 60 * 1000;
|
||||
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
||||
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
||||
const SCOPE = "user.emailChange.request";
|
||||
|
||||
+12
-6
@@ -18,17 +18,22 @@ export function resolveMailFrom(): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** Plaintext + HTML for one-time verify URLs. HTML `href` survives quoted-printable wrapping. */
|
||||
/** Avoid quoted-printable `token=3D` wrapping that some webmail clients do not decode. */
|
||||
export const MAIL_TEXT_ENCODING = "base64" as const;
|
||||
|
||||
/** Plaintext + HTML for one-time verify URLs. */
|
||||
export function buildVerifyLinkParts(
|
||||
verifyUrl: string,
|
||||
intro: string,
|
||||
outro: string,
|
||||
linkLabel: string,
|
||||
): { text: string; html: string } {
|
||||
const text = `${intro}\n\n${verifyUrl}\n\n${outro}`;
|
||||
const href = escapeHtml(verifyUrl);
|
||||
const text = `${intro}\n\n<${verifyUrl}>\n\n${outro}`;
|
||||
const html =
|
||||
`<p>${escapeHtml(intro).replace(/\n/g, "<br />")}</p>` +
|
||||
`<p><a href="${escapeHtml(verifyUrl)}">${escapeHtml(linkLabel)}</a></p>` +
|
||||
`<p><a href="${href}">${escapeHtml(linkLabel)}</a></p>` +
|
||||
`<p><a href="${href}">${href}</a></p>` +
|
||||
`<p>${escapeHtml(outro)}</p>`;
|
||||
return { text, html };
|
||||
}
|
||||
@@ -59,6 +64,7 @@ async function sendHtmlMail(opts: {
|
||||
subject: opts.subject,
|
||||
text: opts.text,
|
||||
html: opts.html,
|
||||
textEncoding: MAIL_TEXT_ENCODING,
|
||||
replyTo: opts.replyTo,
|
||||
});
|
||||
}
|
||||
@@ -69,7 +75,7 @@ export async function sendMagicLinkEmail(
|
||||
): Promise<void> {
|
||||
const { text, html } = buildVerifyLinkParts(
|
||||
verifyUrl,
|
||||
"Open this link to sign in (it expires in 15 minutes):",
|
||||
"Open this link to sign in (it expires in 60 minutes):",
|
||||
"If you did not request this, you can ignore this email.",
|
||||
"Sign in",
|
||||
);
|
||||
@@ -90,7 +96,7 @@ export async function sendRuleStakeholderInviteEmail(
|
||||
): Promise<void> {
|
||||
const { text, html } = buildVerifyLinkParts(
|
||||
verifyUrl,
|
||||
`You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:`,
|
||||
`You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 60 minutes and works once:`,
|
||||
"If you did not expect this, you can ignore this email.",
|
||||
"Open the rule",
|
||||
);
|
||||
@@ -134,7 +140,7 @@ export async function sendEmailChangeEmail(
|
||||
): Promise<void> {
|
||||
const { text, html } = buildVerifyLinkParts(
|
||||
verifyUrl,
|
||||
"You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):",
|
||||
"You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 60 minutes):",
|
||||
"If you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.",
|
||||
"Confirm email",
|
||||
);
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/** Parity with magic-link request TTL (15 minutes). */
|
||||
export const STAKEHOLDER_INVITE_TTL_MS = 15 * 60 * 1000;
|
||||
/** Parity with magic-link request TTL (60 minutes). */
|
||||
export const STAKEHOLDER_INVITE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
@@ -240,6 +240,14 @@ describe("LoginForm", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows URL-driven error for invalid_link", () => {
|
||||
navMock.searchParams = new URLSearchParams("error=invalid_link");
|
||||
renderLoginForm();
|
||||
expect(
|
||||
screen.getByText(/that sign-in link is not valid/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls router.replace to clear error query when user types (full-page /login)", async () => {
|
||||
const user = userEvent.setup();
|
||||
navMock.pathname = "/login";
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const getSessionPepperMock = vi.fn();
|
||||
const hashSessionTokenMock = vi.fn();
|
||||
const findUniqueMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
const upsertMock = vi.fn();
|
||||
const createSessionForUserMock = vi.fn();
|
||||
const setSessionCookieMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||
getSessionPepper: () => getSessionPepperMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/hash", () => ({
|
||||
hashSessionToken: (...args: unknown[]) => hashSessionTokenMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/session", () => ({
|
||||
createSessionForUser: (...args: unknown[]) =>
|
||||
createSessionForUserMock(...args),
|
||||
setSessionCookie: (...args: unknown[]) => setSessionCookieMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/server/db", () => ({
|
||||
prisma: {
|
||||
magicLinkToken: {
|
||||
findUnique: (...args: unknown[]) => findUniqueMock(...args),
|
||||
delete: (...args: unknown[]) => deleteMock(...args),
|
||||
},
|
||||
user: {
|
||||
upsert: (...args: unknown[]) => upsertMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "../../app/api/auth/magic-link/verify/route";
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
getSessionPepperMock.mockReset();
|
||||
hashSessionTokenMock.mockReset();
|
||||
findUniqueMock.mockReset();
|
||||
deleteMock.mockReset();
|
||||
upsertMock.mockReset();
|
||||
createSessionForUserMock.mockReset();
|
||||
setSessionCookieMock.mockReset();
|
||||
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getSessionPepperMock.mockReturnValue("pepper");
|
||||
hashSessionTokenMock.mockReturnValue("token-hash");
|
||||
});
|
||||
|
||||
function getWithToken(token: string) {
|
||||
return new NextRequest(
|
||||
`https://x.test/api/auth/magic-link/verify?token=${encodeURIComponent(token)}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("GET /api/auth/magic-link/verify", () => {
|
||||
it("redirects with invalid_link when the token is missing", async () => {
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/auth/magic-link/verify"),
|
||||
);
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain("error=invalid_link");
|
||||
});
|
||||
|
||||
it("redirects with invalid_link when no row matches", async () => {
|
||||
findUniqueMock.mockResolvedValue(null);
|
||||
const res = await GET(getWithToken("a-token-value-long-enough"));
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain("error=invalid_link");
|
||||
});
|
||||
|
||||
it("redirects with expired_link when the row is past expiresAt", async () => {
|
||||
findUniqueMock.mockResolvedValue({
|
||||
id: "row-1",
|
||||
email: "a@b.c",
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
nextPath: null,
|
||||
draftPayload: null,
|
||||
});
|
||||
const res = await GET(getWithToken("a-token-value-long-enough"));
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain("error=expired_link");
|
||||
});
|
||||
});
|
||||
+23
-13
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import nodemailer from "nodemailer";
|
||||
import {
|
||||
MAIL_TEXT_ENCODING,
|
||||
buildVerifyLinkParts,
|
||||
resolveMailFrom,
|
||||
} from "../../lib/server/mail";
|
||||
@@ -8,12 +9,17 @@ import {
|
||||
const VERIFY_URL =
|
||||
"https://staging.communityrule.info/api/auth/magic-link/verify?token=5IdE_BHowaw-QJj7Rwue7CbB8wDXvYITvnxRb1FGqxA";
|
||||
|
||||
function decodeQuotedPrintable(value: string): string {
|
||||
return value
|
||||
.replace(/=\r?\n/g, "")
|
||||
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
);
|
||||
function decodeBase64Parts(raw: string): string {
|
||||
const blocks = [
|
||||
...raw.matchAll(
|
||||
/Content-Transfer-Encoding:\s*base64\s*\r?\n\r?\n([A-Za-z0-9+/=\s]+)/gi,
|
||||
),
|
||||
];
|
||||
return blocks
|
||||
.map((match) =>
|
||||
Buffer.from(match[1].replace(/\s/g, ""), "base64").toString("utf8"),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const MAIL_FROM_KEYS = ["SMTP_FROM", "CLOUDRON_MAIL_FROM"] as const;
|
||||
@@ -30,15 +36,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("buildVerifyLinkParts", () => {
|
||||
it("puts the exact verify URL in both text and the HTML href", () => {
|
||||
it("puts the exact verify URL in text, href, and visible HTML link", () => {
|
||||
const { text, html } = buildVerifyLinkParts(
|
||||
VERIFY_URL,
|
||||
"Open this link to sign in (it expires in 15 minutes):",
|
||||
"Open this link to sign in (it expires in 60 minutes):",
|
||||
"If you did not request this, you can ignore this email.",
|
||||
"Sign in",
|
||||
);
|
||||
expect(text).toContain(VERIFY_URL);
|
||||
expect(text).toContain(`<${VERIFY_URL}>`);
|
||||
expect(html).toContain(`href="${VERIFY_URL}"`);
|
||||
expect(html).toContain(`>${VERIFY_URL}</a>`);
|
||||
expect(html).toContain(">Sign in</a>");
|
||||
});
|
||||
|
||||
@@ -57,10 +64,10 @@ describe("buildVerifyLinkParts", () => {
|
||||
});
|
||||
|
||||
describe("MIME encoding of verify-link mail", () => {
|
||||
it("keeps a clickable href after quoted-printable encoding", async () => {
|
||||
it("uses base64 so the raw MIME never contains token=3D", async () => {
|
||||
const { text, html } = buildVerifyLinkParts(
|
||||
VERIFY_URL,
|
||||
"Open this link to sign in (it expires in 15 minutes):",
|
||||
"Open this link to sign in (it expires in 60 minutes):",
|
||||
"If you did not request this, you can ignore this email.",
|
||||
"Sign in",
|
||||
);
|
||||
@@ -75,14 +82,17 @@ describe("MIME encoding of verify-link mail", () => {
|
||||
subject: "Sign in to Community Rule",
|
||||
text,
|
||||
html,
|
||||
textEncoding: MAIL_TEXT_ENCODING,
|
||||
});
|
||||
const raw = Buffer.isBuffer(info.message)
|
||||
? info.message.toString("utf8")
|
||||
: String(info.message);
|
||||
const decoded = decodeQuotedPrintable(raw);
|
||||
expect(raw).toMatch(/Content-Transfer-Encoding:\s*base64/i);
|
||||
expect(raw).not.toContain("token=3D");
|
||||
expect(raw).not.toMatch(/quoted-printable/i);
|
||||
const decoded = decodeBase64Parts(raw);
|
||||
expect(decoded).toContain(`href="${VERIFY_URL}"`);
|
||||
expect(decoded).toContain(VERIFY_URL);
|
||||
expect(decoded).not.toContain("token=3D");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user