10.3 · JWT + session internals
บทนี้เจาะสองส่วนที่เกิดในทุก authenticated request — การ verify JWT และการ lookup Session row ที่เก็บอยู่ในรูปแบบ JSON blob ทั้งสองส่วนมี bug ที่มี impact จริง
ภาพรวม — สองทางเข้าสู่ระบบ verify
┌──────────────────────────────────────────────────────────────┐
│ Request with Authorization: Bearer <jwt> + apiKey: <key> │
└──────────────────────────────────────────────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ Path A: refresh flow │ │ Path B: protected field │
│ (renewRefreshToken) │ │ (@auth directive) │
│ │ │ │
│ jwt.decode(token) │ │ jwt.decode → pull jti │
│ (NOT verified!) │ │ jwt.verify (signature) │
│ │ │ │
│ → compare refreshToken │ │ → checkSession │
│ string equality │ │ → checkMenuAuth │
└────────────────────────┘ └────────────────────────┘Path A (refresh) ใช้ decode Path B (directive) ใช้ verify — ความไม่ consistent นี้คือที่มาของช่องโหว่ Critical
Path A · refreshToken bug (Critical)
refreshToken เป็น query ที่ public รับ token (access token เก่า) + refreshToken (string) แล้วคืน access token ใหม่
export const renewRefreshToken = async (token, oldRefreshToken, context) => {
try {
const payload = await decode(token, JWT_SECRET)
const session = await getSession(payload.id)
if (session.refreshToken === oldRefreshToken) {
const accessRole = await getRole(session.role)
await checkSession(payload.uid, [accessRole.roleName], payload.jti, context.device)
const newToken = await refreshToken(payload.uid, [accessRole.roleName], session.client, session.sessionId, session.currentProfile, session.userSession.empCode)
return {
accessToken: newToken.token
}
} else {
log.error(log.logTextFormat('TOKEN_INVALID', 'Method:renewRefreshToken', 'ERROR', 'Token wrong format.', { token: token }))
responError('TOKEN_INVALID')
}
} catch (error) {
log.error(log.logTextFormat('ERROR', 'Method:renewRefreshToken', 'ERROR', error.stack, { token: token, oldRefreshToken: oldRefreshToken, context }))
responError('TOKEN_INVALID')
}
}🔴 บรรทัด 221 — decode() ไม่ใช่ verify()
const payload = await decode(token, JWT_SECRET)decode() ใน jsonwebtoken อ่าน payload โดย ไม่ verify signature หรือ expiry — มันแค่ base64-decode payload section ของ JWT ผู้โจมตีสามารถ craft JWT ที่มี signature invalid หรือ expire ไปแล้ว แล้วส่งเข้ามาได้
สิ่งเดียวที่ตรวจจริงคือบรรทัด 223 — session.refreshToken === oldRefreshToken (string equality) ถ้า refresh token รั่ว (จาก log ในบรรทัด 238 ที่ log oldRefreshToken โดยตรง) ผู้โจมตีมี access ไม่จำกัดจนกว่า session row จะถูกลบ
🔴 บรรทัด 238 — refresh token หลุดเข้า log
} catch (error) {
log.error(log.logTextFormat('ERROR', 'Method:renewRefreshToken', 'ERROR', error.stack, { token: token, oldRefreshToken: oldRefreshToken, context }))
responError('TOKEN_INVALID')
}ตอนเกิด error (token ผิด format, session ไม่พบ, network blip) ระบบ log ทั้ง
token— JWT เก่าoldRefreshToken— refresh token ที่ส่งเข้ามาcontext— object ที่มีuserId,device,appId, และ request headers (รวมapiKey)
ทั้งหมดเขียนลง logs/error_YYYY-MM-DD.log ในแบบ plaintext ทำให้แค่มีสิทธิ์อ่าน log file ก็ได้ credential ไป
วิธีแก้
| ขั้น | Action |
|---|---|
| 1 | เปลี่ยน decode() เป็น jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) |
| 2 | เพิ่ม algorithms: ['HS256'] allow-list (กัน alg:none confusion) |
| 3 | ใช้ try/catch แยก error type — TokenExpiredError vs JsonWebTokenError |
| 4 | Redact oldRefreshToken และ context ออกจาก log |
Path B · @auth directive verify
directive path ใช้ jwt.verify แบบถูกต้อง แต่มีปัญหาอื่น
if (token) {
// 1. Verify token and decode
let verifyDecoded
if (Object.keys(verifyOption).length > 0) {
verifyDecoded = jwt.verify(token, JWT_SECRET, verifyOption) // verify with option and decode
} else {
verifyDecoded = jwt.verify(token, JWT_SECRET) // verify and decode
}verify ทำงานแต่ไม่มี algorithms: ['HS256'] allow-list — เป็น vector ของ alg:none confusion attack (ถึงแม้ jsonwebtoken@8.5.1 จะมี guard บางส่วน แต่ไม่แข็งพอ)
ใน directives/auth.js ก็ใช้ pattern เดียวกัน
const payload = await decode(context.token, JWT_SECRET)
// Verify token
const verify = verifyToken(context.token,
{
expiresIn: JWT_EXP,
issuer: JWT_ISSUER,
jwtId: payload.jti
})ที่น่าสังเกต — directive decode ก่อน verify (บรรทัด 23) เพื่อดึง jti ออกมาเป็น option ของ verify แต่ decode ไม่ verify signature ทำให้ jti ของ malicious token ไหลเข้า verify step ได้ — ไม่ใช่ bypass โดยตรง แต่เป็น design ที่ fragile
Session lookup · JSON LIKE scan (Performance Critical)
หลัง verify token ระบบต้องหา Session row ที่ match userId + jwtId + client ปัญหาคือ jwtId เก็บอยู่ใน JSON blob column ไม่ใช่ indexed column
export const checkSession = async (userId, accessRole, jwtid, device) => {
//Check access role
const currentUserRole = await getCurrentRoleUser(userId, accessRole, device)
if (currentUserRole.length <= 0) {
return responError('CANNOT_ACCESS')
}
//Check Session
var sessionResult = await Models.Session.findAll({
where: {
userId: userId,
userSession: {
[Op.like]: `%${jwtid}%`,
},
client: device
}
})
if (sessionResult.length <= 0) {
responError('TOKEN_INVALID')
}
const expDate = sessionResult[0].expireDate
if (expDate <= Date.now()) {
responError('TOKEN_INVALID')
}
return sessionResult
}🟡 บรรทัด 31-33 — Op.like full table scan
var sessionResult = await Models.Session.findAll({
where: {
userId: userId,
userSession: {
[Op.like]: `%${jwtid}%`,
},
client: device
}
})userSession เป็น TEXT column ที่เก็บ JSON string ระบบ query ด้วย LIKE '%jwtid%' ซึ่ง
- ไม่มี index ที่จะ serve leading-wildcard
LIKEได้ - เป็น full table scan ทุก request ที่ authenticated
- ถ้า Session table โตขึ้น (ทุก login สร้าง row ใหม่) DB cost จะเพิ่มขึ้นเรื่อย ๆ
โดยทั่วไปจะเริ่ม debug เรื่อง "DB CPU 100% ตอน login" ที่นี่ก่อน — เพราะนี่คือ hot path ที่ทุก request ต้องผ่าน
🟡 บรรทัด 31 — substring false-positive
LIKE '%jwtid%' จะ match ถ้า jwtId ของ session หนึ่งเป็น substring ของ userSession JSON ของ session อื่น (เช่น jwtId ฝังอยู่ใน empCode field ของ JSON) ทำให้เกิด false-positive ที่ rare แต่ architecturally unsound
JSON column ยังเก็บ empCode ด้วย (auth.js:153) — เพิ่มโอกาส substring collision
วิธีแก้
| ขั้น | Action |
|---|---|
| 1 | Promote jwtId เป็น indexed column แยกจาก JSON |
| 2 | ที่ดีที่สุด — store sessionId ใน JWT แล้ว lookup ด้วย PK |
| 3 | ใน Go target ใช้ session_id UUID PK + index บน (user_id, device) |
Expiry decoupling
const expDate = sessionResult[0].expireDate
if (expDate <= Date.now()) {
responError('TOKEN_INVALID')
}expireDate ของ Session row ถูก set ที่
expireDate: dateDayAfter(1),dateDayAfter(1) = 24 ชั่วโมงข้างหน้า ขณะที่ JWT expire ใน 15 นาที (JWT_EXP = '15m') — สอง clock นี้ไม่ได้ coupled ทำให้
- JWT expire แต่ Session row ยัง "valid" → refresh flow ทำงานต่อได้ (by design)
- Session row expire แต่ JWT ยังไม่ expire → token ใช้ไม่ได้แล้ว แต่ client อาจไม่รู้ (UX bug)
ออกแบบให้ refresh window ยาวกว่า access window ถือว่าปกติ แต่ควรมี single source of truth สำหรับ expiry ไม่ใช่สอง clock แยกกัน
Transaction swallow bug (CORR-2)
Session repository มี pattern ที่ transaction อาจ commit หลัง rollback
// create() — pattern เดียวกันใน update(), destroySession()
await Models.db.transaction().then(t => {
return Models.Session.create({...}, { transaction: t })
.then(result => { t.commit(); return result })
.catch(err => {
t.rollback()
responError(...) // throw — แต่ outer .then ยังทำงานต่อ
})
})ปัญหา — responError() throw ApolloError แต่ outer .then ของ Models.db.transaction() ยัง resolve ได้ ทำให้ t.commit() ทำงานบน transaction ที่ rollback ไปแล้ว ในกรณีแย่สุดอาจเขียน partial data
วิธีแก้ — ใช้ await + try/catch/finally แทน .then chain
สรุปไฟล์ที่เกี่ยวข้อง
| File | Role |
|---|---|
lib/repositories/auth/auth.js | JWT sign/decode/verify, generateToken |
lib/repositories/session/session.js | checkSession, RBAC lookup, transaction |
lib/controllers/auth/auth.js | renewRefreshToken (bug), signInApp |
directives/auth.js | @auth directive visitor |
lib/encrypt/index.js | EncryptAll (SHA-384 ของ UUID = refresh token) |
config.js:12,38,39 | JWT_SECRET, JWT_EXP, JWT_ISSUER |
ตารารางสรุปช่องโหว่ในบทนี้
| # | ปัญหา | Severity | File:line |
|---|---|---|---|
| 1 | decode() แทน verify() ใน refresh | 🔴 Critical | auth.js:221 |
| 2 | Refresh token ใน log | 🔴 Critical | auth.js:238 |
| 3 | ไม่มี algorithms allow-list | 🟡 High | auth.js:269, directives/auth.js:26-31 |
| 4 | Op.like '%jwtid%' full scan | 🟡 High (PERF-3) | session.js:28-36 |
| 5 | Substring false-positive | 🟢 Medium | session.js:31-33 |
| 6 | Expiry decoupled | 🟢 Medium | auth.js:98,133 |
| 7 | Transaction swallow (CORR-2) | 🟡 High | session.js:79-88 |
อ่านต่อ → 9.4 RBAC + @auth directive