## 🛡️ Security Audit & Hardening Report ### 1. Executive Summary PR นี้ทำการตรวจสอบและปิดช่องโหว่ด้านความปลอดภัยของ **Backend API (Elysia.js)** ทั่วทั้งระบบ โดยบังคับใช้ **Admin Authentication Guard (`adminOnly` Middleware)** กับทุก Endpoint ที่เกี่ยวข้องกับ: 1. การจัดการและแก้ไขข้อมูลแคตตาล็อกสินค้า (Product & Category CRUD, Bulk Import/Delete) 2. ข้อมูลความลับทางการค้าและสูตรการผลิต (**Bill of Materials - BOM**, ต้นทุน Raw Materials, Profitability/MRP Forecast) 3. ข้อมูลส่วนบุคคลของลูกค้า (**PII** เช่น รายชื่อลูกค้า CRM, RFM Segment, ข้อความติดต่อ Contact Form, อีเมล Newsletter Subscribers) 4. การจัดการคำสั่งซื้อและสถานะ (Order Status Update, Tracking Assignment) 5. การจัดการ Assets และเนื้อหาเว็บไซต์ (Articles, Media Deletion, Category Layout Sections) พร้อมทั้งกำจัดจุดรั่วไหลของข้อมูลสูตรสินค้า (`bomItems` และราคาต้นทุน `rawMaterial`) ที่เคยหลุดออกไปใน Public Catalog Query (`GET /products` และ `GET /products/:id`) --- ### 2. Root Cause & Technical Findings #### 2.1 Elysia Lifecycle Plugin Scoping Flaw - **ปัญหาเดิม:** Middleware `adminOnly` ถูกสร้างด้วย `new Elysia({ name: 'admin-auth' }).onBeforeHandle(...)` โดยไม่มีออปชัน `{ as: 'scoped' }` - **ผลกระทบ:** ใน Elysia.js v1.2+ หากไม่ระบุ `{ as: 'scoped' }` หรือ `{ as: 'global' }` ฮุก `onBeforeHandle` ที่ประกาศใน plugin ย่อยจะไม่ถูก propagate ไปยัง Parent Route เมื่อถูกเรียกใช้ผ่าน `.use(adminOnly)` ส่งผลให้ middleware ไม่ทำงานจริงในบาง Controller - **การแก้ไข:** เพิ่ม `{ as: 'scoped' }` ใน `onBeforeHandle` ของ `apps/backend/src/middleware/auth.ts` และรวมศูนย์ `JWT_SECRET` ให้เป็น Single Source of Truth #### 2.2 Business Confidentiality Leak in Public Queries - **ปัญหาเดิม:** `GET /api/products` และ `GET /api/products/:id` ทำการ query `variants` โดย `include: { bomItems: { include: { rawMaterial: true } } }` - **ผลกระทบ:** ผู้ใช้ทั่วไปหรือ Bot ที่เรียกดูหน้าร้าน สามารถมองเห็นสูตรผสม ปริมาณสารเคมี/วัตถุดิบ และราคาต้นทุนต่อหน่วย (`pricePerUnit`) ของสินค้าทุกรายการ - **การแก้ไข:** ตัด `bomItems` ออกจาก Public Queries ทั้งหมด ข้อมูล BOM จะเปิดให้เข้าถึงเฉพาะผ่าน `GET /api/products/:id/bom` และ `GET /api/products/bom-status` ซึ่งถูกครอบด้วย `adminOnly` เท่านั้น --- ### 3. Comprehensive Security Matrix | Module | Route / Endpoint | Method | Previous State | New Protected State | Justification | | :--- | :--- | :---: | :---: | :---: | :--- | | **Products** | `/api/products` | GET | Public (leaked BOM) | **Public (Clean)** | หน้าร้านใช้แสดงสินค้า (ตัดข้อมูลต้นทุน BOM ออก) | | | `/api/products/brands` | GET | Public | **Public** | ตัวกรองแบรนด์สำหรับหน้าร้าน | | | `/api/products/:id` | GET | Public (leaked BOM) | **Public (Clean)** | หน้ารายละเอียดสินค้า (ตัดข้อมูลต้นทุน BOM ออก) | | | `/api/products/:id/detail-sections` | GET | Public | **Public** | CMS Page Builder สำหรับ Product Page | | | `/api/products/bom-status` | GET | ⚠️ Public | 🔒 **Admin Only** | รายการสถานะสูตร BOM และต้นทุนรวมของทุกล็อต | | | `/api/products/:id/bom` | GET, PUT | ⚠️ Public | 🔒 **Admin Only** | ความลับทางการค้า/สูตรผสมของสินค้า | | | `/api/products` | POST | ⚠️ Public | 🔒 **Admin Only** | ป้องกันการสร้างสินค้าโดยไม่ได้รับอนุญาต | | | `/api/products/:id` | PUT, PATCH, DELETE | ⚠️ Public | 🔒 **Admin Only** | ป้องกันการแก้ไข/ลบสินค้า | | | `/api/products/bulk`, `/bulk-delete` | POST | ⚠️ Public | 🔒 **Admin Only** | ป้องกันการโจมตี Mass Write/Delete | | | `/api/products/brands` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการแบรนด์สินค้า | | | `/api/products/:id/images` | POST | ⚠️ Public | 🔒 **Admin Only** | เพิ่มรูปภาพสินค้า | | | `/api/products/:id/detail-sections/**` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | แก้ไข Section ในหน้ารายละเอียดสินค้า | | **Categories** | `/api/categories` | GET | Public | **Public** | แสดงหมวดหมู่สินค้าหน้าร้าน | | | `/api/categories/:id` | GET | Public | **Public** | รายละเอียดหมวดหมู่ | | | `/api/categories/:id/products` | GET | Public | **Public** | แสดงรายการสินค้าในหมวดหมู่ | | | `/api/categories/:id/sections` | GET | Public | **Public** | Category Page Builder read | | | `/api/categories/bulk-import` | POST | ⚠️ Public | 🔒 **Admin Only** | นำเข้าหมวดหมู่จำนวนมาก | | | `/api/categories` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการหมวดหมู่สินค้า | | | `/api/categories/:id/media`, `/image` | PUT, PATCH | ⚠️ Public | 🔒 **Admin Only** | จัดการรูปภาพหมวดหมู่ | | | `/api/categories/:id/sections/**` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการ Section ของหมวดหมู่ | | **Financial & MRP** | `/api/profitability/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | กำไร-ขาดทุน, Margin, ค่าธรรมเนียม, MRP 30 วัน | | | `/api/raw-materials/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | สารเคมี, สต็อกวัตถุดิบ, ราคาต้นทุนต่อหน่วย | | | `/api/business-analytics/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | แผนที่การเงิน Omnichannel, Repeat Analytics | | **CRM & Customers** | `/api/crm/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | Customer RFM Scores, Churn Risk, VIP Lists | | | `/api/customers` | GET, POST | ⚠️ Public | 🔒 **Admin Only** | รายชื่อลูกค้า, ที่อยู่, เบอร์โทรศัพท์, การปรับแต้ม | | | `/api/contacts` | GET, PATCH | ⚠️ Public | 🔒 **Admin Only** | ข้อความติดต่อจากลูกค้าหน้าร้าน (PII) | | | `/api/contacts` | POST | Public | **Public** | ฟอร์มติดต่อเราสำหรับผู้เยี่ยมชม | | | `/api/newsletter/subscribers` | GET | ⚠️ Public | 🔒 **Admin Only** | รายชื่ออีเมลผู้ติดตาม Newsletter | | | `/api/newsletter/subscribe` | POST | Public | **Public** | ฟอร์มสมัครรับจดหมายข่าว | | **Orders & Sales** | `/api/orders` | POST, GET /:id, PATCH /:id/slip | Public | **Public** | ลูกค้าสั่งซื้อ, อัปโหลดสลิป, ดูคำสั่งซื้อของตน | | | `/api/orders/track` | POST | Public | **Public** | ติดตามพัสดุ (ต้องกรอก OrderId + เบอร์โทรตรงกัน) | | | `/api/orders` | GET | ⚠️ Public | 🔒 **Admin Only** | รายการคำสั่งซื้อทั้งหมด | | | `/api/orders/:id/status`, `/tracking` | PATCH | ⚠️ Public | 🔒 **Admin Only** | เปลี่ยนสถานะคำสั่งซื้อ, ใส่เลข Tracking | | | `/api/orders/customer/:customerId` | GET | ⚠️ Public | 🔒 **Admin Only** | ดูประวัติสั่งซื้อตาม Customer ID | | | `/api/tiktok/**`, `/lazada-orders/**`, `/shopee/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | ซิงค์คำสั่งซื้อและค่าธรรมเนียม Marketplace | | **Promotions & CMS** | `/api/coupons/active`, `/validate` | GET, POST | Public | **Public** | ลูกค้าใช้คูปองและตรวจสอบส่วนลด | | | `/api/coupons` (Admin CRUD) | GET, POST, PATCH, DELETE | ⚠️ Public | 🔒 **Admin Only** | สร้าง/แก้ไข/ลบคูปอง | | | `/api/promotions/active` | GET | Public | **Public** | แสดงโปรโมชั่นหน้าร้าน | | | `/api/promotions` (Admin CRUD) | GET, POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการแคมเปญโปรโมชั่น | | | `/api/articles` | GET, GET /:slug, GET /id/:id | Public | **Public** | อ่านบทความบล็อกหน้าร้าน | | | `/api/articles` (Mutations) | POST, PUT, DELETE, PATCH | ⚠️ Public | 🔒 **Admin Only** | จัดการบทความและคำแปลภาษา | | | `/api/media` | GET, DELETE | ⚠️ Public | 🔒 **Admin Only** | รายการ Media Library และการลบไฟล์ | | | `/api/product-attributes/definitions/**` | POST, PUT, PATCH | ⚠️ Public | 🔒 **Admin Only** | การสร้างและแก้ไข Attribute Definitions | --- ### 4. Client Compatibility & Verification - **Storefront & Website (`apps/website`, `apps/spa`):** - ไม่ได้รับผลกระทบใดๆ ทุก Route ที่จำเป็นสำหรับการเรนเดอร์ (Catalog, Detail, Reviews, Sections, Checkout, Tracking, Contact, Newsletter) ยังคงเป็น Public - Payload เบาลงและปลอดภัยขึ้นเนื่องจากไม่มีการส่ง `bomItems` ที่ไม่จำเป็น - **Admin Panel (`apps/admin`):** - ไม่ได้รับผลกระทบใดๆ เนื่องจากระบบ API Client ของ Admin ส่ง `Authorization: Bearer <token>` อยู่แล้วในทุก Request - **Automated Test Results:** - เพิ่ม Security Contract Test: `apps/backend/src/routes/auth-guard.contract.test.ts` - ผ่านการทดสอบทั้งหมด 95/95 tests (0 failures) - ทดสอบผ่าน Production Build (`tsc -b && vite build`) สมบูรณ์ 100% --- ### 5. OWASP Standards Compliance - **A01:2021 – Broken Access Control:** แก้ไขช่องโหว่ Direct Object Reference และ Unprotected Administrative Interfaces โดยบังคับใช้ JWT Authentication ในทุกจุดสำคัญ - **A04:2021 – Insecure Design:** แก้ไขการรั่วไหลของข้อมูลสูตรสินค้าและต้นทุนใน Public Response ด้วย Least Privilege Data Exposure Model --------- Co-authored-by: Your Name <you@example.com> Reviewed-on: #15 Co-authored-by: antigravity-bot <bot@kittisak.com>
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import { Elysia, t } from "elysia";
|
|
import prisma from "../lib/prisma";
|
|
import { adminOnly } from "../middleware/auth";
|
|
|
|
export const tiktokRoute = new Elysia({ prefix: "/tiktok" })
|
|
.use(adminOnly)
|
|
.post(
|
|
"/import/income",
|
|
async ({ body, set }) => {
|
|
try {
|
|
const { data } = body as { data: any[] };
|
|
|
|
// ── Debug logging ────────────────────────────────────────────────────
|
|
console.log(`[TikTok/Income] Received ${data.length} rows`);
|
|
if (data.length > 0) {
|
|
console.log(`[TikTok/Income] First row keys:`, Object.keys(data[0]));
|
|
console.log(`[TikTok/Income] First row id:`, data[0].id);
|
|
console.log(`[TikTok/Income] First row sample:`, JSON.stringify(data[0]).slice(0, 300));
|
|
}
|
|
|
|
// Check for duplicate/missing IDs
|
|
const ids = data.map((r) => r.id);
|
|
const uniqueIds = new Set(ids);
|
|
console.log(`[TikTok/Income] Total IDs: ${ids.length}, Unique IDs: ${uniqueIds.size}`);
|
|
if (uniqueIds.size < ids.length) {
|
|
console.warn(`[TikTok/Income] ⚠️ Duplicate IDs detected!`);
|
|
}
|
|
|
|
// ── Chunked upsert (avoid transaction timeout on large files) ────────
|
|
const CHUNK_SIZE = 50;
|
|
let inserted = 0;
|
|
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
|
const chunk = data.slice(i, i + CHUNK_SIZE);
|
|
await prisma.$transaction(
|
|
chunk.map((row) =>
|
|
prisma.tiktokIncome.upsert({
|
|
where: { id: row.id },
|
|
update: {
|
|
type: row.type,
|
|
orderCreatedTime: row.orderCreatedTime,
|
|
orderSettledTime: row.orderSettledTime,
|
|
currency: row.currency,
|
|
totalSettlementAmount: row.totalSettlementAmount,
|
|
totalRevenue: row.totalRevenue,
|
|
totalFees: row.totalFees,
|
|
rawData: row.rawData,
|
|
},
|
|
create: {
|
|
id: row.id,
|
|
type: row.type,
|
|
orderCreatedTime: row.orderCreatedTime,
|
|
orderSettledTime: row.orderSettledTime,
|
|
currency: row.currency,
|
|
totalSettlementAmount: row.totalSettlementAmount,
|
|
totalRevenue: row.totalRevenue,
|
|
totalFees: row.totalFees,
|
|
rawData: row.rawData,
|
|
},
|
|
})
|
|
),
|
|
{ timeout: 30000 }
|
|
);
|
|
inserted += chunk.length;
|
|
console.log(`[TikTok/Income] Progress: ${inserted}/${data.length}`);
|
|
}
|
|
|
|
console.log(`[TikTok/Income] ✅ Done — ${inserted} records upserted`);
|
|
return { success: true, message: `Imported ${inserted} income records.` };
|
|
} catch (error: any) {
|
|
console.error(`[TikTok/Income] ❌ Error:`, error.message);
|
|
set.status = 500;
|
|
return { success: false, message: error.message };
|
|
}
|
|
},
|
|
{
|
|
body: t.Object({
|
|
data: t.Array(t.Any()),
|
|
}),
|
|
}
|
|
)
|
|
.post(
|
|
"/import/product",
|
|
async ({ body, set }) => {
|
|
try {
|
|
const { data } = body as { data: any[] };
|
|
|
|
await prisma.$transaction(
|
|
data.map((row) =>
|
|
prisma.tiktokProduct.upsert({
|
|
where: { id: row.id },
|
|
update: {
|
|
productName: row.productName,
|
|
status: row.status,
|
|
gmv: row.gmv,
|
|
itemsSold: row.itemsSold,
|
|
orders: row.orders,
|
|
rawData: row.rawData,
|
|
},
|
|
create: {
|
|
id: row.id,
|
|
productName: row.productName,
|
|
status: row.status,
|
|
gmv: row.gmv,
|
|
itemsSold: row.itemsSold,
|
|
orders: row.orders,
|
|
rawData: row.rawData,
|
|
},
|
|
})
|
|
)
|
|
);
|
|
|
|
return { success: true, message: `Imported ${data.length} product records.` };
|
|
} catch (error: any) {
|
|
set.status = 500;
|
|
return { success: false, message: error.message };
|
|
}
|
|
},
|
|
{
|
|
body: t.Object({
|
|
data: t.Array(t.Any()),
|
|
}),
|
|
}
|
|
)
|
|
.post(
|
|
"/import/product-sku",
|
|
async ({ body, set }) => {
|
|
try {
|
|
const { data } = body as { data: any[] };
|
|
|
|
await prisma.$transaction(
|
|
data.map((row) =>
|
|
prisma.tiktokProductSku.upsert({
|
|
where: { id: row.id },
|
|
update: {
|
|
productId: row.productId,
|
|
skuName: row.skuName,
|
|
status: row.status,
|
|
gmv: row.gmv,
|
|
skuOrders: row.skuOrders,
|
|
itemsSold: row.itemsSold,
|
|
rawData: row.rawData,
|
|
},
|
|
create: {
|
|
id: row.id,
|
|
productId: row.productId,
|
|
skuName: row.skuName,
|
|
status: row.status,
|
|
gmv: row.gmv,
|
|
skuOrders: row.skuOrders,
|
|
itemsSold: row.itemsSold,
|
|
rawData: row.rawData,
|
|
},
|
|
})
|
|
)
|
|
);
|
|
|
|
return { success: true, message: `Imported ${data.length} SKU records.` };
|
|
} catch (error: any) {
|
|
set.status = 500;
|
|
return { success: false, message: error.message };
|
|
}
|
|
},
|
|
{
|
|
body: t.Object({
|
|
data: t.Array(t.Any()),
|
|
}),
|
|
}
|
|
)
|
|
// ── GET endpoints ────────────────────────────────────────────────────────────
|
|
.get("/income", async ({ query }) => {
|
|
const page = Number(query.page ?? 1);
|
|
const limit = Number(query.limit ?? 50);
|
|
const skip = (page - 1) * limit;
|
|
|
|
const [items, total] = await Promise.all([
|
|
prisma.tiktokIncome.findMany({
|
|
orderBy: { orderCreatedTime: "desc" },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
prisma.tiktokIncome.count(),
|
|
]);
|
|
|
|
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
|
})
|
|
.get("/product", async ({ query }) => {
|
|
const page = Number(query.page ?? 1);
|
|
const limit = Number(query.limit ?? 50);
|
|
const skip = (page - 1) * limit;
|
|
|
|
const [items, total] = await Promise.all([
|
|
prisma.tiktokProduct.findMany({
|
|
orderBy: { gmv: "desc" },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
prisma.tiktokProduct.count(),
|
|
]);
|
|
|
|
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
|
})
|
|
.get("/product-sku", async ({ query }) => {
|
|
const page = Number(query.page ?? 1);
|
|
const limit = Number(query.limit ?? 50);
|
|
const skip = (page - 1) * limit;
|
|
|
|
const [items, total] = await Promise.all([
|
|
prisma.tiktokProductSku.findMany({
|
|
orderBy: { gmv: "desc" },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
prisma.tiktokProductSku.count(),
|
|
]);
|
|
|
|
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
|
})
|
|
.get("/summary", async () => {
|
|
const [incomeCount, productCount, skuCount, incomeAgg, productAgg] = await Promise.all([
|
|
prisma.tiktokIncome.count(),
|
|
prisma.tiktokProduct.count(),
|
|
prisma.tiktokProductSku.count(),
|
|
prisma.tiktokIncome.aggregate({ _sum: { totalRevenue: true, totalSettlementAmount: true } }),
|
|
prisma.tiktokProduct.aggregate({ _sum: { gmv: true, itemsSold: true, orders: true } }),
|
|
]);
|
|
|
|
return {
|
|
incomeCount,
|
|
productCount,
|
|
skuCount,
|
|
totalRevenue: incomeAgg._sum.totalRevenue ?? 0,
|
|
totalSettlement: incomeAgg._sum.totalSettlementAmount ?? 0,
|
|
totalGmv: productAgg._sum.gmv ?? 0,
|
|
totalItemsSold: productAgg._sum.itemsSold ?? 0,
|
|
totalOrders: productAgg._sum.orders ?? 0,
|
|
};
|
|
})
|
|
.get("/analysis/sku", async () => {
|
|
// 1. Top 10 by GMV
|
|
const topGmv = await prisma.$queryRaw`
|
|
WITH SkuStats AS (
|
|
SELECT "id", "productId", "skuName", "gmv", "skuOrders", "itemsSold",
|
|
CASE WHEN "itemsSold" > 0 THEN "gmv" / "itemsSold" ELSE 0 END AS "avgPrice",
|
|
CASE WHEN "skuOrders" > 0 THEN "gmv" / "skuOrders" ELSE 0 END AS "aov"
|
|
FROM "TiktokProductSku"
|
|
)
|
|
SELECT * FROM SkuStats ORDER BY "gmv" DESC LIMIT 10
|
|
`;
|
|
|
|
// 2. Top 10 by Volume (itemsSold)
|
|
const topVolume = await prisma.$queryRaw`
|
|
WITH SkuStats AS (
|
|
SELECT "id", "productId", "skuName", "gmv", "skuOrders", "itemsSold",
|
|
CASE WHEN "itemsSold" > 0 THEN "gmv" / "itemsSold" ELSE 0 END AS "avgPrice",
|
|
CASE WHEN "skuOrders" > 0 THEN "gmv" / "skuOrders" ELSE 0 END AS "aov"
|
|
FROM "TiktokProductSku"
|
|
)
|
|
SELECT * FROM SkuStats ORDER BY "itemsSold" DESC LIMIT 10
|
|
`;
|
|
|
|
// 3. Scent/Variant Grouping
|
|
const scentAnalysis = await prisma.$queryRaw`
|
|
WITH SplitNames AS (
|
|
SELECT
|
|
"id",
|
|
"gmv",
|
|
"itemsSold",
|
|
"skuOrders",
|
|
TRIM(SPLIT_PART("skuName", ':', 2)) AS "scentName"
|
|
FROM "TiktokProductSku"
|
|
WHERE "skuName" LIKE '%:%'
|
|
)
|
|
SELECT
|
|
"scentName",
|
|
SUM("gmv") AS "totalGmv",
|
|
SUM("itemsSold") AS "totalItems",
|
|
SUM("skuOrders") AS "totalOrders",
|
|
COUNT("id") AS "skuCount"
|
|
FROM SplitNames
|
|
GROUP BY "scentName"
|
|
ORDER BY "totalGmv" DESC
|
|
LIMIT 15
|
|
`;
|
|
|
|
// 4. Overall Summary (Average AOV, Total GMV)
|
|
const summaryList = await prisma.$queryRaw<any[]>`
|
|
SELECT
|
|
SUM("gmv") AS "totalGmv",
|
|
SUM("itemsSold") AS "totalItems",
|
|
SUM("skuOrders") AS "totalOrders",
|
|
CASE WHEN SUM("skuOrders") > 0 THEN SUM("gmv") / SUM("skuOrders") ELSE 0 END AS "avgAov",
|
|
CASE WHEN SUM("itemsSold") > 0 THEN SUM("gmv") / SUM("itemsSold") ELSE 0 END AS "avgPrice"
|
|
FROM "TiktokProductSku"
|
|
`;
|
|
|
|
// Handle BigInt conversion since Prisma $queryRaw returns BigInts for COUNT/SUM in Postgres sometimes.
|
|
const serialize = (data: any) =>
|
|
JSON.parse(JSON.stringify(data, (key, value) => (typeof value === "bigint" ? Number(value) : value)));
|
|
|
|
return serialize({
|
|
summary: summaryList[0] || null,
|
|
topGmv,
|
|
topVolume,
|
|
scentAnalysis
|
|
});
|
|
});
|