Skip to content

recruitment-web — Core Pipeline

เล่มนี้ (branch: prod) trace flow หลักสี่ชุด: OTP login, AES + localStorage session, บันทึกใบสมัคร, และ สมัครตำแหน่งงาน — พร้อมจุดที่ email/OTP รั่วผ่าน GET query string และไฟล์แนบเป็น base64


Flow 1 — Login ด้วยอีเมล + OTP

ผู้ใช้เปิด login modal (HeaderHome)
  │  FormSignIn — กรอกอีเมล กดยืนยัน
  │  `pages/login/form-signIn.js` → props.handleLogin

components/header/index.js#handleLogin
  │  `header/index.js:218-246`
  │  validate email ฝั่ง client

  │  ถ้า process.env.REACT_APP_NODE_ENV === 'dev':
  │      ข้าม backend — ใช้ { otp: 111111, refCode: 'REF CODE TEST | OTP is 111111' }
  │      `header/index.js:230-231`
  │  มิฉะนั้น:
  │      GET /jobList/sent_otp/{email}
  │      `header/index.js:232`

  │  เก็บ refCode → setIsShowOtp(true) → แสดง FormOtp

FormOtp — กรอก OTP 6 หลัก (auto-focus ช่องถัดไป)
  │  อายุ OTP แสดงว่า 5 นาที (UI) + cooldown ส่งซ้ำ 60 วินาที
  │  `form-otp.js:124-137`

handleConfirmOtp
  │  `form-otp.js:48-62`
  │  รวม otp1..otp6 เป็น string
  │  GET /jobList/validate_otp/?email={email}&otp={otp}
  │      ↑ email + OTP อยู่ใน query string ของ GET
  │  ถ้าได้ token:
  │      localStorage.setItem('token', ...)
  │      localStorage.setItem('refreshToken', ...)
  │      เรียก onFinish() จาก header

header#onFinish
  │  `header/index.js:53-172`
  │  encryptData({ isOtp: true }) → เก็บใน user object เป็น checkLogin
  │  GET /appProfile/get_by_email/{email}
  │  ถ้าพบ profile:
  │      decryptText() ~25+ field PII (title, firstName, idCard, address, phone, ...)
  │      `header/index.js:104-148`
  │      setUserStorage(_dataLogin)  ← PII plaintext ใน localStorage
  │      ถ้า delAccStatus === 'wait' → modal ยกเลิกการลบบัญชี
  │  ถ้าไม่พบ:
  │      setUserStorage (ยังไม่มี id) → navigate /applicantProfile
  │      (ถ้ามี rc-job-req ใน storage จะส่ง state.jobReq ไปด้วย)

GET-query OTP leak (SEC-RCWEB-04)

CallMethodSensitive ใน URL?Citation
ส่ง OTPGET /jobList/sent_otp/{email}อีเมลใน pathheader/index.js:232
ยืนยัน OTPGET /jobList/validate_otp/?email=...&otp=...อีเมล + OTP ใน queryform-otp.js:51-52
LogoutPUT /jobList/logout?email=...อีเมลใน queryheader/index.js:266

ค่าเหล่านี้จะอยู่ใน access log ของทุก proxy/nginx ที่ request ผ่าน และอาจอยู่ใน browser history / Referer

Dev OTP bypass (SEC-RCWEB-03)

เงื่อนไข REACT_APP_NODE_ENV === 'dev' เป็น runtime check ใน bundle (header/index.js:230) — ไม่ใช่ dead-code ที่ tree-shake ออกได้ ถ้า build โดยตั้ง env เป็น dev ระบบ OTP จริงจะถูกข้ามและใช้ OTP คงที่ 111111


Flow 2 — Session gate ด้วย AES checkLogin

หลัง OTP สำเร็จ:

_dataLogin.checkLogin = encryptData({ isOtp: true })
  │  ใช้ REACT_APP_SECRET_KEY
  │  `header/index.js:59-69`, `cryptoJs.js:4-13`

setUserStorage(_dataLogin) → localStorage['user-login']
  │  โครงสร้าง: { value: { email, id, data, checkLogin, ... } }
  │  ไม่มี field expiry จริง (comment ปิด)
  │  `localStorage.js:1-9,35-37`

ตอนเข้าแอปใหม่ (App.js mount):

user = getUserStorage()
decryptedData = decryptData(user.checkLogin)
isOtp = decryptedData.isOtp
  │  บรรทัดที่เคยเช็ค expiry ถูก comment ไว้:
  │  //&& new Date().getTime() <= decryptedData.expiry
  │  `App.js:52-56`

ถ้า !isOtp และ path เป็น myjob / applicantprofile → history('/')
  `App.js:59-61`

สรุป: gate นี้เป็น soft client-side check เท่านั้น — ไม่ใช่ server session validation ผู้โจมตีที่แก้ localStorage ได้สามารถปลอม checkLogin ได้ถ้ามี SECRET_KEY จาก bundle


Flow 3 — กรอกและส่งใบสมัครงาน (Application Profile)

/ApplicantProfile
  │  DropdownDataProvider → GET /appProfile/get_dropdown_data
  │  `dropdownDataContext.js:47`

Content (content.js)
  │  getUser() จาก localStorage → setFormData ถ้ามีข้อมูลเดิม

  │  ผู้ใช้กรอก 10 step แล้วกด "บันทึก" หรือ "บันทึกและสมัครงาน"

handleSave(saveDraft)
  │  `content.js:905-973`
  │  form.validateFields() + ตรวจเพิ่ม:
  │    - idCard 13 หลัก (หลังตัด '-')
  │    - phone 10 หลัก
  │    - age >= 18
  │    - reference ≥ 1 คน (ถ้าไม่ใช่ draft)
  │    - ไม่ซ้ำ father/mother/spouse ต่อ type
  │    - ใบขับขี่ตรงกับ checkbox + ไฟล์แนบ
  │  getCountEmpRedCross()
  │    POST /appProfile/check_emp_redcross { email, idCard }
  │    ถ้า status=false และ count >= 2 → เปิด reCAPTCHA popup
  │    `content.js:882-903`

onFinish(values [, captchaToken])
  │  `content.js:384-585`
  │  setDataBeforeSave(values) → encrypt ทุก field PII + base64 ไฟล์ใหม่
  │  POST appProfile/create_app_profile  หรือ  PUT update_app_profile
  │  ตรวจ response:
  │    - isEmpRedCross → ห้ามบุคลากรสมัครผ่านฟอร์มนี้
  │    - isStatusWait → เลขบัตรซ้ำ
  │    - มี id → สำเร็จ; ถ้ามี jobReq → applyJob()
  │  ดึง get_by_email อีกรอบ → decrypt → setUserStorage ใหม่

Fields ที่ encrypt ด้วย encryptText ก่อนส่ง

จาก setDataBeforeSave (content.js:705-748):

title, firstName, lastName, gender, nationality, race, religion, birthDateStr, salaryStr, idCard, maritalStatus, militaryStatus, address, province, district, subDistrict, postCode, currentAddress, currentProvince, currentDistrict, currentSubDistrict, currentPostCode, phone, facebook, addressNo, moo, roomNumber, building, floor, village, lane, road, และชุด current* ของที่อยู่ละเอียด

ไม่ encrypt: email (lowercase อย่างเดียว), flags ใบขับขี่, consent flags, nested arrays (edu/exp/capability/reference), ไฟล์ metadata

Base64 attachments

ไฟล์ใหม่ (ยังไม่มี id) ถูกแปลงด้วย getPreviewUrlFileReader.readAsDataURL (utils/file.js:1-19) แล้วฝังใน:

item.attachFiles = [{
  fileType,
  fileBase64: await getPreviewUrl(item.attachFile)   // เฉพาะเมื่อ !id
}]

ที่ content.js:654-668 (professional license) และ content.js:677-696 (เอกสารแนบทั่วไป)

ไฟล์ที่มี id แล้วส่งแค่ metadata ไม่ส่ง base64 ซ้ำ

ไม่ใช่ multipart upload — ทั้งฟอร์ม + ไฟล์ไปใน JSON body เดียวของ create/update profile

Client-only size/type check: uploadForm.js:22-30 — ≤ 2MB และ MIME ใน LIST_ACCEPT / LIST_ACCEPTNOTPDFUNVERIFIED ว่า recruitment-api ตรวจซ้ำฝั่ง server


Flow 4 — สมัครตำแหน่งงาน (apply)

เกิดได้จากสองทาง:

  1. จาก ApplicantProfile หลังบันทึกสำเร็จพร้อม location.state.jobReqapplyJob() (content.js:987-1041)
  2. จากหน้า JobDetail โดยตรง → candidate/create ใน jobDetail/content/index.js:330
validateApplyJob (เฉพาะจาก content.js)
  │  `content.js:1233-1292` (โดยประมาณ — เรียกจาก onFinish)
  │  - checkStartDateApply() — วันเปิดรับ
  │  - GET candidate/check_duplicate_jobRequestNo?email=&jobReqDocumentNo=
  │  - checkInvalidData / checkAttachFile / checkCarLicense

POST candidate/create
  body รวม:
    status: 'wait'
    documentNo: `RCC-${Date.now()}`
    jobRequestNo, position*, bu*, orgUnit*, email, applyDate, ...
  │  `content.js:992-1022`

status 201 → ReactGA.event("สมัครงาน" / "สมัครงานสำเร็จ")
  │  `content.js:1023-1031`

Modal.success → navigate /myJob

บน JobDetail ยังมี PUT /jobRequest/update_visit นับ visit ทุกครั้งที่เปิดหน้า (jobDetail/index.js:27-30)


Flow 5 — ลบบัญชี (delete account)

จาก form-signOut.js เมื่อ login แล้ว:

  1. GET dropdown/get_dropdown_by_type?dropdownType=delAccReason (form-signOut.js:53-55)
  2. ผู้ใช้กรอกเหตุผล → POST deleteHistory/create_delete_history (form-signOut.js:175)
  3. ถ้า login มาแล้วเจอ delAccStatus === 'wait'PUT deleteHistory/check_update_del_acc เพื่อยกเลิก (header/index.js:174-182)

จุดที่ต้องแก้พร้อมกันเมื่อเพิ่ม field PII

จุดทำอะไรCitation
setDataBeforeSaveencrypt ก่อนส่งcontent.js:705-748
onFinish หลัง savedecrypt หลัง get_by_emailcontent.js:492-537
header#onFinishdecrypt ตอน loginheader/index.js:104-148

ถ้าเพิ่ม field ที่จุดเดียว ข้อมูลจะไม่ sync ระหว่าง login กับหลังบันทึก (QUAL-RCWEB-04)


reCAPTCHA

  • Site key อ่านจาก process.env.REACT_APP_RECAPTCHA_SITE_KET (typo KET) ที่ content.js:1355
  • ไม่บังคับทุกครั้ง — เปิดเฉพาะเมื่อ check_emp_redcross คืน count >= 2
  • Token ส่งเป็น captchaToken ใน payload profile (content.js:392-394)

สรุปความเสี่ยงของ pipeline นี้

  1. OTP และอีเมลใน GET URL → access log leak
  2. AES key ใน client bundle + git → การ encrypt ไม่มีความลับจริง
  3. PII plaintext ใน localStorage ไม่มี expiry
  4. Payload base64 ใหญ่ — timeout / memory ฝั่ง proxy เป็นความเสี่ยงเชิงปฏิบัติ
  5. Auth gate อยู่แค่ client (App.js) — server ต้อง enforce JWT เองที่ recruitment-api (นอก scope เล่มนี้)