บทเรียน 117 · Rails · Day 2

REST ใน Rails — resources, ช่องโหว่ mass assignment และ params.expect

~20–25 นาที (มีส่วนที่ยิง request จริงหลายรอบ) · ควรผ่าน บทเรียน 101 (Routing) · บท 103 (CSRF) และ บท 115 (Association) มาก่อน

บทนี้มีสองครึ่ง · ครึ่งแรกต่อจาก resources :posts ของบท 101: ทำยังไงเมื่อเมนูมาตรฐาน 7 อย่างไม่พอ หรือมากเกินไป และทำยังไงให้ URL บอกได้ว่า "คอมเมนต์นี้เป็นของบทความเลขไหน"
ครึ่งหลังคือเรื่องความปลอดภัย: ฟอร์มของเรามีแค่ 2 ช่อง แต่คนร้ายแอบส่งช่องที่ 3, 4, 5 มาได้ · คุณจะได้ลองเจาะแอปตัวเองจริง ๆ ให้ยอดวิวกลายเป็น 999999 แล้วปิดช่องนั้นด้วยบรรทัดเดียว
อ่านจบแล้วคุณจะเขียน route แบบซ้อนกันได้ · อ่าน bin/rails routes ออก · และเขียน params.expect ที่กันช่องแปลกปลอมได้

🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้ทั้งหมด

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
📦 ข้อมูลตัวอย่าง: ใช้ตาราง users / posts / comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับ บท 107 หัวข้อ "เตรียมตู้ของวันที่ 2" (สมชาย · สมหญิง · มานะ · บทความ 6 ชิ้น · คอมเมนต์ 7 อัน) · ผลรันทุกกล่องมาจากเครื่องจริงที่เปิด server พอร์ต 3000 · เวลา ms · เลข 0x… · วันเวลาใน log ของคุณจะไม่ตรงกับของผม · ผลในบทรันกับตารางสามลิ้นชักแบบบท 107 เปล่า ๆ — ถ้าแอปของคุณยังมีของจากบท 116 (ช่อง comments_count · counter_cache · touch) บรรทัด INSERT INTO "posts" จะมีช่อง comments_count เพิ่ม และตอนสร้างคอมเมนต์จะมีบรรทัด UPDATE "posts" ตามมาอีกบรรทัด — ไม่ใช่ error

ทวน — REST และ resources :posts คือ 7 เมนูมาตรฐาน วางข้าง Gin ทีละแถว

REST คือคำที่คุณเจอแล้วในฝั่ง Go (บท 33) · ภาษาคน: ตั้งชื่อ URL เป็นชื่อของ (คำนาม เช่น /posts) แล้วให้ verb (GET · POST · PATCH · DELETE) บอกว่าจะทำอะไรกับของนั้น · ไม่ตั้ง URL เป็นคำกริยาอย่าง /deletePost

ในร้านเรา REST คือกฎแฟรนไชส์ที่ให้ทุกแผนกรับใบสั่งรูปแบบเดียวกัน: ชื่อแผนก + เลขจาน (ถ้ามี) + อยากทำอะไร · "ของหนึ่งชนิดที่มี URL ของตัวเอง" (บทความ · คอมเมนต์) เรียกว่า resource — ที่มาของชื่อคำสั่ง resources

ในบท 101 เราพิมพ์ resources :posts บรรทัดเดียว แล้ว Rails ลงรายการให้พนักงานต้อนรับครบชุด · สไลด์หน้า 46 นับได้ 7 route แต่ตอนรัน bin/rails routes ในบท 101 เราเห็น 8 แถว — ไม่มีใครผิด:

$ bin/rails routes -c posts
   Prefix Verb   URI Pattern               Controller#Action
    posts GET    /posts(.:format)          posts#index
          POST   /posts(.:format)          posts#create
 new_post GET    /posts/new(.:format)      posts#new
edit_post GET    /posts/:id/edit(.:format) posts#edit
     post GET    /posts/:id(.:format)      posts#show
          PATCH  /posts/:id(.:format)      posts#update
          PUT    /posts/:id(.:format)      posts#update
          DELETE /posts/:id(.:format)      posts#destroy

ดูคอลัมน์ขวาสุด · PATCH กับ PUT ไปที่ posts#update เมนูเดียวกัน · นับเป็น "เมนู" (action) ได้ 7 · นับเป็น "บรรทัดในรายการ" ได้ 8 · เวลาคนพูดว่า "resources ให้ 7 route" เขานับเมนู

นี่คือ 7 เมนูนั้น วางข้างโค้ด Gin ที่คุณเคยเขียนในบท 38 (คอลัมน์ Gin ยกมาจากสไลด์หน้า 46):

ใบสั่งเมนูภาษาคนGin ที่เคยเขียน
GET /postsindexดูบทความทั้งหมดr.GET("/posts", h.List)
GET /posts/newnewเปิดฟอร์มเขียนใหม่— (API ไม่มีหน้าฟอร์ม)
POST /postscreateบันทึกบทความใหม่r.POST("/posts", h.Create)
GET /posts/:idshowดูบทความชิ้นเดียวr.GET("/posts/:id", h.Get)
GET /posts/:id/editeditเปิดฟอร์มแก้— (API ไม่มีหน้าฟอร์ม)
PATCH /posts/:id (และ PUT)updateบันทึกที่แก้r.PUT("/posts/:id", h.Update)
DELETE /posts/:iddestroyลบr.DELETE("/posts/:id", h.Del)

ฝั่ง Gin มีแค่ 5 เพราะตอนนั้นเราทำ API ที่ตอบเป็น JSON ให้โปรแกรมอื่นอ่าน ไม่มีหน้าฟอร์มให้คนกรอก · Rails ทำเว็บที่มีหน้าฟอร์มด้วย เลยมี new กับ edit เพิ่ม

✅ ได้อะไร

ทุกแผนกในร้านใช้ชื่อเมนูชุดเดียวกัน · คนใหม่เข้าทีมเห็น URL /comments/3/edit ก็เดาได้ทันทีว่าไปที่ method edit ของ CommentsController ไม่ต้องเปิดไฟล์

⚠️ เสียอะไร

เปิด config/routes.rb แล้วไม่เห็น URL จริงสักเส้น เห็นแค่ resources :posts · ต้องสั่ง bin/rails routes ดูทุกครั้ง · ฝั่ง Gin เปิด main.go ก็เห็นครบ

🗣 ศัพท์ที่ทวนไป
REST · RESTful
แนวตั้ง URL เป็นชื่อของ (คำนาม) แล้วใช้ verb บอกว่าทำอะไร · route ที่ทำตามแนวนี้เรียกว่า RESTful · ถาม senior ได้ว่า "URL /deletePost?id=3 นี่ไม่ RESTful ใช่ไหมครับ ควรเป็น DELETE /posts/3"
resource
ของหนึ่งชนิดที่มี URL ของตัวเอง (posts · comments) · resources :ชื่อ สร้างเซ็ตเมนูมาตรฐานของมันให้ · พิมพ์บอก AI ได้ว่า "add RESTful routes for comments using resources"

ที่เหลือของครึ่งแรกคือ 4 ท่าสำหรับวันที่เซ็ตมาตรฐานไม่พอดีกับร้านเรา · ทุกท่าผมรัน bin/rails routes จริงให้ดูผล

ท่าที่ 1 · ตัดเมนูที่ไม่ใช้ทิ้ง — only: กับ except:

ถ้าแอปเรายังไม่มีหน้าลบบทความ ก็ไม่ควรเปิดทาง DELETE /posts/:id ทิ้งไว้ · เส้นที่เปิดไว้แต่ไม่ได้ใช้ คือประตูที่ไม่มีคนเฝ้า วันหนึ่งมีคนเผลอเขียน def destroy เพิ่มเข้ามา ประตูนั้นก็ใช้ได้ทันทีโดยไม่มีใครตั้งใจ

# config/routes.rb
resources :posts, only: [:index, :show]
$ bin/rails routes -c posts
Prefix Verb URI Pattern          Controller#Action
 posts GET  /posts(.:format)     posts#index
  post GET  /posts/:id(.:format) posts#show

ถ้าอยากได้เกือบครบ ขาดแค่ตัวเดียว ใช้ except: (= "ทุกเมนู ยกเว้น…") จะสั้นกว่า:

resources :posts, except: [:destroy]
$ bin/rails routes -c posts
   Prefix Verb  URI Pattern               Controller#Action
    posts GET   /posts(.:format)          posts#index
          POST  /posts(.:format)          posts#create
 new_post GET   /posts/new(.:format)      posts#new
edit_post GET   /posts/:id/edit(.:format) posts#edit
     post GET   /posts/:id(.:format)      posts#show
          PATCH /posts/:id(.:format)      posts#update
          PUT   /posts/:id(.:format)      posts#update

แถว DELETE หายไป · ตอนนี้ใบสั่ง DELETE /posts/1 จะได้ Routing Error ทันที (ผมถาม router ดูแล้วได้ No route matches "/posts/1" · พนักงานต้อนรับไม่มีบรรทัดนี้ในรายการ — 404 แบบที่ 1 ของบท 101)

ท่าที่ 2 · ซ้อนกัน — คอมเมนต์ของบทความเลขไหน

คอมเมนต์ไม่ได้ลอยอยู่เฉย ๆ มันอยู่ใต้บทความเสมอ (ในตู้ ใบคอมเมนต์มีช่อง post_id — บท 115) · เวลาจะเขียนคอมเมนต์ใหม่ URL ก็ควรบอกด้วยว่า "ใต้บทความเลขไหน" · Rails ทำได้ด้วยการวาง resources ซ้อนใน do … end:

# config/routes.rb
resources :posts do
  resources :comments
end
$ bin/rails routes -c comments
           Prefix Verb   URI Pattern                                 Controller#Action
    post_comments GET    /posts/:post_id/comments(.:format)          comments#index
                  POST   /posts/:post_id/comments(.:format)          comments#create
 new_post_comment GET    /posts/:post_id/comments/new(.:format)      comments#new
edit_post_comment GET    /posts/:post_id/comments/:id/edit(.:format) comments#edit
     post_comment GET    /posts/:post_id/comments/:id(.:format)      comments#show
                  PATCH  /posts/:post_id/comments/:id(.:format)      comments#update
                  PUT    /posts/:post_id/comments/:id(.:format)      comments#update
                  DELETE /posts/:post_id/comments/:id(.:format)      comments#destroy

ของใหม่ในตารางนี้มีสามอย่าง:

🧾 เทียบกับร้านอาหาร — ใบสั่งที่ต้องระบุเลขจานแม่

ใบคอมเมนต์ในตู้มีช่อง "เป็นของบทความเลขที่ …" อยู่แล้ว (บท 115) · nested route คือการพิมพ์ช่องเดียวกันนี้ไว้บนใบสั่งของลูกค้าด้วย: "ขอเขียนคอมเมนต์ ใต้บทความจานที่ 5" = POST /posts/5/comments · พนักงานต้อนรับเห็นเลข 5 ก็จดใส่ params[:post_id] แล้วส่งเข้าแผนกคอมเมนต์

ที่ไม่เหมือนกัน: พนักงานต้อนรับไม่ได้เปิดตู้เช็คว่าบทความเลข 5 มีจริงไหม หรือคอมเมนต์เลข 9 อยู่ใต้บทความ 5 จริงไหม · เขาแค่จดตัวเลขตามใบสั่ง · คนที่ต้องเช็คคือเชฟ (controller) — เช่น Post.find(params[:post_id]) ถ้าไม่เจอได้ 404 (เห็นจริงในภารกิจท้ายบท)

เทียบกับ Go: ใน Gin คุณเขียนเองว่า r.POST("/posts/:post_id/comments", h.CreateComment) แล้วอ่าน c.Param("post_id") · Rails ได้ชุดนี้ครบ 8 บรรทัดจากการวาง resources :comments ไว้ใน block ของ posts

กฎ: ซ้อนแค่ชั้นเดียวพอ

ถ้าคอมเมนต์มีการตอบกลับ (replies) อีกชั้น แล้วซ้อนลงไปตรง ๆ จะได้แบบนี้ (ตัดมา 3 แถวจาก 8):

# ❌ ซ้อนสองชั้น
resources :posts do
  resources :comments do
    resources :replies
  end
end
$ bin/rails routes -c replies
                 Prefix Verb   URI Pattern                                                     Controller#Action
   post_comment_replies GET    /posts/:post_id/comments/:comment_id/replies(.:format)          replies#index
edit_post_comment_reply GET    /posts/:post_id/comments/:comment_id/replies/:id/edit(.:format) replies#edit
     post_comment_reply GET    /posts/:post_id/comments/:comment_id/replies/:id(.:format)      replies#show

URL ยาวเป็นพรืด และทุกครั้งที่สร้างลิงก์ต้องส่งเลขครบสามตัว (บทความ · คอมเมนต์ · การตอบ) · คู่มือ Rails เองแนะนำว่า ซ้อนไม่เกิน 1 ชั้น · สไลด์หน้า 47 เรียก /posts/1/comments/2/replies/3 ว่า "สัญญาณว่าออกแบบผิด"

ทางออกคือ: ใช้ URL ซ้อนเฉพาะตอนที่ต้องรู้แม่ (ดูรายการคอมเมนต์ของบทความ · เขียนคอมเมนต์ใหม่ใต้บทความ) · ส่วนคอมเมนต์ที่มีเลขของตัวเองแล้ว ใช้ URL สั้น /comments/9 ได้เลย เพราะคอมเมนต์ 9 รู้อยู่แล้วว่าตัวเองอยู่ใต้บทความไหน · Rails มีตัวเลือก shallow: true ที่ทำแบบนี้ให้อัตโนมัติ (แค่รู้จักไว้ · ตัวอย่างเต็มอยู่ในคู่มือ Rails ลิงก์แรกของหัวข้ออ้างอิง):

resources :posts do
  resources :comments, shallow: true   # index · new · create ซ้อน · ที่เหลือใช้ /comments/:id
end
🗣 ศัพท์ที่เพิ่งเรียนไป
nested resources
การวาง resources ซ้อนใน block ของอีกตัว ได้ URL แบบ /posts/:post_id/comments · พิมพ์บอก AI ได้ว่า "nest comments under posts in routes.rb, only index and create"
parent id (:post_id)
เลขของแม่ที่ติดมาใน URL · อ่านด้วย params[:post_id] · ส่วน params[:id] คือเลขของลูก · ถาม senior ได้ว่า "action นี้ควรหา comment ผ่าน post.comments.find หรือ Comment.find ตรง ๆ ครับ" (คำตอบส่วนใหญ่คืออย่างแรก เพราะเช็คว่าเป็นของบทความนี้จริง)
shallow nesting
ซ้อนเฉพาะเส้นที่ยังไม่มี id ของลูก · เส้นที่มี id แล้วใช้ URL สั้น · คุยกับทีมได้ว่า "เส้นนี้ลึกเกิน ใช้ shallow ดีไหม"

ท่าที่ 3 · เมนูพิเศษ — member กับ collection

ร้านเราอยากได้สองหน้าที่ไม่อยู่ในเซ็ตมาตรฐาน: ปุ่ม "เผยแพร่" ของบทความหนึ่งชิ้น (publish) และหน้ารวมบทความที่ยังเป็นร่าง (drafts) · สองอย่างนี้ต่างกันตรงที่ อันแรกต้องรู้ว่าบทความชิ้นไหน (มีเลข id) · อันหลังเป็นเรื่องของทั้งแผนก (ไม่มีเลข id)

# config/routes.rb
resources :posts do
  member do
    post :publish        # เรื่องของบทความชิ้นเดียว → มี :id
  end
  collection do
    get :drafts         # เรื่องของทั้งแผนก → ไม่มี :id
  end
end
$ bin/rails routes -c posts
      Prefix Verb   URI Pattern                  Controller#Action
publish_post POST   /posts/:id/publish(.:format) posts#publish
drafts_posts GET    /posts/drafts(.:format)      posts#drafts
       posts GET    /posts(.:format)             posts#index
             POST   /posts(.:format)             posts#create
    new_post GET    /posts/new(.:format)         posts#new
   edit_post GET    /posts/:id/edit(.:format)    posts#edit
        post GET    /posts/:id(.:format)         posts#show
             PATCH  /posts/:id(.:format)         posts#update
             PUT    /posts/:id(.:format)         posts#update
             DELETE /posts/:id(.:format)         posts#destroy
⭐ เทียบกับร้านอาหาร — เมนูพิเศษนอกเซ็ต

member คือคำสั่งพิเศษที่ต้องบอกเลขจาน: "จานที่ 5 ยกขึ้นตู้โชว์หน้าร้านได้แล้ว" (publish) · collection คือคำสั่งกับทั้งแผนก ไม่ต้องบอกเลข: "ขอดูรายการจานที่ยังไม่ได้ขึ้นตู้โชว์" (drafts) · ทั้งสองอย่างคือการเขียนบรรทัดเพิ่มลงในรายการของพนักงานต้อนรับ

ที่ไม่เหมือนกัน: ร้านจริงประกาศเมนูพิเศษแล้วเชฟก็รู้ว่าต้องทำอะไร · ใน Rails การเพิ่มบรรทัดใน routes.rb คือแค่จดลงในรายการ · เชฟยังไม่มีสูตร ต้องเขียน def publish กับ def drafts ใน PostsController เอง ไม่งั้นยิงไปได้ Unknown action (404 แบบที่ 2 ของบท 101)

เทียบกับ Go: ใน Gin ไม่มีคำว่า member/collection · คุณแค่เขียนเพิ่มสองบรรทัด r.POST("/posts/:id/publish", h.Publish) กับ r.GET("/posts/drafts", h.Drafts) · Rails แยกสองคำนี้ไว้เพื่อให้มันเติม :id ให้ถูกและตั้งชื่อ route ให้เอง

ท่าที่ 4 · ของที่มีชิ้นเดียว — resource (ไม่มี s)

บางอย่างผู้ใช้แต่ละคนมีแค่ชิ้นเดียว เช่น "หน้าโปรไฟล์ของฉัน" · ไม่มีหน้ารวมโปรไฟล์ทุกคน และไม่ต้องใส่เลข id ใน URL เพราะ "ของฉัน" ก็คือของคนที่ login อยู่ (ระบบ login มาใน Day 4) · ใน Gin คุณก็แค่เขียน r.GET("/profile", h.ShowProfile) ไม่มี :id · Rails ใช้ resource ที่ไม่มี s:

# config/routes.rb
resource :profile

$ bin/rails routes -c profile

ใบ้: ชื่อ symbol เป็นเอกพจน์ :profile · คิดว่าคอลัมน์ Controller#Action จะเขียนว่า profile#show หรือ profiles#show? และมี index ไหม?

$ bin/rails routes -c profile
      Prefix Verb   URI Pattern             Controller#Action
 new_profile GET    /profile/new(.:format)  profiles#new
edit_profile GET    /profile/edit(.:format) profiles#edit
     profile GET    /profile(.:format)      profiles#show
             PATCH  /profile(.:format)      profiles#update
             PUT    /profile(.:format)      profiles#update
             DELETE /profile(.:format)      profiles#destroy
             POST   /profile(.:format)      profiles#create

URL เป็นเอกพจน์ /profile และไม่มี :id สักเส้น · ไม่มี index (ของชิ้นเดียวไม่มีหน้ารวม) · แต่ controller ยังเป็นพหูพจน์ profiles#… = ProfilesController · ถ้าคุณสร้างไฟล์ชื่อ profile_controller.rb ตามความรู้สึก จะเจอ error uninitialized constant ProfilesController

🗣 ศัพท์ที่เพิ่งเรียนไป
member route · collection route
member = เมนูเพิ่มของชิ้นเดียว (มี :id) · collection = เมนูเพิ่มของทั้งกลุ่ม (ไม่มี :id) · พิมพ์บอก AI ได้ว่า "add a member route POST /posts/:id/publish and a collection route GET /posts/drafts"
singular resource (resource)
ของที่ผู้ใช้มีชิ้นเดียว · ได้ 6 เมนู ไม่มี index ไม่มี id · controller ยังเป็นพหูพจน์ · ถาม senior ได้ว่า "หน้าตั้งค่าบัญชีควรเป็น resource เอกพจน์ไหมครับ"
only: · except:
ตัดเซ็ตเมนูให้เหลือเฉพาะที่ใช้จริง · คุยกับทีมได้ว่า "route ที่ไม่มี action รองรับ ใส่ only ปิดไว้เลยดีไหม"

route helper — ทวนแล้วต่อยอดให้เส้นที่ซ้อน

ในบท 101 เราได้ post_path(42) ที่ประกอบ URL จากชื่อ route · พอ route ซ้อนกัน ชื่อ helper ก็ต่อกันตามไปด้วย · ผมลองใน console (app. ข้างหน้า = ตัวช่วยใน console ที่ทำตัวเหมือน browser — ตัวเดียวกับ app.get ในบท 104 — จึงเรียก helper ของ route ได้ · ใน controller กับ view ไม่ต้องใส่ app.):

wongnok-blog(dev):001> post = Post.find(5)
  Post Load (1.4ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 5 LIMIT 1 /*application='WongnokBlog'*/
wongnok-blog(dev):002> app.post_path(post)
=> "/posts/5"
wongnok-blog(dev):003> app.edit_post_path(post)
=> "/posts/5/edit"
wongnok-blog(dev):004> app.post_comments_path(post)
=> "/posts/5/comments"
wongnok-blog(dev):005> app.post_comment_path(post, post.comments.first)
  Comment Load (1.5ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 5 ORDER BY "comments"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=> "/posts/5/comments/5"
wongnok-blog(dev):006> app.polymorphic_path(post)
=> "/posts/5"

(routes ตอนรันคือ resources :posts do resources :comments end · ผมตัดบรรทัดที่ console พิมพ์แฟ้มบทความทั้งแผ่นออกหลังบรรทัด 001)

ประโยชน์เหมือนเดิมกับบท 101: ถ้าวันหนึ่งเปลี่ยน URL จาก /posts เป็น /articles แก้แค่ routes.rb · ทุกที่ที่ใช้ post_path ได้ URL ใหม่ทันที ไม่ต้องไล่แก้ข้อความ "/posts" ที่พิมพ์ไว้ 20 ที่

เตรียมยิงใบสั่งจริง — แอปทดลองสำหรับครึ่งหลัง

ครึ่งหลังเราจะส่งข้อมูลเข้า action create แล้วดูว่าอะไรลงตู้บ้าง · ต้องมีสามไฟล์ · ถ้าคุณมีไฟล์ชื่อเดียวกันจากบทก่อน ๆ อยู่แล้ว แทนที่ทั้งไฟล์ด้วยอันนี้ (ใช้ git ย้อนกลับได้ทีหลัง)

# config/routes.rb
Rails.application.routes.draw do
  resources :posts, only: [:index, :create]
end
<%# app/views/posts/index.html.erb %>
<h1>บทความทั้งหมด</h1>
<ul>
  <% @posts.each do |post| %>
    <li><%= post.title %> · <%= post.views %> วิว</li>
  <% end %>
</ul>
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.order(:id)
  end

  def create
    @post = Post.new(params[:post])
    @post.user = User.first   # ชั่วคราว: ยังไม่มีระบบ login
    @post.save!
    render plain: "บันทึกแล้ว: #{@post.attributes.slice("title", "views", "published", "user_id")}\n"
  end
end

เปิด server (bin/rails server) ทิ้งไว้หนึ่งหน้าต่าง แล้วเปิดอีกหน้าต่างมาขอบัตรคิว · บรรทัดนี้ยาว ก็อปไปวางได้เลย หน้าตาเดียวกับบท 103 แค่เก็บรหัสไว้ใช้หลายรอบ:

$ TOKEN=$(curl -s -c jar.txt http://localhost:3000/posts | grep -o 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"$//')

⚠️ เราถือบัตรคิวไปเหมือนลูกค้าจริง ไม่ได้ปิดการตรวจ CSRF · บทความในเน็ตหลายที่แนะนำให้ปิดเพื่อยิง curl ง่าย ๆ — อย่าทำ · ปิดแล้วลืมเปิด = เปิดทางให้เว็บอื่นปลอมใบสั่งแทนผู้ใช้ (บท 103) · ถ้าเห็น 422 + Can't verify CSRF token authenticity. แปลว่าลืม -b jar.txt หรือ -H · หรือเปิดหน้าต่าง terminal ใหม่ (ตัวแปร TOKEN อยู่แค่ในหน้าต่างที่สั่ง หน้าต่างใหม่ $TOKEN ว่าง) → สั่งบรรทัดขอบัตรคิวใหม่ในหน้าต่างนั้น

mass assignment — ลูกค้าแอบเขียนเพิ่มในใบสั่ง

ฟอร์มเขียนบทความของเรามีแค่ 2 ช่อง: ชื่อเรื่องกับเนื้อหา · แต่ ฟอร์มเป็นแค่หน้าตาบนจอ · ของที่ถึงร้านจริงคือ request ซึ่งใครจะเติมช่องอะไรลงไปก็ได้ (ด้วย curl หรือเครื่องมือในเบราว์เซอร์) เช่น post[views]=999999 · post[published]=true

mass assignment คือการเอาของทั้งกลุ่มยัดใส่แฟ้มรวดเดียว อย่าง Post.new(params[:post]) · สะดวกมาก · แต่ถ้าไม่จำกัดว่ารับช่องไหน ช่องที่คนร้ายเติมมาก็เข้าตู้ไปด้วย

📝 เทียบกับร้านอาหาร — ใบสั่งที่ลูกค้าเขียนเพิ่มเอง

ใบสั่งของร้านพิมพ์ช่องไว้สองช่อง: "ชื่อเมนู" กับ "รายละเอียด" · ลูกค้าคนหนึ่งเขียนเพิ่มที่ขอบกระดาษว่า "ฉันเป็นผู้จัดการ · ฟรี · ลงบัญชีโต๊ะอื่น" · ถ้าครัวทำตามทุกบรรทัดที่เขียนบนกระดาษ ร้านก็เจ๊ง — นี่คือ mass assignment

ที่ไม่เหมือนกัน: คนในครัวจริงจะสะดุดตากับลายมือแปลก ๆ ที่ขอบกระดาษ · โปรแกรมไม่มีทางรู้ว่าช่องไหนมาจากฟอร์มของเรา ช่องไหนคนร้ายเติม — ทุกช่องหน้าตาเหมือนกันหมดใน params · เราจึงต้องเขียนบอกเองว่ารับช่องไหน

ขั้นที่ 1 · Rails 8 ไม่ยอมให้ทำแบบนี้ตั้งแต่แรก

ยิงใบสั่งธรรมดา ๆ (ไม่มีช่องแปลก) เข้าโค้ดข้างบน · -o /dev/null -w '%{http_code}\n' = ไม่ต้องโชว์เนื้อหา พิมพ์แค่เลขสถานะ (บท 103):

$ curl -s -o /dev/null -w '%{http_code}\n' -b jar.txt -H "X-CSRF-Token: $TOKEN" \
    -X POST http://localhost:3000/posts -d "post[title]=ลาบหมู" -d "post[body]=แซ่บ"
500

log ของ server (ตัดรายชื่อไฟล์ของ Rails ใต้บรรทัดสุดท้ายออก):

Started POST "/posts" for ::1 at 2026-11-04 19:09:56 +0700
Processing by PostsController#create as */*
  Parameters: {"post" => {"title" => "ลาบหมู", "body" => "แซ่บ"}}
Completed 500 Internal Server Error in 2ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
ActiveModel::ForbiddenAttributesError (ActiveModel::ForbiddenAttributesError):
app/controllers/posts_controller.rb:8:in 'PostsController#create'

ข่าวดีคือ Rails ปิดประตูนี้ให้แล้ว · ข่าวร้ายคือมีท่าเปิดประตูกลับ ที่โผล่ในคำตอบของ AI และในเน็ตบ่อย ๆ เวลามีคนเจอ error ข้างบนแล้วอยากให้มัน "หายไปเร็ว ๆ"

ขั้นที่ 2 · เปิดประตูด้วย permit! แล้วลองเจาะ

⚠️ permit! (มี !) แปลว่า "รับทุกช่อง ไม่ต้องตรวจ" · ใส่ในบทนี้เพื่อให้เห็นช่องโหว่กับตาเท่านั้น · ถ้าเห็นในโค้ดจริงหรือในคำตอบของ AI ให้ถือว่าเป็นบั๊ก · ทำหัวข้อนี้เสร็จแล้วเปลี่ยนกลับตามหัวข้อถัดไปทันที

แก้บรรทัดที่ 8 เป็น:

    @post = Post.new(params[:post].permit!)

ใบสั่งธรรมดาผ่านแล้ว · แฟ้มใหม่ได้ views 0 · published false (ค่าตั้งต้นที่ migration ของตาราง posts ตั้งไว้) · ผู้เขียนเป็นสมชาย (user_id 1):

$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" \
    -X POST http://localhost:3000/posts -d "post[title]=ลาบหมู" -d "post[body]=แซ่บ"
บันทึกแล้ว: {"title" => "ลาบหมู", "views" => 0, "published" => false, "user_id" => 1}

ทีนี้เป็นคนร้าย · ส่งสองช่องตามฟอร์ม แล้วแอบเติมอีกสามช่อง: ยอดวิว 999999 · เผยแพร่ทันที · และอ้างว่าผู้เขียนคือมานะ (user_id 3)

$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" -X POST http://localhost:3000/posts \
    -d "post[title]=ร้านผม" -d "post[body]=อร่อยที่สุดในโลก" \
    -d "post[views]=999999" -d "post[published]=true" -d "post[user_id]=3"

สามช่องที่แอบเติม ช่องไหนลงตู้ได้บ้าง? (ดูโค้ด create อีกที — บรรทัดที่ 9 ทำอะไรหลังบรรทัดที่ 8)

บันทึกแล้ว: {"title" => "ร้านผม", "views" => 999999, "published" => true, "user_id" => 1}

log ยืนยันว่าเข้าตู้จริง (ตัดเฉพาะสองบรรทัดที่สำคัญ):

  Parameters: {"post" => {"title" => "ร้านผม", "body" => "อร่อยที่สุดในโลก", "views" => "999999", "published" => "true", "user_id" => "3"}}
  Post Create (2.2ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES ('ร้านผม', 'อร่อยที่สุดในโลก', '2026-11-04 12:09:58.082068', '2026-11-04 12:09:58.082068', TRUE, 999999, 1) RETURNING "id" /*action='create',application='WongnokBlog',controller='posts'*/

INSERT INTO "posts" (…) VALUES (…) แปลว่า "เพิ่มแฟ้มใหม่ลงลิ้นชัก posts โดยช่องในวงเล็บแรกได้ค่าในวงเล็บที่สองตามลำดับ" · ช่อง published ได้ TRUE · views ได้ 999999 · RETURNING "id" = "แล้วบอกเลขที่แฟ้มใหม่กลับมาด้วย"

views กับ published เข้าตู้ · บทความที่ควรรอตรวจกลายเป็นเผยแพร่แล้วและยอดวิวสูงสุดในเว็บ · user_id ไม่เข้า — ได้ 1 ไม่ใช่ 3 · ไม่ใช่เพราะ Rails กันให้ แต่เพราะบรรทัดที่ 9 @post.user = User.first เขียนทับหลังจากบรรทัดที่ 8 ใส่ 3 ไปแล้ว · นี่คือ "รอดเพราะโชค" ไม่ใช่รอดเพราะตั้งใจกัน

อยากเห็นตอนที่โชคไม่ช่วย · ผมเขียน action update ชั่วคราวที่ใช้ท่าเดียวกัน (@post.update!(params[:post].permit!)) · ใน update ไม่มีบรรทัดไหนเขียนทับผู้เขียน · ส่งแก้คำผิดในชื่อบทความ 2 (ของสมชาย) พร้อมแอบเติม user_id:

$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" -X PATCH http://localhost:3000/posts/2 \
    -d "post[title]=ส้มตำปูปลาร้า (แก้คำผิด)" -d "post[user_id]=3"
แก้แล้ว: {"title" => "ส้มตำปูปลาร้า (แก้คำผิด)", "user_id" => 3}
  Post Update (1.3ms)  UPDATE "posts" SET "title" = 'ส้มตำปูปลาร้า (แก้คำผิด)', "updated_at" = '2026-11-04 12:10:01.703604', "user_id" = 3 WHERE "posts"."id" = 2 /*action='update',application='WongnokBlog',controller='posts'*/

UPDATE "posts" SET … WHERE "posts"."id" = 2 = "แก้แฟ้มเลข 2 ในลิ้นชัก posts ให้ช่องเหล่านี้เป็นค่าใหม่" · "user_id" = 3 อยู่ในนั้น → บทความของสมชายกลายเป็นของมานะ แค่เติมช่องเดียวในใบสั่ง (ตอนทดลองผมเปลี่ยน only: ใน routes เป็น [:index, :update] ชั่วคราว · ไม่ต้องทำตาม)

สไลด์หน้า 48 อ้างว่าช่องโหว่แบบนี้เคยถูกใช้กับ GitHub ในปี 2012 · ส่วนที่ผมยืนยันได้กับยืนยันไม่ได้ อยู่ในตารางท้ายบท · ไม่ต้องจำเรื่องนี้ จำแค่ว่าช่องโหว่นี้ทำได้จริงด้วย curl คำสั่งเดียว อย่างที่คุณเพิ่งเห็น

เทียบกับ Go: ช่องโหว่เดียวกันเกิดได้ถ้าเอา JSON ของผู้ใช้ใส่ตรงเข้า struct ที่ลง DB · struct Post แบบ GORM ที่มีช่อง Views · UserID พร้อม tag json:

var post Post                           // ❌ struct ที่ลง DB
json.Unmarshal(body, &post)              // body มี views · published · user_id แอบมา
// ผลรันจริง: {ID:0 Title:ร้านผม Body:x Views:999999 Published:true UserID:3}

(ตัดมาสามบรรทัด · ผมรันในไฟล์ main.go เต็มที่มี package main กับ import "encoding/json" · ShouldBindJSON ของ Gin ในบท 39 โดยปกติก็ใช้ encoding/json ตัวนี้ข้างใน)

🗣 ศัพท์ที่เพิ่งเรียนไป
mass assignment
การใส่ค่าหลายช่องให้ object รวดเดียวจาก Hash เช่น Post.new(hash) · post.update(hash) · ตัวมันเองไม่ผิด ผิดตอนที่ hash มาจากผู้ใช้โดยไม่ได้กรอง · ถาม senior ได้ว่า "action นี้มีช่องโหว่ mass assignment ไหมครับ ผมควรเช็คตรงไหน"
ActiveModel::ForbiddenAttributesError
error ที่ Rails โยนเมื่อเอา params ที่ยังไม่ได้กรองไปใส่แฟ้ม · ทางแก้ที่ถูกคือ strong parameters (หัวข้อถัดไป) ไม่ใช่ permit! · พิมพ์บอก AI ได้ว่า "I got ForbiddenAttributesError — write strong params with params.expect, do not use permit!"
permit!
"อนุญาตทุกช่อง" · ปิด error ได้แต่เปิดช่องโหว่กลับมาทั้งหมด · ในการรีวิวโค้ด เจอคำนี้กับ params จากผู้ใช้ ให้ทักทุกครั้ง

strong parameters — รับเฉพาะช่องที่อยู่บนแบบฟอร์ม

ทางแก้คือเขียนรายชื่อช่องที่ยอมรับ ไว้ใน method แยกใต้ private แล้วให้ create เรียกใช้ · ช่องที่ไม่อยู่ในรายชื่อจะถูกทิ้งก่อนถึงแฟ้ม · เปลี่ยนไฟล์ controller เป็น:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.order(:id)
  end

  def create
    @post = Post.new(post_params)
    @post.user = User.first   # ชั่วคราว: ยังไม่มีระบบ login
    @post.save!
    render plain: "บันทึกแล้ว: #{@post.attributes.slice("title", "views", "published", "user_id")}\n"
  end

  private

  def post_params
    params.require(:post).permit(:title, :body)
  end
end

ยิงคำสั่งเจาะชุดเดิม:

$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" -X POST http://localhost:3000/posts \
    -d "post[title]=ร้านผม" -d "post[body]=อร่อยที่สุดในโลก" \
    -d "post[views]=999999" -d "post[published]=true" -d "post[user_id]=3"
บันทึกแล้ว: {"title" => "ร้านผม", "views" => 0, "published" => false, "user_id" => 1}

บันทึกได้ (คนร้ายไม่รู้ตัวด้วยซ้ำว่าโดนตัด) แต่ views กลับเป็น 0 · published เป็น false · ใน log มีบรรทัดใหม่ (ตัดท้ายบรรทัดที่ยาวมากออก):

  Parameters: {"post" => {"title" => "ร้านผม", "body" => "อร่อยที่สุดในโลก", "views" => "999999", "published" => "true", "user_id" => "3"}}
Unpermitted parameters: :views, :published, :user_id. Context: { controller: PostsController, action: create, … }
  Post Create (2.5ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES ('ร้านผม', 'อร่อยที่สุดในโลก', '2026-11-04 12:09:59.183341', '2026-11-04 12:09:59.183341', FALSE, 0, 1) RETURNING "id" /*action='create',application='WongnokBlog',controller='posts'*/

หลักการข้างหลังคือ allowlist ชนะ blocklist — เขียนว่า "ช่องไหนผ่านได้" ไม่ใช่ "ช่องไหนห้ามผ่าน" · ถ้าเขียนแบบห้าม วันที่ migration เพิ่มช่อง is_featured (บทความแนะนำ) เข้ามา แล้วลืมเพิ่มในรายการห้าม ช่องนั้นก็เปิดให้คนร้ายทันที · แบบ allowlist ช่องใหม่ถูกปิดไว้ก่อนเสมอจนกว่าจะตั้งใจเปิด

🛎 เทียบกับร้านอาหาร — พนักงานต้อนรับลอกเฉพาะช่องบนแบบฟอร์ม

strong parameters คือพนักงานต้อนรับที่ถือแบบฟอร์มทางการของร้านไว้ · ลูกค้ายื่นกระดาษอะไรมาก็ตาม พนักงานลอกเฉพาะช่อง "ชื่อเมนู" กับ "รายละเอียด" ลงใบสั่งของร้าน แล้วส่งใบนั้นเข้าครัว · ข้อความ "ฉันเป็นผู้จัดการ · ฟรี" ที่ขอบกระดาษไม่เคยไปถึงครัว

ที่ไม่เหมือนกัน: พนักงานจริงอาจหันไปบอกลูกค้าว่า "ช่องนี้เราไม่รับนะครับ" · permit ทิ้งเงียบ ๆ ลูกค้าได้คำตอบ 200 เหมือนสำเร็จทุกอย่าง · คนที่รู้มีแค่เรา ผ่านบรรทัด Unpermitted parameters ใน log · และพนักงานคนนี้ไม่ตรวจว่าค่าในช่องถูกไหม (ชื่อเรื่องว่างก็ลอกให้) — เรื่องนั้นเป็นงานของ validation ในบท 110

เทียบกับ Go: ท่าที่ถูกคือแยก struct สำหรับรับ input (เรียกกันว่า DTO) ออกจาก struct ที่ลง DB · struct รับ input มีแค่ช่องที่อยู่บนฟอร์ม:

❌ รับตรงเข้า struct ที่ลง DB

var post Post
json.Unmarshal(body, &post)
// Views:999999 Published:true UserID:3

✅ รับเข้า DTO ก่อน แล้วค่อยย้าย

type CreatePostInput struct {
    Title string `json:"title"`
    Body  string `json:"body"`
}
var in CreatePostInput
json.Unmarshal(body, &in)
post := Post{Title: in.Title, Body: in.Body, UserID: 1}
// Views:0 Published:false UserID:1

(ผลในคอมเมนต์มาจากการรันจริง ด้วย JSON ที่แอบเติม views · published · user_id แบบเดียวกับคนร้าย · encoding/json ทิ้งช่องที่ struct ไม่มีเงียบ ๆ เหมือน permit) · struct CreatePostInput ของ Go = รายชื่อใน permit(:title, :body) ของ Rails · ต่างกันแค่ Go ประกาศเป็นชนิดข้อมูล Rails ประกาศเป็นรายชื่อ symbol

🗣 ศัพท์ที่เพิ่งเรียนไป
strong parameters
ระบบกรอง params ของ Rails · ต้องประกาศช่องที่รับก่อนเอาไปใส่ model · method post_params ใต้ private คือที่อยู่ประจำ · พิมพ์บอก AI ได้ว่า "add strong parameters for Post allowing only title and body"
allowlist · blocklist
allowlist = รายชื่อของที่ผ่านได้ (ที่เหลือห้ามหมด) · blocklist = รายชื่อของที่ห้าม (ที่เหลือผ่านหมด) · งานความปลอดภัยใช้ allowlist เสมอ · คุยกับทีมได้ว่า "ตรงนี้เราใช้ allowlist หรือ blocklist ครับ"
Unpermitted parameters (log)
บรรทัดใน log ตอนพัฒนาที่บอกว่าช่องไหนโดน permit ทิ้ง · ถ้าเห็นช่องที่ควรผ่าน แปลว่าลืมใส่ในรายชื่อ · ถาม senior ได้ว่า "log ขึ้น Unpermitted parameter: :category_id แปลว่าผมต้องเพิ่มใน permit ใช่ไหมครับ"

params.expect — ท่าของ Rails 8 ที่ตรวจรูปร่างด้วย

require/permit กันช่องเกินได้แล้ว แต่ยังมีรูรั่วอีกแบบ · require(:post) เช็คแค่ว่า "มีของชื่อ post ไหม" · ไม่ได้เช็คว่าของนั้นเป็นกลุ่มช่อง (Hash) จริงหรือเปล่า · ถ้าคนป่วนส่ง post มาเป็นคำเดียวหรือเป็นรายการ จะเกิดอะไรขึ้น:

$ curl -s -o /dev/null -w '%{http_code}\n' -b jar.txt -H "X-CSRF-Token: $TOKEN" \
    -X POST http://localhost:3000/posts -d "post=hello"
500
  Parameters: {"post" => "hello"}
Completed 500 Internal Server Error in 1ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
NoMethodError (undefined method 'permit' for an instance of String):
app/controllers/posts_controller.rb:17:in 'PostsController#post_params'
app/controllers/posts_controller.rb:8:in 'PostsController#create'

ข้อมูลไม่ได้เสีย แต่ได้ 500 · ทั้งที่เป็นความผิดของคนส่ง ไม่ใช่บั๊กของเรา · Rails 8 มี method ใหม่ params.expect ที่เช็คทั้งรายชื่อช่องและรูปร่างในคำสั่งเดียว · แก้ post_params เป็น:

  def post_params
    params.expect(post: [:title, :body])
  end

ผมยิงสามแบบเดียวกันเข้าทั้งสองท่า (ทุกครั้งเปลี่ยนแค่ -d) ได้ status แบบนี้:

ส่งมาแบบไหน (-d …)ภาษาคนrequire/permitparams.expect
"post=hello"post เป็นข้อความคำเดียว500 NoMethodError400 ParameterMissing
"post[]=a"post เป็นรายการ (Array)500 NoMethodError400 ParameterMissing
"title=x"ไม่มี post เลย400 ParameterMissing400 ParameterMissing
ห้าช่องของคนร้ายชุดเดิมช่องเกิน 3 ช่อง200 · ช่องเกินถูกทิ้ง200 · ช่องเกินถูกทิ้ง

บรรทัดใน log ของ expect ตอนส่ง post=hello:

  Parameters: {"post" => "hello"}
Completed 400 Bad Request in 1ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
ActionController::ParameterMissing (param is missing or the value is empty or invalid: post):

param is missing or the value is empty or invalid: post = "ช่อง post ไม่มี · ว่าง · หรือรูปร่างไม่ถูก" · Rails ตอบ 400 ให้เอง ไปไม่ถึงบรรทัด save!

400 กับ 500 ต่างกันยังไง ทำไมต้องสน: 400 Bad Request = "ใบสั่งของคุณผิดแบบ" (ความผิดฝั่งคนส่ง) · 500 Internal Server Error = "ครัวเราพังเอง" (ความผิดฝั่งเรา) · หลายทีมตั้งระบบเตือนให้ปลุกคนเวรเมื่อเห็น 500 เยอะ ๆ · ถ้าคนป่วนยิง post=hello มาพันครั้งแล้วได้ 500 พันครั้ง ทีมต้องตื่นมาไล่ log หาบั๊กที่ไม่มีอยู่จริง

📋 เทียบกับร้านอาหาร — ดูก่อนว่าเป็นแบบฟอร์มของร้านจริงไหม

require เหมือนพนักงานต้อนรับที่ถามแค่ว่า "มีกระดาษที่เขียนว่า ใบสั่ง ไหม" · ลูกค้ายื่นกระดาษที่เขียนคำว่า "hello" คำเดียวมา พนักงานก็รับส่งเข้าครัว · เชฟหาช่องชื่อเมนูไม่เจอ ทำจานตกแตก (500)
expect คือพนักงานที่ดูก่อนว่าเป็นแบบฟอร์มที่มีช่องจริง ๆ · ถ้าไม่ใช่ คืนลูกค้าที่หน้าร้านเลยว่า "ใบสั่งผิดแบบครับ" (400) ครัวไม่ต้องรู้เรื่อง

ที่ไม่เหมือนกัน: การตรวจนี้จริง ๆ เกิดในครัว (ตอน create เรียก post_params) ไม่ใช่หน้าร้าน แค่เกิดก่อนเชฟลงมือ · พนักงานจริงที่คืนใบสั่งจะบอกว่าผิดตรงไหน แต่ expect บอกแค่ชื่อกลุ่ม (invalid: post) · และเรื่องช่องเกิน expect ทิ้งเงียบกว่า require/permit อีก — ผมยิงห้าช่องของคนร้ายเข้าท่า expect แล้วไม่มีบรรทัด Unpermitted parameters ใน log เลย · ช่องเกินยังถูกทิ้งเหมือนเดิม แค่ไม่มีใครบอก

เรื่อง AI: สไลด์หน้า 49 เตือนว่า AI มักเขียน require/permit ให้ เพราะเรียนจากโค้ดยุค Rails 6–7 · ไม่ผิด กันช่องเกินได้เหมือนกัน แต่ได้ 500 ตอนเจอรูปร่างแปลก · ขอให้ AI ใช้ params.expect ตรง ๆ ได้เลย · ส่วน bin/rails generate scaffold ของ Rails 8.1.4 เขียน params.expect ให้แล้ว (ผมเปิดไฟล์แม่แบบของมันดู — บท 118 จะอ่านโค้ดที่มันสร้าง)

🗣 ศัพท์ที่เพิ่งเรียนไป
params.expect
ท่าของ Rails 8 ที่รวม require + permit และเช็คว่าเป็นกลุ่มช่องจริง · รูปร่างผิดได้ 400 · พิมพ์บอก AI ได้ว่า "use params.expect (Rails 8) instead of require/permit"
ActionController::ParameterMissing · 400 Bad Request
error ที่ Rails แปลงเป็น 400 ให้เองเมื่อใบสั่งไม่มีกลุ่มที่ต้องการหรือรูปร่างไม่ถูก · ไม่ต้องเขียน rescue เอง · ถาม senior ได้ว่า "API นี้ตอบ 500 ตอนส่ง body ผิดรูป ควรเปลี่ยนเป็น params.expect ให้ได้ 400 ไหมครับ"
500 Internal Server Error
"ฝั่ง server พังเอง" · ควรเห็นเฉพาะตอนมีบั๊กจริง · ถ้าคนส่งผิดแล้วได้ 500 คือเราตอบผิดประเภท · คุยกับทีมได้ว่า "error rate 500 ขึ้น เพราะ request ผิดรูปหรือบั๊กจริง"

ภารกิจ — ช่องเขียนคอมเมนต์ใต้บทความ ที่กันคนสวมรอยได้

🎯 nested route + params.expect (~10 นาที)

ต่อจากแอปทดลองข้างบน (ต้องมี posts#index + view ไว้ขอบัตรคิว) · คอมเมนต์ต้องเป็นของ "คนที่ login อยู่" เสมอ ไม่ใช่คนที่ใบสั่งอ้าง

  1. แก้ config/routes.rb ให้มี POST /posts/:post_id/comments เส้นเดียว ซ้อนใต้ posts (ใช้ only: ทั้งสองชั้น ให้ posts เหลือ index กับ create เหมือนเดิม · ใบ้: only: […] เขียนไว้ก่อนคำว่า do ได้ เช่น resources :posts, only: [...] do)
  2. สั่ง bin/rails routes -c comments · ควรเห็นแถวเดียว ชื่อ post_comments ไปที่ comments#create
  3. สร้างไฟล์ app/controllers/comments_controller.rb · action create ให้: หาบทความจาก params[:post_id] · สร้างคอมเมนต์ใต้บทความนั้นด้วย post.comments.new(comment_params) (new = build ในบท 115) · ใส่ผู้เขียนเป็น User.last (สมมุติว่าคนที่ login คือมานะ) · save! · แล้ว render plain: ข้อความบอกว่าใครเขียนอะไรใต้บทความไหน
  4. เขียน comment_params ใต้ private ด้วย params.expect ให้รับแค่ body
  5. ขอบัตรคิวใหม่ (บรรทัด TOKEN=…) แล้วยิงแบบคนร้ายที่อ้างว่าเป็นสมชาย:
    $ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" -X POST http://localhost:3000/posts/5/comments \
        -d "comment[body]=ขอสูตรด้วย" -d "comment[user_id]=1"

ควรเห็น: ข้อความที่บอกว่าผู้เขียนคือ มานะ ไม่ใช่สมชาย · บทความเลข 5 ใน seeds คือ "ผัดกะเพราหมูสับ" (ถ้าของคุณเลขไม่ตรงเพราะเคยเพิ่ม/ลบบทความ ใช้เลขบทความที่มีอยู่จริงในตู้ของคุณแทนได้)

ถ้าเจอ error แปลว่า…

① routes:

# config/routes.rb
Rails.application.routes.draw do
  resources :posts, only: [:index, :create] do
    resources :comments, only: [:create]
  end
end
$ bin/rails routes -c comments
       Prefix Verb URI Pattern                        Controller#Action
post_comments POST /posts/:post_id/comments(.:format) comments#create

② controller:

# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  def create
    post = Post.find(params[:post_id])
    comment = post.comments.new(comment_params)
    comment.user = User.last   # ชั่วคราว: สมมุติว่าคนที่ login คือมานะ
    comment.save!
    render plain: "คอมเมนต์ใหม่บน #{post.title} โดย #{comment.user.name}: #{comment.body}\n"
  end

  private

  def comment_params
    params.expect(comment: [:body])
  end
end
  • params[:post_id] — เลขบทความจาก URL (ไม่ใช่ params[:id])
  • post.comments.new(…) — สร้างใบคอมเมนต์ที่ช่อง post_id ถูกเติมเป็น 5 ให้แล้ว (บท 115)
  • comment.user = User.last — ผู้เขียนมาจากฝั่งเรา ไม่ได้มาจากใบสั่ง · ต่อให้ไม่มีบรรทัดนี้ user_id ของคนร้ายก็ไม่ผ่าน expect อยู่ดี

③ ผลรันจริง:

$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" -X POST http://localhost:3000/posts/5/comments \
    -d "comment[body]=ขอสูตรด้วย" -d "comment[user_id]=1"
คอมเมนต์ใหม่บน ผัดกะเพราหมูสับ โดย มานะ: ขอสูตรด้วย

log (ตัดเฉพาะบรรทัดสำคัญ):

  Parameters: {"comment" => {"body" => "ขอสูตรด้วย", "user_id" => "1"}, "post_id" => "5"}
  Post Load (1.1ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 5 LIMIT 1 /*action='create',application='WongnokBlog',controller='comments'*/
  User Load (1.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 1 /*action='create',application='WongnokBlog',controller='comments'*/
  Comment Create (3.1ms)  INSERT INTO "comments" ("body", "post_id", "user_id", "created_at", "updated_at") VALUES ('ขอสูตรด้วย', 5, 3, '2026-11-04 12:10:02.806225', '2026-11-04 12:10:02.806225') RETURNING "id" /*action='create',application='WongnokBlog',controller='comments'*/
  • "post_id" => "5" อยู่นอกกลุ่ม comment — มาจาก URL ไม่ใช่จากฟอร์ม
  • User Load … ORDER BY "users"."id" DESC LIMIT 1 = "ขอผู้ใช้หนึ่งคน เรียงเลข id จากมากไปน้อย" = คนสุดท้าย (มานะ) · DESC = เรียงจากมากไปน้อย
  • INSERT INTO "comments" … VALUES ('ขอสูตรด้วย', 5, 3, …) — ลงลิ้นชัก comments ด้วย post_id 5 และ user_id 3 (มานะ) ไม่ใช่ 1 ที่คนร้ายอ้าง
  • ไม่มีบรรทัด Unpermitted parameters — ท่า expect ทิ้งเงียบ (หัวข้อก่อน)

ทดสอบความเข้าใจ

ข้อ 1 มาจาก Quiz 6 ของสไลด์หน้า 50 · ทุกเฉลยรันพิสูจน์แล้ว

ข้อ 1 · routes.rb มีแค่ resources :posts do resources :comments end · ใบสั่ง DELETE /posts/5/comments/9 ไปที่ไหน และ params มีอะไร?

ตัวเลือกที่ 3 ถูก · ผมถามพนักงานต้อนรับตรง ๆ ด้วย Rails.application.routes.recognize_path (ท่าเดียวกับ quiz ข้อ 4 ของบท 105) วนทีละเส้นครบห้าเส้นของสไลด์ แล้วพิมพ์ผล:

GET /posts/5 => {controller: "posts", action: "show", id: "5"}
GET /posts/5/edit => {controller: "posts", action: "edit", id: "5"}
POST /posts/5/comments => {controller: "comments", action: "create", post_id: "5"}
DELETE /posts/5/comments/9 => {controller: "comments", action: "destroy", post_id: "5", id: "9"}
GET /posts/new => {controller: "posts", action: "new"}

ตัวเลือกที่ 1 ลืมว่า URL ที่มี /comments ไปแผนก comments · ตัวเลือกที่ 2 ไปแผนกถูก แต่สลับเลข — :id เป็นของคอมเมนต์ (ลูก) ส่วนเลขบทความ (แม่) ชื่อ post_id · ตัวเลือกที่ 4 ผิดทั้ง verb และแผนก — DELETE ไปเมนู destroy เสมอ

จุดที่ต้องจำ: ใน nested route เลขของแม่ชื่อ :ชื่อแม่_id · เลขของลูกชื่อ :id · ตอบไม่ได้เมื่อไหร่ สั่ง bin/rails routes -c comments ดูได้เลย

ข้อ 2 · บน Rails 8.1 · action create เขียน @post = Post.new(params[:post]) (ไม่มี permit/expect) แล้วผู้ใช้ส่งฟอร์มธรรมดาที่มีแค่ title กับ body · เกิดอะไรขึ้น?

ตัวเลือกที่ 2 ถูก · ผลจริงในหัวข้อ mass assignment ขั้นที่ 1: 500 · log เขียน ActiveModel::ForbiddenAttributesError · 0 queries · คู่มือ Rails (Action Controller Overview หัวข้อ Strong Parameters) ก็เขียนตัวอย่างเดียวกันไว้ว่าบรรทัดแบบนี้จะโยน error ตัวนี้

ตัวเลือกที่ 1 คือสิ่งที่สไลด์หน้า 48 และ 50 พูดถึง — เกิดจริงเฉพาะตอนมีคนเติม permit! เข้าไป · ตัวเลือกที่ 3 ผิด — Rails ไม่รู้ว่าฟอร์มเรามีช่องอะไร ต้องเขียนรายชื่อเอง · ตัวเลือกที่ 4 ผิด — 400 มาจาก expect/require ตอนไม่มีกลุ่ม post ไม่ใช่เพราะลืมเขียน

จุดที่ต้องจำ: Rails 8 ปิดประตู mass assignment ไว้ก่อน · คนที่เปิดกลับคือเราเอง (ด้วย permit!) · ทางแก้ error นี้ที่ถูกคือเขียน strong parameters

ข้อ 3 · post_params เขียน params.require(:post).permit(:title, :body) · คนร้ายส่ง title · body และแอบเติม post[views]=999999 · ผลคืออะไร?

ตัวเลือกที่ 1 ถูก · ผลจริง: บันทึกแล้ว: {"title" => "ร้านผม", "views" => 0, …} และ log มี Unpermitted parameters: :views, :published, :user_id · SQL INSERT ใส่ views เป็น 0

ตัวเลือกที่ 2 กับ 4 ผิด — permit ไม่ปฏิเสธใบสั่ง แค่ทิ้งช่องเกินแล้วไปต่อ (status 200) · ตัวเลือกที่ 3 ผิด — log จดไว้ก็จริง แต่ช่องนั้นไม่ได้ลงตู้ · คำเตือนไม่ใช่การปล่อยผ่าน

จุดที่ต้องจำ: permit = allowlist ที่ทิ้งเงียบ ๆ · คนส่งไม่รู้ตัว · เราดูได้จาก log ตอนพัฒนา

ข้อ 4 · มีคนป่วนส่ง -d "post=hello" (post เป็นข้อความคำเดียว) · ถ้า post_params เขียนด้วย require/permit กับเขียนด้วย params.expect จะได้ status อะไรตามลำดับ?

ตัวเลือกที่ 4 ถูก · ผลจริงในตารางหัวข้อ params.expect: require/permit ได้ 500 + undefined method 'permit' for an instance of String · expect ได้ 400 + ParameterMissing

ตัวเลือกที่ 1 ผิดครึ่งเดียว — ท่าเก่าไม่ได้ 400 เพราะ require เห็นว่ามี post ก็ปล่อยผ่าน · ตัวเลือกที่ 2 ผิดครึ่งเดียว — expect เช็ครูปร่างก่อน จึงไม่พัง · ตัวเลือกที่ 3 ผิด — ทั้งสองท่าไม่ไปถึงบรรทัด save! เลย

จุดที่ต้องจำ: expect = require + permit + เช็ครูปร่าง · ใบสั่งผิดแบบได้ 400 (ความผิดคนส่ง) ไม่ใช่ 500 (ครัวพัง)

ข้อ 3 ปลายเปิดของสไลด์หน้า 50 ("แก้ให้ปลอดภัยแล้ว ยังมีอีก 2 เรื่องที่ควรแก้ · เรื่องหนึ่งเกี่ยวกับ save อีกเรื่องเกี่ยวกับผู้ใช้ที่กรอกผิด") · ลองคิดเองก่อน แล้วเทียบกับ action create ในบท 110 หัวข้อ 7 ที่ใช้ if @post.save … else render :new, status: :unprocessable_content · โค้ดในสไลด์ไม่ดูผลของ save แล้ว redirect เสมอ แม้บันทึกไม่สำเร็จ

ถามกลับได้เลย

ถ้าคุณเจอ route ที่ไม่รู้ว่าควรเป็น member · collection · หรือ resource ใหม่ (เช่น "กดไลก์บทความ" ควรเป็น POST /posts/:id/like หรือ resources :likes) — นี่คือคำถามที่ดีมากที่จะเอาไปถาม senior หรือ AI · ส่งไปพร้อมผล bin/rails routes -c ชื่อแผนก คนที่ช่วยจะเห็นภาพทันที

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริงต้องสนใจไหม
45–46resources 1 บรรทัด = 7 routebin/rails routes พิมพ์ 8 แถว เพราะ PATCH กับ PUT ไปเมนู update เดียวกันไม่ต้อง — นับเมนูได้ 7 ถูกแล้ว
46คอลัมน์ Gin ใช้ r.PUT คู่กับ PATCH ของ RailsRails รับทั้ง PATCH และ PUTไม่ต้อง
48Post.create(params[:post]) → "ทุก field ที่ส่งมา เข้าไปหมด"Rails 8.1.4 ได้ ForbiddenAttributesError (500) ไม่มีอะไรลงตู้ · ช่องโหว่เปิดจริงเมื่อเติม permit!รู้ไว้ — quiz ข้อ 2
48–49"field อื่นถูกทิ้ง + log เตือน"จริงกับ require/permit · ส่วน params.expect ทิ้งเหมือนกันแต่ไม่มี log Unpermitted parametersสนใจ — ถ้าเปลี่ยนเป็น expect อย่ารอดู log บรรทัดนี้
48GitHub ถูกเจาะ มีนาคม 2012 (Egor Homakov)ยืนยันได้ว่ามี issue #5228 ใน repo ของ Rails ที่เขาเปิดเรื่อง mass assignment วันที่ 1 มี.ค. 2012 · ส่วนรายละเอียดการเจาะไม่ได้ยืนยันไม่ต้อง
50 ข้อ 2ถามว่าโค้ด Post.new(params[:post]) มีปัญหาอะไรบน Rails 8.1.4 ได้ error ก่อนถึง save (ดูแถวหน้า 48) · ถ้าเปลี่ยนเป็น permit! บรรทัด user_id = current_user.id จะเขียนทับ user_id ได้ แต่ช่องอื่นรั่วรู้ไว้ — predict ในหัวข้อ mass assignment

อ้างอิง