mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
fix(security): lock down admin + add bulk cleanup UI
This commit is contained in:
@@ -8,12 +8,25 @@ describe("Admin Routes", () => {
|
||||
let testApp: Hono;
|
||||
let mockEnv: Env;
|
||||
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
||||
let loginAndGetCookie: () => Promise<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnv = createMockEnv();
|
||||
testApp = new Hono();
|
||||
testApp.route("/admin", app);
|
||||
request = (path, init = {}) => testApp.request(path, init, mockEnv);
|
||||
loginAndGetCookie = async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("password", "test-password");
|
||||
const response = await request("/admin/login", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
expect(response.status).toBe(302);
|
||||
const setCookie = response.headers.get("Set-Cookie");
|
||||
expect(setCookie).toBeTruthy();
|
||||
return (setCookie as string).split(";")[0];
|
||||
};
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
@@ -38,11 +51,13 @@ describe("Admin Routes", () => {
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("Location")).toBe("/admin");
|
||||
const cookie = res.headers.get("Set-Cookie");
|
||||
expect(cookie).toContain("admin_auth=true");
|
||||
expect(cookie).toContain("admin_auth=");
|
||||
expect(cookie).toContain("HttpOnly");
|
||||
expect(cookie).toContain("SameSite=Strict");
|
||||
expect(cookie).toContain("Secure");
|
||||
expect(cookie).toContain("Path=/");
|
||||
});
|
||||
|
||||
@@ -73,9 +88,8 @@ describe("Admin Routes", () => {
|
||||
});
|
||||
|
||||
describe("Protected Routes", () => {
|
||||
const authCookie = "admin_auth=true";
|
||||
|
||||
it("should allow access to dashboard with valid auth cookie", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const res = await request("/admin", {
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
@@ -85,6 +99,16 @@ describe("Admin Routes", () => {
|
||||
expect(res.headers.get("Content-Type")).toContain("text/html");
|
||||
});
|
||||
|
||||
it("should reject access with forged auth cookie", async () => {
|
||||
const res = await request("/admin", {
|
||||
headers: {
|
||||
Cookie: "admin_auth=true",
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("Location")).toBe("/admin/login");
|
||||
});
|
||||
|
||||
describe("Feed Creation", () => {
|
||||
it("should prevent feed creation without authentication", async () => {
|
||||
const formData = new FormData();
|
||||
@@ -105,6 +129,7 @@ describe("Admin Routes", () => {
|
||||
});
|
||||
|
||||
it("should allow feed creation with valid authentication", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const formData = new FormData();
|
||||
formData.append("title", "Test Feed");
|
||||
formData.append("description", "Test Description");
|
||||
@@ -118,7 +143,7 @@ describe("Admin Routes", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(302); // Redirects back to dashboard
|
||||
expect(res.headers.get("Location")).toBe("/admin");
|
||||
expect(res.headers.get("Location")).toBe("/admin?view=list");
|
||||
|
||||
// Verify feed was created in KV
|
||||
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
||||
@@ -141,6 +166,7 @@ describe("Admin Routes", () => {
|
||||
});
|
||||
|
||||
it("should reject feed creation with missing title", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const formData = new FormData();
|
||||
formData.append("description", "Test Description");
|
||||
|
||||
@@ -187,6 +213,7 @@ describe("Admin Routes", () => {
|
||||
});
|
||||
|
||||
it("should allow feed deletion with valid authentication", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
// First create a feed
|
||||
const formData = new FormData();
|
||||
formData.append("title", "Test Feed");
|
||||
@@ -218,7 +245,7 @@ describe("Admin Routes", () => {
|
||||
});
|
||||
|
||||
expect(deleteRes.status).toBe(302);
|
||||
expect(deleteRes.headers.get("Location")).toBe("/admin");
|
||||
expect(deleteRes.headers.get("Location")).toBe("/admin?view=list");
|
||||
|
||||
// Verify feed was deleted
|
||||
const updatedFeedList = (await mockEnv.EMAIL_STORAGE.get(
|
||||
@@ -235,6 +262,53 @@ describe("Admin Routes", () => {
|
||||
);
|
||||
expect(feedConfig).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow bulk feed deletion with valid authentication", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
|
||||
for (const title of ["Feed A", "Feed B"]) {
|
||||
const formData = new FormData();
|
||||
formData.append("title", title);
|
||||
formData.append("description", "Test");
|
||||
const createRes = await request("/admin/feeds/create", {
|
||||
method: "POST",
|
||||
headers: { Cookie: authCookie },
|
||||
body: formData,
|
||||
});
|
||||
expect(createRes.status).toBe(302);
|
||||
}
|
||||
|
||||
const feedListBefore = (await mockEnv.EMAIL_STORAGE.get(
|
||||
"feeds:list",
|
||||
"json",
|
||||
)) as {
|
||||
feeds: Array<{ id: string; title: string }>;
|
||||
} | null;
|
||||
expect(feedListBefore?.feeds.length).toBe(2);
|
||||
|
||||
const bulkForm = new FormData();
|
||||
for (const feed of feedListBefore?.feeds || []) {
|
||||
bulkForm.append("feedIds", feed.id);
|
||||
}
|
||||
|
||||
const bulkDeleteRes = await request("/admin/feeds/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { Cookie: authCookie },
|
||||
body: bulkForm,
|
||||
});
|
||||
|
||||
expect(bulkDeleteRes.status).toBe(302);
|
||||
expect(bulkDeleteRes.headers.get("Location")).toContain("/admin?view=list");
|
||||
expect(bulkDeleteRes.headers.get("Location")).toContain("message=bulkDeleted");
|
||||
|
||||
const feedListAfter = (await mockEnv.EMAIL_STORAGE.get(
|
||||
"feeds:list",
|
||||
"json",
|
||||
)) as {
|
||||
feeds: Array<{ id: string; title: string }>;
|
||||
} | null;
|
||||
expect(feedListAfter?.feeds.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1637
-657
File diff suppressed because it is too large
Load Diff
+111
-32
@@ -1,26 +1,72 @@
|
||||
import { Context } from 'hono';
|
||||
import { EmailParser } from '../utils/email-parser';
|
||||
import { Env, FeedMetadata } from '../types';
|
||||
import { Context } from "hono";
|
||||
import { EmailParser } from "../utils/email-parser";
|
||||
import { Env, FeedConfig, FeedMetadata } from "../types";
|
||||
|
||||
// Interface for ForwardEmail.net webhook payload
|
||||
interface ForwardEmailPayload {
|
||||
recipients?: string[];
|
||||
from?: {
|
||||
value?: Array<{address?: string; name?: string}>;
|
||||
value?: Array<{ address?: string; name?: string }>;
|
||||
text?: string;
|
||||
html?: string;
|
||||
};
|
||||
subject?: string;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
date?: string;
|
||||
messageId?: string;
|
||||
headerLines?: Array<{key: string; line: string}>;
|
||||
headerLines?: Array<{ key: string; line: string }>;
|
||||
headers?: string;
|
||||
raw?: string;
|
||||
attachments?: Array<any>;
|
||||
}
|
||||
|
||||
function normalizeEmail(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extractIncomingSenderAddresses(
|
||||
payload: ForwardEmailPayload,
|
||||
): string[] {
|
||||
const valueEntries = payload.from?.value || [];
|
||||
const structuredAddresses = valueEntries
|
||||
.map((entry) => entry.address || "")
|
||||
.map(normalizeEmail)
|
||||
.filter(Boolean);
|
||||
|
||||
if (structuredAddresses.length > 0) {
|
||||
return Array.from(new Set(structuredAddresses));
|
||||
}
|
||||
|
||||
// Fallback parser for plain text like "Name <sender@example.com>"
|
||||
const fromText = payload.from?.text || "";
|
||||
const matches =
|
||||
fromText.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [];
|
||||
return Array.from(new Set(matches.map(normalizeEmail)));
|
||||
}
|
||||
|
||||
function senderMatchesAllowlist(
|
||||
sender: string,
|
||||
allowedSender: string,
|
||||
): boolean {
|
||||
const normalizedSender = normalizeEmail(sender);
|
||||
const normalizedAllowed = normalizeEmail(allowedSender);
|
||||
|
||||
if (!normalizedAllowed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedAllowed.includes("@")) {
|
||||
return normalizedSender === normalizedAllowed;
|
||||
}
|
||||
|
||||
const senderDomain = normalizedSender.split("@")[1] || "";
|
||||
const normalizedDomain = normalizedAllowed.startsWith("@")
|
||||
? normalizedAllowed.slice(1)
|
||||
: normalizedAllowed;
|
||||
return senderDomain === normalizedDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming emails from ForwardEmail.net webhook
|
||||
*/
|
||||
@@ -28,63 +74,96 @@ export async function handle(c: Context): Promise<Response> {
|
||||
try {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env as unknown as Env;
|
||||
|
||||
|
||||
// Parse the incoming JSON payload
|
||||
const payload: ForwardEmailPayload = await c.req.json();
|
||||
|
||||
|
||||
// Log basic information about the incoming email
|
||||
console.log("Received email:", {
|
||||
to: payload.recipients?.[0],
|
||||
from: payload.from?.text || 'Unknown',
|
||||
from: payload.from?.text || "Unknown",
|
||||
subject: payload.subject,
|
||||
contentType: payload.html ? 'HTML' : 'Text'
|
||||
contentType: payload.html ? "HTML" : "Text",
|
||||
});
|
||||
|
||||
|
||||
// Extract feed ID from email address (e.g., apple.mountain.42@domain.com -> apple.mountain.42)
|
||||
const toAddress = payload.recipients?.[0] || '';
|
||||
const toAddress = payload.recipients?.[0] || "";
|
||||
const feedId = EmailParser.extractFeedId(toAddress);
|
||||
|
||||
|
||||
if (!feedId) {
|
||||
console.error(`Invalid email address format: ${toAddress}`);
|
||||
return new Response('Invalid email address format', { status: 400 });
|
||||
return new Response("Invalid email address format", { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
// Check if the feed exists by looking up the feed configuration
|
||||
const feedConfigKey = `feed:${feedId}:config`;
|
||||
const feedConfig = await env.EMAIL_STORAGE.get(feedConfigKey, 'json');
|
||||
|
||||
const feedConfig = (await env.EMAIL_STORAGE.get(
|
||||
feedConfigKey,
|
||||
"json",
|
||||
)) as FeedConfig | null;
|
||||
|
||||
if (!feedConfig) {
|
||||
console.error(`Feed with ID ${feedId} does not exist or has been deleted`);
|
||||
return new Response('Feed does not exist', { status: 404 });
|
||||
console.error(
|
||||
`Feed with ID ${feedId} does not exist or has been deleted`,
|
||||
);
|
||||
return new Response("Feed does not exist", { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
const allowedSenders = (feedConfig.allowed_senders || [])
|
||||
.map(normalizeEmail)
|
||||
.filter(Boolean);
|
||||
if (allowedSenders.length > 0) {
|
||||
const incomingSenders = extractIncomingSenderAddresses(payload);
|
||||
const senderAllowed = incomingSenders.some((sender) =>
|
||||
allowedSenders.some((allowedSender) =>
|
||||
senderMatchesAllowlist(sender, allowedSender),
|
||||
),
|
||||
);
|
||||
|
||||
if (!senderAllowed) {
|
||||
console.warn(
|
||||
`Rejected email for feed ${feedId}; sender not in allowlist`,
|
||||
{
|
||||
incomingSenders,
|
||||
allowedSenders,
|
||||
},
|
||||
);
|
||||
return new Response("Sender not allowed for this feed", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the email using our simplified parser
|
||||
const emailData = EmailParser.parseForwardEmailPayload(payload);
|
||||
|
||||
|
||||
// Generate a unique key for this email in KV storage
|
||||
const emailKey = `feed:${feedId}:${Date.now()}`;
|
||||
|
||||
|
||||
// Store the email data in KV
|
||||
await env.EMAIL_STORAGE.put(emailKey, JSON.stringify(emailData));
|
||||
|
||||
|
||||
// Get existing feed metadata
|
||||
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||
const feedMetadata = (await env.EMAIL_STORAGE.get(feedMetadataKey, 'json') || { emails: [] }) as FeedMetadata;
|
||||
|
||||
const feedMetadata = ((await env.EMAIL_STORAGE.get(
|
||||
feedMetadataKey,
|
||||
"json",
|
||||
)) || { emails: [] }) as FeedMetadata;
|
||||
|
||||
// Add this email to the feed metadata
|
||||
feedMetadata.emails.unshift({
|
||||
key: emailKey,
|
||||
subject: emailData.subject,
|
||||
receivedAt: emailData.receivedAt
|
||||
receivedAt: emailData.receivedAt,
|
||||
});
|
||||
|
||||
|
||||
// Store updated feed metadata
|
||||
await env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
||||
|
||||
|
||||
console.log(`Successfully processed email for feed ${feedId}`);
|
||||
return new Response('Email processed successfully', { status: 200 });
|
||||
return new Response("Email processed successfully", { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Error processing email:', error);
|
||||
return new Response('Error processing email', { status: 500 });
|
||||
console.error("Error processing email:", error);
|
||||
return new Response("Error processing email", { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user