Skip to content

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() — พฤติกรรมจริง

typescript
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:

  1. AuthGuard('jwt') เรียก JwtStrategy
  2. Strategy ดึง Bearer จาก header (jwt.strategy.ts:12)
  3. verify ด้วย SECRET_JWT_TWOWAY, ignoreExpiration: false (jwt.strategy.ts:13-14)
  4. validate คืน payload ทั้งก้อนเข้า request.user (jwt.strategy.ts:19-22)
  5. ถ้า fail → UnauthorizedException ใน handleRequest (jwt-auth.guard.ts:26-35)

ผลกระทบเชิงระบบ: ปริมาณ @Public() ใน controller ที่ live ทำให้ JWT เป็น "opt-in protection" มากกว่า default-deny — โดยเฉพาะ complaints และ employees ที่เปิดเกือบทั้งหมด


ValidationPipe

typescript
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

typescript
@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

typescript
@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.getByConditionfindListOfAnnounce (announces.service.ts ช่วงต้นไฟล์เรียกต่อที่บรรทัด ~164)

4) SQL construction — จุด SQL injection ที่ยืนยันแล้ว

findListOfAnnounce ต่อ string จาก DTO โดยตรง:

typescript
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):

typescript
ISNULL((SELECT aAll3.id FROM t_content_assign_all aAll3
  WHERE c.id = aAll3.content_id AND aAll3.emp_code = '${dto.empCode}' ...

และ:

typescript
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:

typescript
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:

typescript
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

รันด้วย:

typescript
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

typescript
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

typescript
@Public()
@Post('/knowContent')
knowContentAnnounce(@Body() body: AnnounceReadKnowDto) {
  return this.service.knowContent(body);
}

แหล่ง: announces.controller.ts:85-88

2) อัปเดต DB ตาม typeAssign

typescript
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:

typescript
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

typescript
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

MethodPathHandler
POSTauth/signInNonEmp
POSTauth/redirectbypasssignInRedirectByPass
POSTauth/verifyTokenverifyToken

ทั้งหมด @Public() (auth.controller.ts:13-28)

C1 — Backdoor i3admin (ยืนยันแล้ว)

typescript
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

typescript
@Public()
@Post('/redirectbypass')
signInRedirectByPass(@Headers() headers: any) {
  return this.service.signInRedirectByPass(headers);
}

แหล่ง: auth.controller.ts:19-22

Flow:

  1. ตัด Bearer จาก headers.authorization
  2. verifyJwt(token, SECRET_KEY) ด้วย ignoreExpiration: true (auth.service.ts:34,290)
  3. อ่าน profileData.profile.empCode
  4. Query teamMember/teamAdmin เพื่อได้ level (parameterized @0) — auth.service.ts:299-306
  5. ออก JWT ใหม่ด้วย SECRET_JWT_TWOWAY อายุ 48h พร้อม profile เดิม
  6. การเรียก fetchEmployeeProfileForOtherService (vtrc-api GraphQL) ถูก commentauth.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 stateRouteService method
createPOST /complaintscreate
0POST /complaints/changeDivicodechangeDivisionsWorkState0
1POST /complaints/adminAssignadminAssignReponsibleWorkState1
1POST /complaints/adminRejectadminRejectReponsibleWorkState1
2POST /complaints/assigneeManageresponsibleAskForApprovalWorkState2
3POST /complaints/secretaryApproveadminDivisionApprovalWorkState3
3POST /complaints/secretaryDeclineadminDivisionRejectWorkState3
4POST /complaints/superAdminApprovalsuperAdminApprovalWorkState4
4POST /complaints/superAdminRejectsuperAdminRejectWorkState4

แหล่ง 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 ที่ถูกปิด

typescript
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

typescript
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 (เปิดทั้งก้อน)

typescript
@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)

RouteAuth
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:

typescript
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-22
  • survey.task.service.ts:21-25
  • complaint.task.service.ts:21-22

Pattern ร่วม:

  1. raw SQL หา records ที่เข้าเงื่อนไขเวลา/สถานะ
  2. join หรือ lookup userLine เพื่อได้ lineID
  3. เรียก LineService push/multicast
  4. อัปเดต history / flag

Notification PMS — โค้ดมี แต่ cron ปิด

typescript
// @Cron(CronExpression.EVERY_10_SECONDS)
// async handleCron() {
//   await this.notificationLinePms();
// }

แหล่ง: notification-pms.task.service.ts:19-23

เมธอด notificationLinePms ยังมี SQL:

sql
FROM pms.dbo.tbnotify AS tn
INNER JOIN [2way].dbo.userline AS ul ON tn.empcode ... = ul.userid ...

และ UPDATE:

typescript
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

typescript
@Public()
@Post()
create(@Body() body: UploadDto) {
  return this.service.create(body);
}

แหล่ง: upload.controller.ts:19-22

UploadService.create:

  1. ตรวจ attachFiles.length
  2. แยก mime จาก fileType; allowlist jpeg/jpg/png/pdf/mp4
  3. ตัด base64 หลัง comma
  4. เขียนไฟล์ใต้ /uploads/{prefixPath}/{YYYY-MM-DD} ผ่าน base64ToFile
  5. คืน 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 ใช้:

typescript
constructor(
  @InjectEntityManager('main')
  private readonly entityManager: EntityManager,
) {}

Connection name 'main' ตรงกับ TypeOrmModule.forRootAsync({ name: 'main', ... }) (app.module.ts:28)

สองสไตล์ในโค้ด:

สไตล์ตัวอย่างความปลอดภัย input
TypeORM repository/QBknowContent update assignดี (parameters)
entityManager.query(str)findListOfAnnounceอันตรายถ้าต่อจาก DTO

CORS และ body size — ผลต่อ attack surface

typescript
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 loginauth.service.ts:188 hardcode i3admin / aA123456!
SQL injection (announce list)announces.service.ts:167-288 string concat + query(strQuery)
Auth bypass via @Publiccomplaints / line / knowContent / employees ส่วนใหญ่
ignoreExpiration บน verifyauth.service.ts:34 vs strategy false
Dual centralizeemployees REST vs auth GraphQL (รายละเอียดบท 03)
PMS SQL concatnotification-pms.task.service.ts:77-84 (cron ปิดอยู่)

รายละเอียด severity และ remediation → 04-scorecard


ลำดับอ่านแนะนำหลังจบบทนี้

  1. เปิด announces.service.ts ที่ findListOfAnnounce อ่าน SQL ทั้งก้อน
  2. เปิด auth.service.ts ที่ signInNonEmp ไล่ backdoor → CD check → SSO
  3. นับ @Public() ใน complaints.controller.ts เทียบกับ surveys ที่ปิดบางส่วน
  4. ไปบท 03 เพื่อ map env → ระบบปลายทางให้ครบ