Skip to content

02 · Core pipeline — Public API, auth, SQL injection, consumers

vtrc-common ไม่มี request pipeline แบบ npm library (import class ข้าม process) — มันคือ NestJS HTTP service ที่ consumer เรียกผ่าน REST บทนี้อธิบาย bootstrap → auth → API ทั้ง 3 module → จุด SQL injection → แผนที่ผู้เรียกจริงจาก grep ทั้ง monorepo

อ้างอิง branch: prod


1. Bootstrap pipeline

ลำดับใน vtrc-common/src/main.ts (branch: prod):12-41:

NestFactory.create(AppModule, { logger: I3GatewayLogger })

  ├── setGlobalPrefix(`${BASE_PATH}api/v1`)     → vtrc-common/api/v1
  ├── bodyParser.json / urlencoded limit 50mb   → รองรับ base64 ไฟล์ใหญ่
  ├── useGlobalFilters(HttpExceptionFilter)
  ├── useGlobalGuards(JwtAuthGuard)
  ├── useGlobalPipes(ValidationPipe whitelist+forbidNonWhitelisted)
  ├── enableCors({ origin: '*', allowedHeaders: '*', ... })
  ├── SwaggerModule.setup(`${BASE_PATH}docs`, ...)
  └── listen(port, '0.0.0.0')

จุดที่ควรระวังตอนแก้:

จุดพฤติกรรมหลักฐาน
Body limit 50mbอัปโหลด base64 หลายไฟล์ใน request เดียวได้main.ts:24-25
CORS origin: '*'browser จาก origin ใดก็เรียกได้main.ts:29-33
ValidationPipe forbidNonWhitelistedfield นอก DTO → 400main.ts:28
Listen 0.0.0.0bind ทุก interface ใน containermain.ts:37

2. Auth pipeline — เกือบทุก route เป็น public

Guard

JwtAuthGuard.canActivate (vtrc-common/libs/jwt/jwt-auth.guard.ts (branch: prod):15-24):

typescript
canActivate(context: ExecutionContext) {
  const isPublic = this.reflector.getAllAndOverride<boolean>(isPublicKey, [
    context.getHandler(),
    context.getClass(),
  ]);
  const request = context.switchToHttp().getRequest();
  this.serviceName = request.url;
  if (isPublic) {
    return true;
  }
  return super.canActivate(context);
}

@Public() = SetMetadata('isPublic', true) (:39-41)

Strategy

JwtStrategy (vtrc-common/libs/jwt/jwt.strategy.ts (branch: prod):10-22):

  • ดึง token จาก Authorization: Bearer ...
  • ignoreExpiration: false
  • secretOrKey: configService.get('jwtSecretKey') ← env SECRET_KEY
  • validate(payload) คืน payload ทั้งก้อนโดยไม่ตรวจ uid / role / issuer

สรุปสถานะ auth ต่อ endpoint

Endpoint@Public()ผลจริง
ทุก /address/* (6 route)ใช่ไม่ต้อง JWT
ทุก /bank/* (2 route)ใช่ไม่ต้อง JWT
POST /file/save, POST /file/deleteใช่เขียน/soft-delete ได้โดยไม่ auth
GET /file/:id, POST /file/getFileArrayไม่ต้อง JWT — แต่ forge ได้ถ้ามี SECRET_KEY จาก .env ที่ commit

ดู SEC-COMMON-01 / SEC-COMMON-03 ใน 04 Scorecard


3. Address module — query variants และ SQL injection

3.1 Safe paths (TypeORM / parameterized)

Service methodRepository methodเทคนิคหลักฐาน
fetchListAllProvincefindAllentityManager.find(MProvince, { isActive: true })address.repository.ts:17-23
fetchListAumphurfindAumphurByProvinceCode ผ่าน AddressUtilTypeORM where + relationsrepository.ts:25-39, address.util.ts:13-32
fetchListTumbolfindTumbolByProvinceCodeAndAumphurCodeTypeORM find + relationsrepository.ts:44-59
fetchListTumbolV3 (/testTumbol2)findQueryBuilderTumbolByProvinceCodeAndAumphurCodeQueryBuilder + .setParameter()repository.ts:101-114
fetchListTumbolV4 (ไม่มี route)findTumbolByProvinceCodeAndAumphurCodeV2QueryBuilder named paramsrepository.ts:65-74

ตัวอย่างที่ทำถูกแล้วในไฟล์เดียวกัน:

typescript
.where('p.province_code = :provCode')
.andWhere('a.aumphur_code = :amphCode')
.andWhere('a.is_active = 1')
.setParameter('provCode', provCode)
.setParameter('amphCode', amphCode)
.getRawMany();

แหล่ง: vtrc-common/src/modules/address/address.repository.ts (branch: prod):109-114

3.2 Unsafe paths — SQL injection (SEC-COMMON-02)

สองเมธอดต่อ raw SQL ด้วย string interpolation ของ provCode / amphCode ที่มาจาก HTTP query (TumbolReqDto@IsString() อย่างเดียว ไม่ sanitize):

A) findNativeTumbolByProvinceCodeAndAumphurCode — ถูกเรียกจาก GET /address/testTumbolfetchListTumbolV2:

typescript
return await this.entityManager.query(`
  SELECT t.tumbol_code as tumbolCode, ...
  FROM m_aumphur a
  INNER JOIN m_tumbol t ON a.id = t.aumphur_id
  ...
  WHERE p.province_code = '${provCode}'
  AND   a.aumphur_code = '${amphCode}'
  AND   a.is_active = 1
`);

แหล่ง: vtrc-common/src/modules/address/address.repository.ts (branch: prod):81-94
เรียกจาก: address.service.ts:60-70, address.controller.ts:32-36

B) findSubDistirctByProvCodeAndDistrictCode (สะกด District ผิดในชื่อเมธอด) — ถูกเรียกจาก GET /address/listSubDistrictByProvCodeAndDistrictCode:

typescript
WHERE p.province_code = '${provCode}'
AND   a.aumphur_code = '${amphCode}'

แหล่ง: vtrc-common/src/modules/address/address.repository.ts (branch: prod):117-130
เรียกจาก: address.service.ts:89-97, address.controller.ts:44-48

3.3 Attack surface

เพราะ route ทั้งคู่เป็น @Public():

  1. ผู้โจมตีไม่ต้องมี JWT
  2. ส่ง provCode หรือ amphCode เป็น payload SQL เช่น ' OR 1=1--
  3. Query รันบน connection main (DB vtrc_common) ด้วยบัญชี sa ตาม .env

ผลกระทบ: อ่าน/แก้ข้อมูลใน vtrc_common ตามสิทธิ์ DB account (รวมตาราง file metadata และ bank ใน database เดียวกัน)

การแก้ไขที่แนะนำ: เปลี่ยนทั้งสองเมธอดให้ใช้ pattern เดียวกับ findQueryBuilderTumbolByProvinceCodeAndAumphurCode หรือ entityManager.query(sql, [provCode, amphCode]) แบบ parameterized

3.4 In-memory cache อำเภอ

AddressUtil.MAP_PROVINCE_CODE_AND_ARRAY_AUMPHUR (libs/utils/address.util.ts (branch: prod):11,16-26) — cache ต่อ process ไม่มี TTL:

  • ข้อดี: ลด query ซ้ำเมื่อ frontend cascade จังหวัด→อำเภอ
  • ข้อเสีย: ถ้า admin แก้ master data ใน DB ต้อง restart process ถึงจะเห็นค่าใหม่
  • Cache ใช้เฉพาะ listAumphur path — ไม่เกี่ยวกับ raw SQL path

3.5 Response shape ตัวอย่าง

GET /address/listAllProvince:

typescript
return { provinces: arrDto }; // ProvinceDto[] จาก entity map

แหล่ง: address.service.ts:26-28

GET /address/listTumbol:

typescript
return { tumbols: result }; // TumbolDto[] จาก AddressUtil.convertEntityOfAumphurToTumbolDto

แหล่ง: address.service.ts:52-54

GET /address/testTumbol2 คืน raw array จาก QueryBuilder โดยตรง (ไม่มี wrapper { tumbols: ... }) — shape ไม่สม่ำเสมอข้าม endpoint (address.service.ts:73-78)


4. Bank module

POST /bank/getBankList

Filter:

typescript
where: {
  isActive: 1,
  bankCode: Not('001'),
  bankBranch: '0000', // สำนักงานใหญ่
},
order: { bankCode: 'ASC' },

แหล่ง: vtrc-common/src/modules/bank/bank.service.ts (branch: prod):18-27

Response: { bankList: [{ id, bankCode, bankBranch, bankName, isActive }] } — map bankNameThbankName (:35-41)

ใช้ Promise.all + spread array ใน loop (:31-44) — ทำงานได้แต่ O(n²) บน array copy; ข้อมูลธนาคาร HQ มีจำนวนไม่มากจึงยังไม่เป็น bottleneck จริง

POST /bank/getBankBranchList

Body: { bankCode: string } (bank.req.dto.ts:4-8)

Filter: isActive: 1, bankBranch: Not('0000'), bankCode: body.bankCode (bank.service.ts:53-58)

Response: { bankBranchList: [{ id, bankCode, bankBranch, bankBranchName, isActive }] } — map bankNameThbankBranchName

Consumer ฝั่ง backoffice ที่ยืนยันแล้ว: vtrc-rc-backoffice/src/pages/OtherWelfare/Form/sector/includes/OtherPaymentBank.js:1892,1918,1959 (hardcode UAT URL)


5. File module

POST /file/save (@Public())

Flow ใน FileService.saveFile (vtrc-common/src/modules/file/file.service.ts (branch: prod):35-77):

  1. วน body.files ตรวจ mime.lookup(item.fileName) — ถ้าไม่มีนามสกุล → BadRequestException
  2. สร้างชื่อไฟล์สุ่ม: getRandomFileName() = ISO timestamp + random digits (:28-33)
  3. path = {module}/{YYYY-MM-DD}/ ภายใต้ process.cwd()/uploads/
  4. fs.writeFileSync(path, item.base64, 'base64')
  5. entityManager.save(FileEntity, fileBodys)createBy/updateBy hardcode เป็น 'system'

DTO บังคับ @IsBase64() บน field base64 (file.dto.ts:6-9)

ความเสี่ยง: เพราะ @Public() — ใครก็อัปโหลดได้ไม่จำกัดขนาดเกิน body parser 50mb, เขียน disk จนเต็ม, แทรก record ในตาราง file

POST /file/delete (@Public())

Soft-delete เท่านั้น:

typescript
await this.entityManager.save(
  FileEntity,
  body.id.map((ele) => ({ id: ele, isCancel: 1 })),
);

แหล่ง: file.service.ts:96-106ไม่ลบไฟล์จาก disk

GET /file/:id (ต้อง JWT)

findOne ตาม id → createReadStream(process.cwd() + filePath)StreamableFile พร้อม Content-Disposition (file.service.ts:79-94)

ไม่เช็ค isCancel — ไฟล์ที่ soft-delete แล้วยัง stream ได้ถ้ายังอยู่บน disk

POST /file/getFileArray (ต้อง JWT)

find({ where: { id: In(body.id) } }) คืน metadata array (file.service.ts:108-116) — ไม่คืน binary


6. หลักฐานว่าไม่ใช่ npm library

  1. ไม่มี repo ใดใน workspace ประกาศ vtrc-common หรือ vtrc-common-v1 ใน dependencies / devDependencies ของ package.json (grep ทั้ง monorepo)
  2. มี Dockerfile ที่ CMD yarn start:prod — process ค้างฟัง port (Dockerfile:25)
  3. .env ตั้ง BASE_PATH + PORT ตาม pattern microservice อื่น
  4. Consumer ทั้งหมดเรียกผ่าน HTTP URL ที่มี path /vtrc-common/api/v1/...

7. Consumer map (grep ทั้ง monorepo)

Active consumers

RepoวิธีเรียกEndpoint ที่ใช้หลักฐาน
benefit-api (branch: prod)axios + env VTRC_COMMON_SERVER/file/save, /file/delete, /file/getFileArraydisbursement.service.ts:143,161,605,611,639,645; wdhospitals.service.ts:67; payments.service.ts:902
vtrc-mobile${HOST}/vtrc-common/api/v1/.../file/*, /address/listAllProvince, /listAumphur, /listTumbolFileUpload.js:267; withdrawSlice.js:875,902,930; recruitment.js:110,121,132; OtherWelfare.js:1038
vtrc-weburlCommon() / checkLinkURL() hardcode UAT/prod host/address/* (disaster form, recruit contact)auth.js:196-220; InDisasterHouse.js:527,606,681; secContact.js:117,198,296
vtrc-rc-backofficeaxios-common.js host switch + hardcode ในบางหน้า/address/* (ผ่าน axiosCommon), /bank/getBankList, /bank/getBankBranchListaxios-common.js:6-8; OtherPaymentBank.js:1892+; หลายหน้า Recruitment import axios-common

Vestigial / inactive

Repoสถานะหลักฐาน
job-scheduler-apiมี VTRC_COMMON_SERVER ใน .env แต่ไม่พบ call ใน src/env อย่างเดียว
recruitment-apicommonServer: process.env.VTRC_COMMON_SERVER ใน config แต่ไม่พบ call ใน src/recruitment-api/config/configuration.ts:8
recruitment-webURL ถูก comment ปิดrecruitment-web/src/config/axios-common.js:5

Consumer ที่ไม่เกี่ยว (false positive จาก grep)

vtrc-api / cu-central-api มีโฟลเดอร์ address / bank ของตัวเอง — ไม่เรียก vtrc-common HTTP; เป็น master data คนละระบบ

ตัวอย่าง call จาก benefit-api

typescript
const files = await this.axios.post<ResponseSaveFile[]>(
  `${this.config.get('VTRC_COMMON_SERVER')}vtrc-common/api/v1/file/save`,
  {
    files: [{
      base64: ele.base64,
      module: 'benefit',
      fileName: ele.filename,
      sort: ele.sort,
      description: ele.description ?? null,
    }],
  },
);

แหล่ง: benefit-api/src/modules/disbursement/disbursement.service.ts (branch: prod):143-153

หมายเหตุ — URL รวม VTRC_COMMON_SERVER + path vtrc-common/api/v1/... ซ้ำคำว่า vtrc-common ใน path (env มักลงท้าย / เช่น http://vtrc-common:9310/) ให้ตรงกับ BASE_PATH

ตัวอย่าง host switch จาก backoffice

javascript
const checkLinkURL = () => {
  if (window.location.href.includes("https://vtrcbackoffice.redcross.or.th/")
      || window.location.href.includes("https://rc-vtrcbackoffice.redcross.or.th/")) {
    return 'https://vtrcapi.redcross.or.th/vtrc-common/api/v1'
  } else {
    return 'https://uat-vtrcapi.redcross.or.th/vtrc-common/api/v1'
  }
}

แหล่ง: vtrc-rc-backoffice/src/config/axios-common.js:3-9

vtrc-web ใช้ pattern คล้ายกันใน urlCommon() แต่ในโค้ดที่ตรวจแล้วหลาย branch ชี้ไป UAT (vtrc-web/src/utils/auth.js:214-220) — ต้องระวังตอน debug ว่า frontend local/prod ชี้ endpoint ไหนจริง


8. Error handling

HttpExceptionFilter (libs/exception/http-exception.filter.ts (branch: prod):8-18) จับเฉพาะ HttpException แล้ว flatten message เป็น:

json
{ "statusCode": <n>, "message": <string|array> }

Address service หลายเมธอด catch แล้วแค่ logger.error โดยไม่ rethrow (address.service.ts:30-32,43-45,...) → client อาจได้ undefined / empty response แทน 4xx/5xx เมื่อ DB ล้ม — ต่างจาก Bank/File ที่ throw new BadRequestException(error)


9. สรุป pipeline ต่อ request type

Browser / Mobile / benefit-api
        │  HTTPS

nginx (vtrcapi.redcross.or.th)  path /vtrc-common/*


vtrc-common :9310
  JwtAuthGuard → @Public? skip : verify JWT
  ValidationPipe
  Controller → Service → (Repository | EntityManager)
        │                    │
        │                    ├── MSSQL vtrc_common
        │                    └── ./uploads/... (file only)

   JSON / StreamableFile