Skip to content

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 ใหม่

218:241:vtrc-api/api/src/lib/controllers/auth/auth.js
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()

221:221:vtrc-api/api/src/lib/controllers/auth/auth.js
    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

237:240:vtrc-api/api/src/lib/controllers/auth/auth.js
  } 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
4Redact oldRefreshToken และ context ออกจาก log

Path B · @auth directive verify

directive path ใช้ jwt.verify แบบถูกต้อง แต่มีปัญหาอื่น

265:272:vtrc-api/api/src/lib/repositories/auth/auth.js
    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 เดียวกัน

23:31:vtrc-api/api/src/directives/auth.js
      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

20:48:vtrc-api/api/src/lib/repositories/session/session.js
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

28:36:vtrc-api/api/src/lib/repositories/session/session.js
  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
1Promote jwtId เป็น indexed column แยกจาก JSON
2ที่ดีที่สุด — store sessionId ใน JWT แล้ว lookup ด้วย PK
3ใน Go target ใช้ session_id UUID PK + index บน (user_id, device)

Expiry decoupling

42:45:vtrc-api/api/src/lib/repositories/session/session.js
  const expDate = sessionResult[0].expireDate
  if (expDate <= Date.now()) {
    responError('TOKEN_INVALID')
  }

expireDate ของ Session row ถูก set ที่

133:133:vtrc-api/api/src/lib/repositories/auth/auth.js
            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

79:88:vtrc-api/api/src/lib/repositories/session/session.js
// 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


สรุปไฟล์ที่เกี่ยวข้อง

FileRole
lib/repositories/auth/auth.jsJWT sign/decode/verify, generateToken
lib/repositories/session/session.jscheckSession, RBAC lookup, transaction
lib/controllers/auth/auth.jsrenewRefreshToken (bug), signInApp
directives/auth.js@auth directive visitor
lib/encrypt/index.jsEncryptAll (SHA-384 ของ UUID = refresh token)
config.js:12,38,39JWT_SECRET, JWT_EXP, JWT_ISSUER

ตารารางสรุปช่องโหว่ในบทนี้

#ปัญหาSeverityFile:line
1decode() แทน verify() ใน refresh🔴 Criticalauth.js:221
2Refresh token ใน log🔴 Criticalauth.js:238
3ไม่มี algorithms allow-list🟡 Highauth.js:269, directives/auth.js:26-31
4Op.like '%jwtid%' full scan🟡 High (PERF-3)session.js:28-36
5Substring false-positive🟢 Mediumsession.js:31-33
6Expiry decoupled🟢 Mediumauth.js:98,133
7Transaction swallow (CORR-2)🟡 Highsession.js:79-88

อ่านต่อ → 9.4 RBAC + @auth directive