3.2.4 · @auth directive + bypass
บทนี้เจาะ @auth directive ของ cu-central-api ซึ่งมี complexity ที่ vtrc-api ไม่มี — accessLevel + level system, ignoreAuth bypass ที่ใช้ apiKey แทน JWT, และ requires role check ที่ commented out
Directive declaration
import { gql } from 'apollo-server-express'
export default gql`
directive @auth(requires: [String], accessLevel: Int, ignoreAuth: Boolean) on FIELD_DEFINITION3 arguments
| Arg | Type | หน้าที่ | สถานะ |
|---|---|---|---|
requires | [String] | role names ที่จำเป็น | commented out ใน implementation |
accessLevel | Int | API key level ขั้นต่ำ (1=general, 4=admin) | ใช้งาน |
ignoreAuth | Boolean | ข้าม JWT verification — ใช้ apiKey อย่างเดียว | ใช้งาน |
Implementation
class AuthDirective extends SchemaDirectiveVisitor {
visitFieldDefinition (field) {
const { resolve = defaultFieldResolver } = field
const { requires, accessLevel, ignoreAuth } = this.args
field.resolve = async function (...args) {
const [, , context] = args
const { apikey } = context.headers
const clientAppInfo = context.clientAppInfo
let currentLevel = null
let appId = null
if (typeof clientAppInfo !== 'undefined') {
currentLevel = clientAppInfo.level
appId = clientAppInfo.appId
// Check API key access level
if (accessLevel > 0) {
if (clientAppInfo.level < accessLevel) {
log.warning(log.logTextFormat('NOT_AUTHORIZED', 'directive:visitFieldDefinition', 'WARNING', 'This level cant access.', { token: context.token, requiredLevel: accessLevel, currentLevel, appId }))
throw new AuthenticationError('NOT_AUTHORIZED')
}
}
// Ignore verify token
if (ignoreAuth) {
return resolve.apply(this, args)
} else {
// ** Not ignore then verify token ** //
// Verify token
const verifyProfile = await verifyToken(context.token)
if (!verifyProfile.error) {
// Verify logged in
const profile = verifyProfile.data
const loggingIn = await isLoggedIn(profile)
if (loggingIn) {
// if (requires) {
// if (!hasRole(requires, profile.profile.roles)) {
// // log.warning(log.logTextFormat('NOT_AUTHORIZED', 'directive:visitFieldDefinition', 'WARNING', 'Role not authorize to access.', { profileUid: profile.profile.profileUid, token: context.token, requiredRole: requires, userRole: profile.profile.roles }))
// throw new AuthenticationError('NOT_AUTHORIZED')
// }
// }
return resolve.apply(this, args)
} else {
throw new AuthenticationError('You must be logged in to access this service.')
}
} else {
const error = verifyProfile.error
if (error.name === 'TokenExpiredError') {
throw new ApolloError(error.message, error.name, { expiredAt: error.expiredAt })
} else {
throw new ApolloError(error.message, error.name)
}
}
}
} else {
log.warning(log.logTextFormat('NOT_AUTHORIZED', 'directive:visitFieldDefinition', 'WARNING', 'Invalid API KEY.', { apikey }))
throw new AuthenticationError('NOT_AUTHORIZED')
}
}
}
}flow มี 5 gate
- apiKey check (
auth.js:24) —clientAppInfoต้องไม่undefined(มาจากcheckApiKey(req.headers.apikey)ใน context builder) - accessLevel check (
auth.js:29-33) — เทียบclientAppInfo.levelกับ directive arg - ignoreAuth bypass (
auth.js:37-38) — ถ้าtrueข้ามไป resolve โดยตรง ข้าม JWT + session - JWT verify + session check (
auth.js:42-47) — เกิดเฉพาะถ้าไม่ใช่ bypass - requires check — commented out ทั้ง block (
auth.js:48-53) RBAC ไม่ทำงาน
Hardcoded apiKey table
export const checkApiKey = (apiKey) => {
if (typeof apiKey === 'undefined') {
return apiKey
}
// access key level 1 is general, 4 is able to search users.
const allowedApiKeys = [
{
apiKey: 'OGJlYTEzOTBjMzk3NjlmYWQ1ZTcxZDZhN2NhZWMyYTk3YTQ2NGIzMDhiMGM4NmRjM2NlNjZkM2I2ZDIyOGQyMQ',
appId: 'acwfa3t-wafbAS-fawkgt3948yf-egrhe3fwe',
appName: 'VTRC_APP',
level: 1
},
{
apiKey: '562B132B1E87695A052717C20336CDA57FBC4C5B19EF31356CC07C5A43656DE47DEFE452D241C3F443DE54',
appId: '7d962c5b-68d1-4a8f-849d-4d617711d94b',
appName: 'VTRC_ADMIN_APP',
level: 4
}
]
return allowedApiKeys.find(key => key.apiKey === apiKey)
}🔴 Critical (SEC-CU-02) — 2 apiKeys commit เป็น literal ใน code
| Key | App | Level |
|---|---|---|
OGJlYTEzOTBj... | VTRC_APP | 1 (general) |
562B132B1E87... | VTRC_ADMIN_APP | 4 (admin — search users ได้) |
ต่างจาก vtrc-api ที่เก็บใน config.js (API_DEVICE_KEY) — cu-central-api hardcode ใน controller เลย
level system
| Level | ความหมาย | ตัวอย่าง field |
|---|---|---|
| 1 | general (VTRC_APP) | query ข้อมูลตัวเอง |
| 4 | admin (VTRC_ADMIN_APP) | searchUser — search employee อื่น |
@auth(accessLevel: 4) บน field บังคับว่าต้องใช้ admin apiKey
ignoreAuth bypass pattern
): SearchUserResponse! @auth(accessLevel: 4, ignoreAuth: true) extend type Query {
"Fetch personal profile by filter."
searchUser(
empCode: String!
firstName: String!
lastName: String!
identityCard: String!
userIds: [ID!]
): SearchUserResponse! @auth(accessLevel: 4, ignoreAuth: true)
}การใช้งาน @auth(accessLevel: 4, ignoreAuth: true) หมายถึง
- ต้องมี admin apiKey (level 4)
- แต่ข้าม JWT verification + session check ทั้งหมด
คือ "apiKey อย่างเดียวพอ" — ไม่ต้อง login เป็น user
Use case ที่น่าจะเป็น
endpoint นี้อาจออกแบบมาสำหรับ service-to-service call — admin service ที่ถือ apiKey แล้วอยาก search user โดยไม่ต้อง maintain session ของตัวเอง
ความเสี่ยง (SEC-CU-05)
ถ้า apiKey level 4 รั่ว (และมัน commit อยู่ใน code อยู่แล้วใน auth.controller.js:266) — attacker เรียก searchUser ได้ทันทีโดยไม่ต้องผ่านขั้นตอน login ใด ๆ
requires — commented out
// if (requires) {
// if (!hasRole(requires, profile.profile.roles)) {
// // log.warning(log.logTextFormat('NOT_AUTHORIZED', 'directive:visitFieldDefinition', 'WARNING', 'Role not authorize to access.', { profileUid: profile.profile.profileUid, token: context.token, requiredRole: requires, userRole: profile.profile.roles }))
// throw new AuthenticationError('NOT_AUTHORIZED')
// }
// }RBAC role check ที่ประกาศใน schema (requires: [String]) ไม่ถูก implement — ทุก field ที่ใช้ @auth(requires: [...]) จะผ่านเสมอ (QUAL-CU-01)
Impact
- ไม่มี RBAC จริงในระบบ
- การ authorize พึ่ง apiKey level + JWT verification เท่านั้น
- ถ้ามีคนวาง
@auth(requires: ['ADMIN'])เพื่อจำกัด field — มันไม่ทำงาน
Fix ที่ควรทำ
- ลบ
requiresarg ออกจาก directive declaration (clean up schema) - หรือ implement ให้ครบ + เพิ่ม test matrix
Context builder
context: async ({ req, res }) => {
// If correct then return client info object otherwise return undefined.
const clientAppInfo = checkApiKey(req.headers.apikey)
// get the user token from the headers
const tokenWithBearer = req.headers.authorization || ''
const token = tokenWithBearer.split(' ')[1]
// try to retrieve a user with the token
let data = await decodeJwtToken(token)
data = data.data
let userData = null
// Get Session data
if (data.userCredential) {
userData = await getSessionFromTokenCredential(data.userCredential)
}
// Verify
const verify = await verifyToken(token)
let isVerify = false
if (typeof verify.data !== 'undefined') {
if (typeof verify.data.verify !== 'undefined') {
isVerify = verify.data.verify
}
} else {
isVerify = verify.error === null
}
// add the user to the context
return {
token,
isVerify, // (Boolean) Data is verified
userData, // Should be access to data only
headers: req.headers,
clientAppInfo
}
},context builder ส่ง clientAppInfo ให้ directive — แต่ ไม่ throw ถ้า apiKey invalid — gate นั้นอยู่ที่ directive เอง
Bug — context ไม่ fail-fast
ถ้า apiKey invalid (clientAppInfo === undefined) context ยังคง return ปกติ — directive ถึงจะ throw ทีหลัง ทำให้ logging ของ request ที่ invalid ไม่ครบถ้วน
Bug — decode 2 ครั้ง
decodeJwtToken(token) (บรรทัด 45) และ verifyToken(token) (บรรทัด 54) ทำงานซ้อนกัน — decode ครั้งแรกไม่ verify, ครั้งที่สอง verify ทั้งคู่อ่าน token จาก string เดียวกัน เป็น overhead เล็กน้อย
JWT verification
ใน directive (gate 4 หลัง ignoreAuth bypass)
const verifyProfile = await verifyToken(context.token)export const verifyToken = (token, option = {}) => {
try {
const decodedVerify = decodeToken(token, true, option)
return { data: decodedVerify, error: null }
} catch (e) {
log.warning(log.logTextFormat('TOKEN_VERIFY_FAILED', 'Method:verifyToken', 'WARNING', 'Verify token has failed.', { error: e, token, option }))
return { data: {}, error: e }
}
}const decodeToken = (token, verify = true, verifyOption = {}) => {
// ...
if (token) {
if (verify) {
let verifyDecoded
if (Object.keys(verifyOption).length > 0) {
verifyDecoded = jwt.verify(token, getPrivayeKey(), verifyOption)
} else {
verifyDecoded = jwt.verify(token, getPrivayeKey())
}ไม่มี algorithms: ['HS256'] allow-list — เปิดช่อง alg-confusion attack ในทางทฤษฎี (เหมือน vtrc-api บท 9.3)
Session check (isLoggedIn)
หลัง JWT verify แล้ว directive ตรวจ session ใน Redis
const loggingIn = await isLoggedIn(profile)isLoggedIn อยู่ใน lib/authenAndAuthorize — ใช้ getSessionFromTokenCredential ที่ดูใน 3.2.3
ถ้า session ไม่อยู่ → throw AuthenticationError('You must be logged in...')
Session lookup ไม่มี JSON scan
ต่างจาก vtrc-api ที่ใช้ LIKE '%jwtid%' scan JSON blob — cu-central-api lookup ผ่าน key pattern SESSION:{redisPrefix}:{userUid}_{sessionId} โดยตรง — ทำงานเร็วกว่า
เปรียบเทียบกับ vtrc-api @auth
| Aspect | vtrc-api | cu-central-api |
|---|---|---|
| Args | accessRole, accessMenu | requires, accessLevel, ignoreAuth |
| apiKey ที่ใช้ | จาก config (7 prod keys) | hardcoded 2 keys ใน controller |
| RBAC | getCurrentRoleUser + checkMenuAuth (มี bug CORR-4) | requires commented out — ไม่ทำงาน |
| Bypass | ไม่มี | 🔴 ignoreAuth: true สำหรับ apiKey-only |
| Session lookup | LIKE '%jwtid%' JSON scan | Redis key pattern (ดีกว่า) |
| Default-deny | ไม่มี (field ที่ลืม @auth = public) | ไม่มี (เหมือนกัน) |
| Error handling | generic | แยก TokenExpiredError vs JsonWebTokenError (auth.js:61-67) |
ตารางสรุปช่องโหว่ในบทนี้
| # | ปัญหา | Severity | ID | File:line |
|---|---|---|---|---|
| 1 | apiKeys hardcoded ใน code | 🔴 Critical | SEC-CU-02 | cu-central-api/main-api/src/lib/auth/auth.controller.js:258-271 |
| 2 | ignoreAuth bypass | 🔴 Critical | SEC-CU-05 | cu-central-api/main-api/src/directives/auth.js:37-38 + cu-central-api/main-api/src/typeDefs/searchProfile.js:12 |
| 3 | requires commented out | 🟡 High | QUAL-CU-01 | cu-central-api/main-api/src/directives/auth.js:48-53 |
| 4 | ไม่มี algorithms allow-list | 🟡 High | — | cu-central-api/main-api/src/lib/jwtToken.js:143-145 |
| 5 | ไม่มี default-deny | 🟡 High | — | directive เฉพาะ field ที่ annotated |
| 6 | /auth/signinbypass endpoint | 🔴 Critical | SEC-CU-04 | cu-central-api/main-api/src/routes.js:31-37 |
| 7 | context ไม่ fail-fast ถ้า apiKey invalid | 🟢 Medium | QUAL-CU-04 | cu-central-api/main-api/src/index.js:38 |
| 8 | decode token 2 ครั้ง (overhead) | 🟢 Medium | PERF-CU-02 | cu-central-api/main-api/src/index.js:45,54 |
อ่านต่อ → 3.2.5 Multi-tenant SourceDB sharding