2way-api — Core Pipeline
บทนี้อธิบาย lifecycle ของ HTTP request และ cron job ใน 2way-api (branch: prod) จาก edge จนถึง DB / LINE — พร้อม trace ที่พิสูจน์จากซอร์ส และจุดเสี่ยงที่ตรวจแล้ว (SQL injection, backdoor login, auth bypass)
ดูแผนที่โมดูลที่ 01-repository-map และ persistence/integrations ที่ 03-persistence-integrations
Request lifecycle ภาพรวม
ทุก HTTP request ผ่านชั้นเหล่านี้ก่อนถึง controller method:
Client
│
▼
NestExpressApplication (main.ts)
│ bodyParser 50mb
│ CORS origin=*
▼
Global prefix: `${BASE_PATH}api/v1`
│ ตัวอย่าง: 2way-api/api/v1/announces
▼
JwtAuthGuard (global)
│ ถ้า handler มี @Public() → ผ่านทันที
│ ไม่งั้น → passport-jwt ตรวจ Bearer + SECRET_JWT_TWOWAY
▼
ValidationPipe (whitelist + forbidNonWhitelisted)
│
▼
Controller method
│
▼
Service
│ EntityManager name 'main' และ/หรือ axios ออกนอกระบบ
▼
JSON response หรือ HttpExceptionFilterหลักฐาน bootstrap: 2way-api/src/main.ts:30-34
ลำดับในโค้ดคือ Filter → Guard → Pipe ตามที่ลงทะเบียนใน main.ts (Nest จะจัด execution order ตาม lifecycle ของ Nest เอง; ที่สำคัญคือทั้งสามตัวถูกติดแบบ global)
JwtAuthGuard และ @Public() — พฤติกรรมจริง
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(isPublicKey, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}แหล่ง: 2way-api/libs/jwt/jwt-auth.guard.ts:15-24
@Public() = SetMetadata('isPublic', true) (jwt-auth.guard.ts:39-41)
เมื่อไม่ public:
AuthGuard('jwt')เรียกJwtStrategy- Strategy ดึง Bearer จาก header (
jwt.strategy.ts:12) - verify ด้วย
SECRET_JWT_TWOWAY,ignoreExpiration: false(jwt.strategy.ts:13-14) validateคืน payload ทั้งก้อนเข้าrequest.user(jwt.strategy.ts:19-22)- ถ้า fail →
UnauthorizedExceptionในhandleRequest(jwt-auth.guard.ts:26-35)
ผลกระทบเชิงระบบ: ปริมาณ @Public() ใน controller ที่ live ทำให้ JWT เป็น "opt-in protection" มากกว่า default-deny — โดยเฉพาะ complaints และ employees ที่เปิดเกือบทั้งหมด
ValidationPipe
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));แหล่ง: 2way-api/src/main.ts:34
whitelist: true— ตัด property ที่ไม่มี decorator ใน DTO ทิ้งforbidNonWhitelisted: true— ถ้ามี property แปลก จะ 400
เงื่อนไข: DTO ต้องมี class-validator decorators จริง — ถ้า controller รับ query: any หรือ @Body() body ที่ไม่มี class จะไม่ได้รับการปกป้องเต็มรูปแบบ
ตัวอย่าง DTO ที่ validate จริง: AuthDto มี @IsString() บน username/password (2way-api/src/dto/auth.dto.ts:4-12)
HttpExceptionFilter
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
// ...
response.status(status).json({
statusCode: status,
message: message['message'] || message['error'],
});
}
}แหล่ง: 2way-api/libs/exception/http-exception.filter.ts:5-19
จับเฉพาะ HttpException — controller หลายจุด throw 'No Bearer token' เป็น string (announces.controller.ts:49-51) ซึ่งไม่ใช่ HttpException จึงได้ error shape คนละแบบจาก Nest default
Trace A — GET /announces (list ประกาศ, @Public)
1) Route entry
@Public()
@Get()
getByCondition(@Query() query: AnnounceRequestDto, @Headers() headers: any) {
if (query?.isMobile === null || query === undefined) {
if (!headers.authorization) {
throw 'No Bearer token';
}
}
return this.service.getByCondition(query, headers);
}แหล่ง: 2way-api/src/modules/announces/announces.controller.ts:45-54
Full URL ตัวอย่าง (UAT/local ตาม BASE_PATH):
GET /2way-api/api/v1/announces?isMobile=true&empCode=12345&typeGroup=RC
2) Guard clause ระดับ controller
เงื่อนไข query?.isMobile === null || query === undefined — ถ้า client ส่ง isMobile=true จะ ข้าม การเช็ค Authorization แม้ route จะเป็น @Public() อยู่แล้ว การเช็คนี้จึงเป็น soft gate สำหรับเส้นทาง admin ที่ไม่ได้ส่ง isMobile
3) Service dispatch
AnnounceService.getByCondition → findListOfAnnounce (announces.service.ts ช่วงต้นไฟล์เรียกต่อที่บรรทัด ~164)
4) SQL construction — จุด SQL injection ที่ยืนยันแล้ว
findListOfAnnounce ต่อ string จาก DTO โดยตรง:
let typeGroup = ` AND c.type_group = 'RC' `;
if (dto.typeGroup !== null && dto.typeGroup !== undefined) {
typeGroup = ` AND c.type_group = '${dto.typeGroup}' `;
}แหล่ง: 2way-api/src/modules/announces/announces.service.ts:167-170
ในสาขา mobile (dto.isMobile && dto.empCode):
ISNULL((SELECT aAll3.id FROM t_content_assign_all aAll3
WHERE c.id = aAll3.content_id AND aAll3.emp_code = '${dto.empCode}' ...และ:
AND (t.name_en = '${contentType}' AND t_assign.emp_code = '${dto.empCode}')
OR (t.name_en = '${contentType}' AND c.type_assign = 'ALL')แหล่ง: 2way-api/src/modules/announces/announces.service.ts:198-225
ในสาขา admin:
let whereOrgCode = `AND h1.org_code = '${dto.orgCode}'`;
let whereDiviCode = `AND h1.divi_code = '${dto.diviCode}'`;แหล่ง: announces.service.ts:230-231
Date / type / pagination ก็ต่อ string:
strQuery += ` AND c.offer_date BETWEEN '${moment(dto.from).format('YYYY-MM-DD')}' AND '${moment(dto.to).format('YYYY-MM-DD')}'`;
strQuery += `AND t.id = ${dto.announceType}`;
strQuery += ` OFFSET ${dto.offset ?? 0} ROWS FETCH NEXT ${dto.limit ?? 10} ROWS ONLY`;แหล่ง: announces.service.ts:277-288
รันด้วย:
const countAll = await this.entityManager.query(strQuery);
const result = await this.entityManager.query(strQuery);แหล่ง: announces.service.ts:285-290
ไม่มี parameterized placeholders สำหรับค่าจาก client → input เช่น empCode, typeGroup, orgCode, offset สามารถฉีด SQL ได้ถ้าไม่มีชั้นกรองอื่นด้านหน้า
5) สาขา admin อ่าน JWT เองทั้งที่ route เป็น Public
const { profile, level } = await this.profileUtil.getProfile(headers);
if (level === 1) {
whereOrgCode = '';
whereDiviCode = '';
}แหล่ง: announces.service.ts:233-240
ProfileUtil.getProfile ถอด Bearer ด้วย SECRET_JWT_TWOWAY และ ไม่ ตั้ง ignoreExpiration: true (ต่างจาก AuthService) — profiles.util.ts:8-18,21-29
ถ้า level === 1 จะถอด filter org/divi ออกทั้งก้อน → list ประกาศทั้งระบบ
6) Response shape
Service คืน { data, countAll } แล้ว controller/service ชั้นบน map เพิ่มเป็นรายการประกาศพร้อม countRead / isReaded / isKnown จาก SQL
Trace B — POST /announces/knowContent (รับทราบ + LINE)
1) Controller
@Public()
@Post('/knowContent')
knowContentAnnounce(@Body() body: AnnounceReadKnowDto) {
return this.service.knowContent(body);
}แหล่ง: announces.controller.ts:85-88
2) อัปเดต DB ตาม typeAssign
const content = await this.entityManager.findOne(TContent, { where: { id: body.contentId } });
if (content.typeAssign === 'ALL') {
// findOne / update หรือ save TContentAssignAll ด้วย body.empCode, body.lineId
} else {
// QueryBuilder update TContentAssign SET knowDate WHERE content_id + emp_code
}แหล่ง: announces.service.ts:1302-1356
สาขา ATTENDEE ใช้ parameterized QueryBuilder (:contentId, :empCode) — ปลอดภัยกว่า list SQL
สาขา ALL ใช้ TypeORM update/save ด้วย object — ก็ไม่ใช่ string concat
3) Side-effect LINE
ถ้ามี body.lineId:
const bodyLine = { lineId: body.lineId, messages: msg };
const pushMassage = this.lineService.pushMassage(bodyLine, content.typeGroup, this.uuid);แหล่ง: announces.service.ts:1361-1382
LineService.pushMassage POST ไป https://api.line.me/v2/bot/message/push ด้วย Bearer จาก lineToken หรือ lineCUToken ตาม typeGroup (line.service.ts:19-35)
4) Audit log
await this.entityManager.save(TLogsNotification, {
contentId: body.contentId,
moduleType: 'Announce',
serviceName: 'knowContent',
uuid: this.uuid,
createBy: body.empCode,
updateBy: body.empCode,
});แหล่ง: announces.service.ts:1387-1394
5) ช่องว่าง authorization
Route เป็น @Public() และรับ empCode / contentId / lineId จาก body โดยตรง — ไม่มี การเทียบว่า Bearer (ถ้ามี) ตรงกับ empCode ที่ส่งมา
ผลกระทบที่พิสูจน์ได้จากโค้ด: caller ที่รู้ contentId สามารถตั้ง knowDate และสั่ง push LINE ไปยัง lineId ใดก็ได้โดยไม่ผ่าน JWT
Trace C — Auth login pipeline (SSO + backdoor)
Routes
| Method | Path | Handler |
|---|---|---|
| POST | auth/ | signInNonEmp |
| POST | auth/redirectbypass | signInRedirectByPass |
| POST | auth/verifyToken | verifyToken |
ทั้งหมด @Public() (auth.controller.ts:13-28)
C1 — Backdoor i3admin (ยืนยันแล้ว)
async signInNonEmp(body: AuthDto) {
const { username, password } = body;
if (username === 'i3admin' && password === 'aA123456!') {
const mockProfile = { empCode: 'adm9999', /* ... level: 1 ... */ };
const mockToken = await jwt.sign(
{ id: 999999, profile: mockProfile, uid: '99999-ad-9999' },
process.env.SECRET_JWT_TWOWAY,
{ expiresIn: '48h' },
);
return { token: mockToken, success: true, statusCode: 2000, profile: mockProfile };
}
// ...
}แหล่ง: 2way-api/src/modules/auth/auth.service.ts:184-240
ข้อเท็จจริงที่ตรวจแล้ว:
- Username/password ฝัง hardcode ในซอร์ส
- ออก JWT จริงด้วย
SECRET_JWT_TWOWAYอายุ 48 ชั่วโมง mockProfile.level = 1→ ใน announce list จะได้สิทธิ์ข้าม org/divi filter- ไม่เรียก SSO และไม่เรียก
checkEmployeeCD
นี่คือ backdoor login ที่ทำงานในทุก environment ที่ deploy โค้ดชุดนี้ — ไม่มี feature flag
C2 — เส้นทางปกติหลัง backdoor ไม่ match
checkEmployeeCD(username) → GraphQL cu-central-api (CENTRALIZE_API_URL)
│
├─ isHasUser == true → throw 'Username or Password is incorrect.'
│ (พนักงานใน CD ไม่ให้ login ผ่านเส้นนี้)
│
└─ isHasUser == false → ssoLogin(username, password)
AES-256-CBC encrypt + POST SSO_END_POINT/ssoLogin
ถ้าสำเร็จ → jwt.sign({ loginToken }, SECRET_JWT_TWOWAY, 48h)แหล่ง: auth.service.ts:242-272, checkEmployeeCD ที่ 74-119, ssoLogin ที่ 151-179
หมายเหตุ logic: ถ้า username พบ ใน cu-central employee list จะ ปฏิเสธ login — SSO ใช้เฉพาะคนที่ "ไม่พบ" ใน CD
C3 — POST /auth/redirectbypass
@Public()
@Post('/redirectbypass')
signInRedirectByPass(@Headers() headers: any) {
return this.service.signInRedirectByPass(headers);
}แหล่ง: auth.controller.ts:19-22
Flow:
- ตัด Bearer จาก
headers.authorization verifyJwt(token, SECRET_KEY)ด้วยignoreExpiration: true(auth.service.ts:34,290)- อ่าน
profileData.profile.empCode - Query
teamMember/teamAdminเพื่อได้level(parameterized@0) —auth.service.ts:299-306 - ออก JWT ใหม่ด้วย
SECRET_JWT_TWOWAYอายุ 48h พร้อม profile เดิม - การเรียก
fetchEmployeeProfileForOtherService(vtrc-api GraphQL) ถูก comment —auth.service.ts:295-296,326-327
ผล: token จากระบบ VTRC (ลายเซ็น SECRET_KEY) ที่หมดอายุแล้วยังแลกเป็น two-way token ได้
C4 — POST /auth/verifyToken
ตรวจ Bearer ด้วย SECRET_JWT_TWOWAY แต่ผ่าน verifyJwt ที่ ignoreExpiration: true → token หมดอายุยัง "verify successfully" (auth.service.ts:339-356)
Trace D — Complaints workflow (เกือบทั้งหมด @Public)
Controller เปิด @Public() บนทุก active route รวมถึง write/state transitions:
| Work state | Route | Service method |
|---|---|---|
| create | POST /complaints | create |
| 0 | POST /complaints/changeDivicode | changeDivisionsWorkState0 |
| 1 | POST /complaints/adminAssign | adminAssignReponsibleWorkState1 |
| 1 | POST /complaints/adminReject | adminRejectReponsibleWorkState1 |
| 2 | POST /complaints/assigneeManage | responsibleAskForApprovalWorkState2 |
| 3 | POST /complaints/secretaryApprove | adminDivisionApprovalWorkState3 |
| 3 | POST /complaints/secretaryDecline | adminDivisionRejectWorkState3 |
| 4 | POST /complaints/superAdminApproval | superAdminApprovalWorkState4 |
| 4 | POST /complaints/superAdminReject | superAdminRejectWorkState4 |
แหล่ง route: complaints.controller.ts:13-114
Service ยาว ~2012 บรรทัด (complaints.service.ts) — ใช้ TypeORM entities + raw query สำหรับ summary/counts (entityManager.query ที่บรรทัด ~278, 322, 345, 370, 395)
ความเสี่ยง pipeline: ไม่มี JWT บังคับบน state machine → authorization ต้องพึ่ง logic ใน service (ถ้ามี) หรือ network ACL ด้านนอกเท่านั้น; จาก controller layer ไม่มี guard เพิ่ม
Trace E — Employees proxy → centralize-api
Controller
@Controller('employee') — ส่วนใหญ่ @Public(); เฉพาะ GET /employee และ GET /employee/cu ที่ comment @Public() ออกแล้วจึงต้อง JWT (employees.controller.ts:97-108)
EmpCodeMiddleware ที่ถูกปิด
async use(req, _res, next) {
next();
// ... verifyJwt + validateEmpCodeToken ทั้งก้อน comment ...
}แหล่ง: employees.controller.ts:33-49
Permission check ผ่าน view VM_PERMISSION_FOR_SEARCH_EMPLOYEE มีอยู่ใน validateEmpCodeToken (employees.service.ts:266-272) แต่ ไม่ถูกเรียก เพราะ middleware และ call site ใน getEmployees ถูก comment (employees.service.ts:112-125)
Service → REST
await this.http.get(`${this.config.get('CENTRALIZE_SERVER')}api/employee/organize`);
await this.http.get(`${this.config.get('CENTRALIZE_SERVER')}api/employee/division`, { params: ... });
// ... department, manager, employee, cu, getByEmpCode, level, position, posManage, posTechnical, groupแหล่ง: employees.service.ts:46-258
นี่คือขา NestJS centralize-api (REST) — คนละระบบกับ GraphQL ใน AuthService
รายละเอียด URL/env ดูบท 03
Trace F — LINE controller (เปิดทั้งก้อน)
@Controller('Line')
export class LineController {
@Public() @Post() pushMassage(...)
@Public() @Post('/multiCast') multiCast(...)
@Public() @Post('/nextPage') nextPageLine(...)
@Public() @Delete('/previousPage') previousPageLine(...)
}แหล่ง: line.controller.ts:9-35
ใครก็ตามที่เข้าถึง prefix ได้สามารถสั่ง push/multicast ไปยัง LINE user ID ได้โดยตรง — service จะใช้ channel token จาก config
nextPage / previousPage ยิงต่อไปที่ IP hardcoded ของ line-service (line.service.ts:76,86)
Trace G — Surveys (ผสม Public / JWT)
| Route | Auth |
|---|---|
GET /surveys, GET /surveys/:id/:empCode | @Public() + soft Bearer check แบบ announces |
GET /types, POST /, GET /summary, approve/reject/read, PUT :id | ต้อง JWT |
แหล่ง: surveys.controller.ts:13-75
SQL injection จุดที่ยืนยันใน surveys:
const countTypeAssignAll = await this.entityManager.query(
`SELECT id FROM t_content_survey_all WHERE content_id = ${item.id}`
);แหล่ง: surveys.service.ts:430
item.id มาจากผล query ก่อนหน้า (ไม่ใช่ user input ตรง) — ความเสี่ยงต่ำกว่า announce list แต่ยังเป็น string interpolation pattern เดียวกัน
Cron pipeline
Cron ไม่ผ่าน JwtAuthGuard / ValidationPipe — รันใน-process ตาม @nestjs/schedule
Announce / Survey / Complaint — 07:00
ทั้งสามใช้ CronExpression.EVERY_DAY_AT_7AM:
announce.task.service.ts:19-22survey.task.service.ts:21-25complaint.task.service.ts:21-22
Pattern ร่วม:
- raw SQL หา records ที่เข้าเงื่อนไขเวลา/สถานะ
- join หรือ lookup
userLineเพื่อได้lineID - เรียก
LineServicepush/multicast - อัปเดต history / flag
Notification PMS — โค้ดมี แต่ cron ปิด
// @Cron(CronExpression.EVERY_10_SECONDS)
// async handleCron() {
// await this.notificationLinePms();
// }แหล่ง: notification-pms.task.service.ts:19-23
เมธอด notificationLinePms ยังมี SQL:
FROM pms.dbo.tbnotify AS tn
INNER JOIN [2way].dbo.userline AS ul ON tn.empcode ... = ul.userid ...และ UPDATE:
await this.entityManager.query(
`UPDATE pms.dbo.tbNotify SET isSendLine = 1, sendLineDate = @0
WHERE notifyCode = '${notifyCode}'
AND fiscalYear = ${notifyItems.fiscalYear}
AND empCode IN (${empSet.map((code) => `'${code}'`).join(',')})`,
[new Date()],
);แหล่ง: notification-pms.task.service.ts:27-85
notifyCode / empCode มาจากผล SELECT ก่อนหน้า ไม่ใช่ HTTP input — จัดเป็น medium risk ของ string concat; ปัจจุบันไม่รัน เพราะไม่มี @Cron active
Upload pipeline
@Public()
@Post()
create(@Body() body: UploadDto) {
return this.service.create(body);
}แหล่ง: upload.controller.ts:19-22
UploadService.create:
- ตรวจ
attachFiles.length - แยก mime จาก
fileType; allowlist jpeg/jpg/png/pdf/mp4 - ตัด base64 หลัง comma
- เขียนไฟล์ใต้
/uploads/{prefixPath}/{YYYY-MM-DD}ผ่านbase64ToFile - คืน path/filename ให้ caller เก็บลง DB
แหล่ง: upload.service.ts:24-78
Static serve: app.useStaticAssets(..., { prefix: '/2way-api/' }) (main.ts:27-29) → URL เข้าถึงไฟล์โดยไม่ผ่าน JWT
มี @Post() สอง method บน path เดียวกัน (create และ complaints) — Nest จะ bind ตามลำดับประกาศ; เป็นคุณภาพโค้ดที่ควรรู้ตอน debug routing
EntityManager injection pattern
Services ที่คุย DB ใช้:
constructor(
@InjectEntityManager('main')
private readonly entityManager: EntityManager,
) {}Connection name 'main' ตรงกับ TypeOrmModule.forRootAsync({ name: 'main', ... }) (app.module.ts:28)
สองสไตล์ในโค้ด:
| สไตล์ | ตัวอย่าง | ความปลอดภัย input |
|---|---|---|
| TypeORM repository/QB | knowContent update assign | ดี (parameters) |
entityManager.query(str) | findListOfAnnounce | อันตรายถ้าต่อจาก DTO |
CORS และ body size — ผลต่อ attack surface
app.enableCors({
origin: '*',
allowedHeaders: '*',
methods: 'GET,PUT,POST,DELETE,PATCH,OPTIONS',
});
app.use(bodyParser.json({ limit: '50mb' }));แหล่ง: main.ts:30-38
origin: '*'เปิดให้ browser จาก origin ใดก็เรียกได้ (ถ้าผู้ใช้มี session/token)- body 50mb รองรับ upload base64 ใหญ่ — เพิ่มความเสี่ยง DoS/memory ถ้าระดับ network ไม่จำกัด
สรุปจุดเสี่ยงที่พิสูจน์ในบทนี้ (อ้าง scorecard)
| ID (ใน 04) | หลักฐานใน pipeline |
|---|---|
| Backdoor login | auth.service.ts:188 hardcode i3admin / aA123456! |
| SQL injection (announce list) | announces.service.ts:167-288 string concat + query(strQuery) |
Auth bypass via @Public | complaints / line / knowContent / employees ส่วนใหญ่ |
| ignoreExpiration บน verify | auth.service.ts:34 vs strategy false |
| Dual centralize | employees REST vs auth GraphQL (รายละเอียดบท 03) |
| PMS SQL concat | notification-pms.task.service.ts:77-84 (cron ปิดอยู่) |
รายละเอียด severity และ remediation → 04-scorecard
ลำดับอ่านแนะนำหลังจบบทนี้
- เปิด
announces.service.tsที่findListOfAnnounceอ่าน SQL ทั้งก้อน - เปิด
auth.service.tsที่signInNonEmpไล่ backdoor → CD check → SSO - นับ
@Public()ในcomplaints.controller.tsเทียบกับ surveys ที่ปิดบางส่วน - ไปบท 03 เพื่อ map env → ระบบปลายทางให้ครบ