7.9 · Security + performance
บทนี้สรุปจุดเสี่ยงด้านความปลอดภัยและปัญหาประสิทธิภาพของ vtrc-web โดยอ้างอิงถึง file:line ที่ต้องระวัง
ถ้าจะทำ security review — บทนี้คือจุดเริ่มต้น และควร cross-check กับ Volume 9 (Security)
Security
S-1 · Token เก็บใน localStorage/sessionStorage (ความเสี่ยงสูง)
utils/auth.js:4-35 เก็บ JWT + refreshToken ใน localStorage หรือ sessionStorage
if (isRemember) {
localStorage.setItem('token', accessToken)
if(refreshToken){
localStorage.setItem('refreshToken', refreshToken)
}
localStorage.setItem('isRemember', 'true')
} else {
sessionStorage.setItem('token', accessToken)
if(refreshToken){
sessionStorage.setItem('refreshToken', refreshToken)
}
}ความเสี่ยง: ทุก JavaScript ที่รันในหน้าเว็บสามารถอ่าน token ได้ผ่าน localStorage.getItem('token') — ถ้ามี XSS ผู้โจมตีขโมย token ได้ทันที
การลดความเสี่ยง: ทางที่ดีควรเก็บ token ใน HttpOnly cookie แต่การเปลี่ยนแปลงนี้กระทบทั้งระบบ (frontend + backend + token flow)
S-2 · apiKey เข้ารหัส base64 ใน bundle
utils/apiKey.js:1-12:
export const apiKeyNews = () => {
if(window.location.href.includes("https://vtrc.redcross.or.th/")){
// return 'fd77b56f-1489-4fb1-a094-a614f0d16198'
return 'ZmQ3N2I1NmYtMTQ4OS00ZmIxLWEwOTQtYTYxNGYwZDE2MTk4'
}else if(window.location.href.includes("https://uat-vtrc.redcross.or.th/")){
// return '1080670e-926f-49e3-b798-63bb75f47b7d'
return 'MTA4MDY3MGUtOTI2Zi00OWUzLWI3OTgtNjNiYjc1ZjQ3Yjdk'
}else if(window.location.href.includes("http://localhost:3000/") || window.location.href.includes("http://192.168.0.105:3000/")){
return 'MTA4MDY3MGUtOTI2Zi00OWUzLWI3OTgtNjNiYjc1ZjQ3Yjdk'
// return '1080670e-926f-49e3-b798-63bb75f47b7d'
}
}ความเสี่ยง: apiKey เป็น base64 ของ UUID — decode ด้วย atob() ได้ค่าจริงทันที ค่า prod จริงคือ fd77b56f-1489-4fb1-a094-a614f0d16198 ฝังอยู่ใน bundle ทุกคนที่เปิดเว็บจะเห็นได้
base64 ไม่ใช่การเข้ารหัส — เป็นเพียง encoding เพื่อไม่ให้ grep หา UUID pattern เจอง่าย
ผลกระทบ: apiKey ใช้ระบุ APP_TYPE: WEB ฝั่ง server — ถ้าผู้โจมตีได้ apiKey มาสามารถยิง GraphQL ในนาม "WEB" ได้โดยไม่ต้อง login (สำหรับ public query เช่น login, generateCaptcha)
การลดความเสี่ยง: ฝั่ง server ต้องมี rate limit + WAF — apiKey เป็นเพียงการระบุประเภท client ไม่ใช่ authentication จริง
S-3 · AES key สำหรับ "remember password" hardcoded
utils/auth.js:37-47:
export const passwordEncrypt = function(data) {
let encryptLocalStorage = CryptoJS.AES.encrypt(JSON.stringify(data), "6A45soD%9eB#",
{
keySize: 128 / 8,
iv: "6A45soD%9eB#",
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}).toString();
localStorage.setItem('profileDetailPW', encryptLocalStorage)
}ความเสี่ยง: AES key "6A45soD%9eB#" และ IV เดียวกัน hardcoded ใน source — ทุกคนที่เปิด bundle จะเห็น key สามารถ decrypt พาสเวิร์ดที่เก็บใน localStorage ได้ทันที
IV ที่เหมือนกับ key เป็นความผิดพลาดร้ายแรงในการเข้ารหัส — IV ต้องสุ่มทุกครั้ง และต้องไม่ตรงกับ key
ผลกระทบ: ถ้าผู้โจมตีได้ localStorage ของเครื่องผู้ใช้ (เช่นผ่าน malware หรือ physical access) สามารถถอดรหัส password ได้ทันที
การลดความเสี่ยง: ห้ามใช้ "remember password" ในรูปแบบนี้ — ทางที่ดีให้ใช้ refresh token + autologin แทน ไม่ต้องเก็บ plaintext-equivalent ของ password
S-4 · dangerouslySetInnerHTML ใช้ในหลายหน้า
พบใน 20+ ไฟล์ ส่วนใหญ่ render HTML ที่ได้จาก server (news content, payroll slip text, notification)
ความเสี่ยง: ถ้า content จาก server มี <script> หรือ attribute onerror โดยไม่ผ่าน sanitizer ผู้ใช้จะถูก XSS
การลดความเสี่ยง: ฝั่ง server ต้อง sanitize HTML ก่อนเก็บ หรือฝั่ง frontend ต้องใช้ DOMPurify.sanitize(html) ก่อน dangerouslySetInnerHTML
ปัจจุบันไม่พบ sanitizer ใน repo — เป็นช่องโหว่ที่ควรเพิ่ม
S-5 · isShow เป็นเพียง flag ฝั่ง UI
ดู บท 6.4 — route ทั้งหมดถูกสร้างเสมอ ผู้ใช้ที่พิมพ์ URL ตรง ๆ จะเข้าได้
ผลกระทบ: ผู้ใช้ที่ไม่มีสิทธิ์ PMS สามารถเข้า /pms/indicators/list ได้ — หน้าจะ render แต่ทุก query จะ fail ด้วย FORBIDDEN
การลดความเสี่ยง: ฝั่ง server คุมผ่าน @auth directive อยู่แล้ว — แต่ UX ไม่ดี ถ้าต้องการป้องกันที่ฝั่ง frontend ต้องเพิ่ม <PrivateRoute> ที่เช็ค role
S-6 · Console override ทำให้ debug ฝั่ง prod ได้ยาก
App/index.js:15:
console.log = console.warn = console.error = () => { };ผลกระทบ: ถ้าเกิด error ใน production จะ debug ได้ยากเพราะ console ทั้งหมดถูกปิด
การลดความเสี่ยง: เปลี่ยนเป็น strip ใน webpack build แทน (ด้วย terser-webpack-plugin + drop_console) แทนการ override ตอน runtime
S-7 · Google Analytics ID เปิดเผย
public/index.html:33-39:
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LKXD2EBDZ4"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-LKXD2EBDZ4');
</script>GA4 ID G-LKXD2EBDZ4 เปิดเผยอยู่ใน HTML — เป็น public ID อยู่แล้วตามธรรมชาติ แต่ทำให้ผู้อื่นยิง event เทียมเข้า GA ของระบบได้ (spam หรือ skew analytics)
S-8 · ไม่มี Content Security Policy (CSP)
ไม่พบ CSP header ใน nginx-config/default.conf — ป้องกัน XSS และ data exfiltration ได้ยาก
การลดความเสี่ยง: เพิ่ม CSP header ใน nginx config:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.googletagmanager.com; ...";Performance
P-1 · Bundle ใหญ่ ~12 MB (รวม chunks)
จากการตรวจสอบ build/static/js/:
| ไฟล์ | ขนาด | gzip โดยประมาณ |
|---|---|---|
2.72598b5c.chunk.js | 4.0 MB | ~1.3 MB |
2.bbb73d14.chunk.js | 3.7 MB | ~1.2 MB |
main.e840df76.chunk.js | 2.4 MB | ~800 KB |
main.6aa492c2.chunk.js | 1.9 MB | ~600 KB |
| รวม | ~12 MB | ~3-4 MB |
รวม build/ ทั้งโฟลเดอร์ 79 MB (รวม CSS, fonts, PDF, images)
สาเหตุ:
- ไม่มี code splitting ระดับ route — ทุก page อยู่ใน main bundle
- moment.js รวม locale ทั้งหมด (มี
IgnorePluginแล้ว แต่ยังใหญ่) - AntD v3 import ทั้ง library (ไม่มี tree-shake)
- ใช้ PDF libraries หลายตัว (
@react-pdf/renderer,react-pdf,@react-pdf-viewer,pdfjs-dist) — ใหญ่มาก custom-antd.css21,190 บรรทัด compile แล้ว ไม่ได้ minify
ผลกระทบ: เวลาโหลดหน้าแรกช้า โดยเฉพาะบนมือถือหรือเน็ตช้า
การลดความเสี่ยง (ถ้าจะแก้):
- ใช้
React.lazy+Suspenseสำหรับ route-level splitting — แต่ต้องเปลี่ยน pattern จาก class component เป็น lazy ที่ซับซ้อน - ใช้
dayjsแทนmoment(เล็กกว่ามาก) — แต่ต้องแก้ helper ทั้งหมดในutils/formatdate.js - ใช้
babel-plugin-importสำหรับ AntD ทำ per-component import — ลดขนาดลงได้มาก - เลือก PDF library ตัวเดียว — ตัด 3 ตัวที่เหลือออก
P-2 · fetchPolicy: 'no-cache' ทำให้ทุก query ดึงใหม่
apollo.js:171-182:
apolloClient.defaultOptions = {
watchQuery: {
fetchPolicy: 'no-cache',
errorPolicy: 'ignore'
},
query: {
fetchPolicy: 'no-cache',
errorPolicy: 'all'
}
}ผลกระทบ: เปลี่ยนหน้าไหนก็ตาม ทุก query ดึงใหม่จาก server — ทำให้รู้สึกช้าเวลาเปลี่ยนหน้า โดยเฉพาะ query ที่ไม่ควรเปลี่ยนบ่อย (เช่น dropdown master data)
การลดความเสี่ยง: เปลี่ยน fetchPolicy เฉพาะ query ที่ควร cache เช่น:
this.props.client.query({
query: Queries.dropdownListProvince,
fetchPolicy: 'cache-first',
variables: { ... }
})P-3 · SiderLayout query profile ทุกครั้งที่ reload
SiderLayout ดึง profile + role flag ทุกครั้งที่เข้าหน้าใหม่ — ทำให้หน้าแรกช้ากว่าที่ควร
การลดความเสี่ยง: cache profile ใน localStorage + revalidate เป็นระยะ (stale-while-revalidate pattern)
P-4 · ไม่มี lazy loading ของรูป
รูปใน src/assets/images/ ไม่ได้ lazy load — ทุกรูปโหลดทันทีตอน page render
การลดความเสี่ยง: ใช้ loading="lazy" ใน <img> สำหรับรูปที่ไม่ได้อยู่ใน viewport แรก
P-5 · Webpack 4 (CRA ejected) ไม่มี webpack 5 features
webpack 5 มี ModuleFederationPlugin และ asset optimization ที่ดีกว่า — แต่ upgrade เป็น breaking change
P-6 · console.log กระจายอยู่ในหลายไฟล์
แม้ App/index.js:15 จะปิด console ไว้ แต่ console.log ยังอยู่ใน source code หลายสิบจุด — ทำให้ bundle ใหญ่กว่าที่ควร
การลดความเสี่ยง: ใช้ terser drop_console ใน webpack config เพื่อ strip ออกตอน build
P-7 · PDF.js โหลด worker แยกต่างหาก
pdfjs-dist ใช้ web worker แยก — โหลดเพิ่มอีก 1 request ทำให้หน้า Slip ช้ากว่าหน้าอื่น
การจัดลำดับความสำคัญ
| Priority | ปัญหา | Effort | ผลกระทบ |
|---|---|---|---|
| สูง | S-1 token ใน localStorage | สูงมาก | ลด XSS impact |
| สูง | S-3 AES key hardcoded | ต่ำ | ลด credential theft |
| สูง | S-4 ไม่มี HTML sanitizer | ต่ำ | ลด XSS |
| กลาง | P-1 bundle ใหญ่ | สูง | เร่งเวลาโหลด |
| กลาง | S-6 console override | ต่ำ | debug ง่ายขึ้น |
| กลาง | P-2 no-cache ทุก query | กลาง | เร่งเปลี่ยนหน้า |
| ต่ำ | S-7 GA ID เปิดเผย | — | ยอมรับได้ |
| ต่ำ | P-7 PDF worker | กลาง | เร่งหน้า Slip |