เว็บกับคิวคนละผู้ใช้ — mkdir 0700 ฆ่า default ACL ทิ้งทั้งโฟลเดอร์
ปัญหาสิทธิ์ไฟล์ที่กินเวลาผมมากที่สุดไม่ใช่แบบที่ chmod แล้วจบ แต่เป็นแบบที่ตั้ง ACL ไว้ถูกต้องทุกบรรทัด getfacl ก็อ่านออกมาสวยงาม แต่พออีกฝั่งไปเขียนจริงกลับ Permission denied เพราะโฟลเดอร์ตัวที่มีปัญหาถูกสร้างขึ้นมาด้วยโหมดที่ไปฆ่า ACL ทิ้งตั้งแต่วินาทีแรกที่มันเกิด
พอดีมีระบบหนึ่งที่ผมดูแลอยู่ เป็นแอปเว็บที่มีงานหนักบางอย่างโยนเข้าคิวไปทำเบื้องหลัง อาการที่เจอคือไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้าเว็บ บางโฟลเดอร์คิวหยิบไปทำต่อได้ปกติ บางโฟลเดอร์คิวเขียนอะไรลงไปไม่ได้เลย ทั้งที่โครงสร้างเหมือนกันเป๊ะ ตั้ง default ACL ไว้ตัวเดียวกันกับตัวที่ทำงานได้ ผมไล่อยู่นานพอสมควรกว่าจะเจอว่าความต่างอยู่ที่ "ใครสร้างโฟลเดอร์นั้นก่อน และสร้างด้วยโหมดอะไร"
ขอแยกให้ชัดก่อนว่าเรื่องนี้ไม่เหมือนเคส ACL ข้ามเครื่องบน NFS ที่ผมเคยเขียนไว้ ตรงนั้นคือ uid ของสองเครื่องไม่ตรงกัน แต่เคสนี้คือเครื่องเดียว แอปเดียว ดิสก์เดียว แต่มีผู้ใช้สองบัญชีเข้ามาเขียนไฟล์ในโฟลเดอร์ชุดเดียวกัน ซึ่งเป็นสถาปัตยกรรมที่พบบ่อยมากเวลาเอา Laravel หรือแอป PHP อะไรก็ตามขึ้น VPS ของตัวเอง ชื่อผู้ใช้ ชื่อโฟลเดอร์ และค่าทั้งหมดในบทความนี้ผมสมมุติขึ้นมาใหม่หมด แต่พฤติกรรมของ kernel ที่เล่าคือของจริงที่ลองซ้ำได้
แอปตัวเดียว แต่มีสองผู้ใช้จริงๆ
เวลาวางระบบแบบนี้ ถ้าไม่ไปนั่งรวมทุกอย่างเป็นผู้ใช้เดียว หน้าตาปกติจะเป็นประมาณนี้ครับ
ฝั่งเว็บรันด้วยผู้ใช้ของ php-fpm pool ฝั่งคิวเป็น systemd service ที่รันด้วยผู้ใช้ของแอปอีกตัวหนึ่ง และ cron ก็มักจะเป็นผู้ใช้เดียวกับคิว เหตุผลที่แยกกันก็มีอยู่จริง — เครื่องมือบางอย่างติดตั้งอยู่ใน home ของผู้ใช้แอป และผมไม่อยากให้ process ที่รับ request จากอินเทอร์เน็ตตรงๆ เข้าถึงของพวกนั้นได้
; /etc/php/8.2/fpm/pool.d/app.conf
[app]
user = webuser
group = webgroup
listen = /run/php/app.sock
listen.owner = www-data
# /etc/systemd/system/app-queue.service
[Service]
User=appuser
Group=webgroup
UMask=0007
WorkingDirectory=/srv/app
ExecStart=/usr/bin/php artisan queue:work --queue=default --timeout=600
Restart=always
สังเกตว่าผมให้ทั้งสองบัญชีอยู่ใน group เดียวกันคือ webgroup ตรงนี้สำคัญและเดี๋ยวจะย้อนมาพูดถึงอีกที
default ACL คืออะไร และทำไมมันควรจะแก้ปัญหานี้ได้
สิทธิ์ยูนิกซ์แบบดั้งเดิมมีแค่สามช่อง เจ้าของ / กลุ่ม / คนอื่น พอมีสองบัญชีที่ต้องเขียนไฟล์ชุดเดียวกันแล้วไม่อยากเปิดให้ other ก็ต้องพึ่ง POSIX ACL ซึ่งเพิ่มกฎรายบัญชีเข้าไปได้
คำว่า default ACL (d:) คือ "แม่แบบ" ที่ติดอยู่กับโฟลเดอร์ ของใหม่ที่เกิดขึ้นข้างในจะได้ ACL ชุดนี้ติดตัวไปด้วยอัตโนมัติ ตั้งทีเดียวแล้วไม่ต้องมาไล่ chmod ทุกครั้ง
sudo setfacl -m u:appuser:rwx -m d:u:appuser:rwx /srv/app/storage/app/private
sudo setfacl -m u:webuser:rwx -m d:u:webuser:rwx /srv/app/storage/app/private
getfacl /srv/app/storage/app/private
ผลที่ได้ควรหน้าตาแบบนี้
# file: srv/app/storage/app/private
# owner: webuser
# group: webgroup
user::rwx
user:appuser:rwx
user:webuser:rwx
group::r-x
mask::rwx
other::---
default:user::rwx
default:user:appuser:rwx
default:user:webuser:rwx
default:group::r-x
default:mask::rwx
default:other::---
ถึงตรงนี้ทุกอย่างดูถูกหมด ผมก็คิดว่าจบแล้ว จนกระทั่งคิวเริ่มเขียนไฟล์ลงโฟลเดอร์ย่อยที่เว็บสร้างขึ้นมาใหม่ไม่ได้
จุดตาย: mkdir(0700) ตั้ง mask ทับ ACL
บรรทัดที่เป็นต้นเหตุคือบรรทัดเดียวในโค้ดแอป หน้าตาไม่มีพิษภัยเลย
// โค้ดที่ดู "ปลอดภัยดี" แต่ฆ่า ACL ทิ้งทั้งโฟลเดอร์
$dir = storage_path('app/private/jobs/'.$jobId);
if (! is_dir($dir)) {
mkdir($dir, 0700, true);
}
0700 อ่านแล้วรู้สึกว่าแน่นดี ปิดทั้ง group ทั้ง other แต่สิ่งที่เกิดขึ้นจริงคือ kernel เอาโหมดที่เราส่งเข้าไปมาตัดกับ default ACL แล้วผลของการตัดนั้นไปลงที่ mask:: ซึ่งเป็นเพดานสูงสุดของทุกกฎ ACL ที่ระบุตัวบุคคล พอ group bit ในโหมดเป็น 0 ก็ได้ mask::--- ทันที
sudo -u webuser mkdir -m 0700 /srv/app/storage/app/private/jobs/demo01
getfacl /srv/app/storage/app/private/jobs/demo01
# file: .../jobs/demo01
user::rwx
user:appuser:rwx #effective:---
user:webuser:rwx #effective:---
group::r-x #effective:---
mask::---
other::---
บรรทัด #effective:--- นั่นแหละคือคำตอบทั้งหมด กฎยังอยู่ครบทุกบรรทัด getfacl ก็ยังพิมพ์ชื่อ appuser ออกมาให้เห็นเต็มตา แต่ผลที่ใช้ได้จริงเป็นศูนย์ เพราะถูก mask กดไว้ ถ้าไล่ดูแบบผ่านตาจะไม่เห็นอะไรผิดเลย ต้องอ่านคอลัมน์ effective ถึงจะเจอ
โฟลเดอร์ที่เกิดด้วย mkdir โหมด 0700 จะเป็นของผู้ใช้ที่สร้างมันคนเดียวถาวร อีกฝั่งเขียนไม่ได้ อ่านไม่ได้ ลบไม่ได้ และที่แย่กว่านั้นคือมันขึ้นกับว่าใครวิ่งมาถึงก่อน งานที่ผู้ใช้กดเองผ่านหน้าเว็บก็จะเว็บสร้าง งานที่เข้ามาทาง cron ก็จะคิวสร้าง ผลเลยออกมาเป็นอาการแบบ "บางงานผ่าน บางงานไม่ผ่าน" ที่ทำซ้ำไม่ได้ตามใจ ซึ่งเป็นอาการที่กินเวลาไล่หาสาเหตุนานที่สุดแบบหนึ่ง
0770 คือค่าที่ถูกต้อง ไม่ใช่ 777
ทางแก้สั้นมากครับ เปลี่ยนเลขตัวเดียว
// โฟลเดอร์: group bit ต้องเปิด ไม่งั้น mask ตายทันที
mkdir($dir, 0770, true);
// ไฟล์ข้างใน: ให้ group เขียนได้ แต่ other ปิดสนิทเหมือนเดิม
file_put_contents($path, $data);
chmod($path, 0660);
0770 ยังปิด other สนิทเท่าเดิมทุกประการ ระดับความปลอดภัยไม่ได้ลดลงเลยสักนิด ต่างกันแค่ว่ามันไม่ไปกด mask ให้เป็นศูนย์ ACL ที่เราอุตส่าห์ตั้งไว้เลยทำงานได้ตามที่ตั้งใจ
ACL ตายแต่ยังอ่านเห็น
mask กลายเป็น --- ทุกกฎรายบุคคลถูกกดเป็นศูนย์ getfacl ยังพิมพ์ชื่อผู้ใช้ออกมาครบ ทำให้คนตรวจเข้าใจผิดว่าตั้งไว้แล้ว ต้องอ่าน effective ถึงจะรู้ว่าใช้ไม่ได้
ACL ทำงาน other ยังปิด
mask เป็น rwx กฎรายบุคคลมีผลจริง ส่วน other ยังเป็น --- เหมือนเดิม คนนอกกลุ่มยังเข้าไม่ได้ ได้ความปลอดภัยเท่าเดิมโดยไม่ต้องไปแตะ 777
ส่วนคนที่กำลังคิดจะ chmod -R 777 ให้จบๆ ไป ผมอยากให้หยุดคิดสักนิด เพราะมันไม่ได้แค่ "เปิดกว้างไปหน่อย" แต่เป็นการเปลี่ยนคลาสของความเสี่ยง ถ้าโฟลเดอร์ที่เป็น 777 อยู่ในต้นไม้ของโค้ด process เว็บจะเขียนทับไฟล์โค้ดของตัวเองได้ ซึ่งแปลว่าช่องโหว่อัปโหลดไฟล์ระดับธรรมดาจะกลายเป็นการรันโค้ดถาวรบนเครื่องทันที
โฟลเดอร์ที่ process เว็บต้องเขียนได้ควรมีแค่ storage กับ cache เท่านั้น และควรอยู่นอกเส้นทางที่ web server เสิร์ฟตรงๆ ส่วนไฟล์โค้ดทั้งหมดควรเป็นของผู้ใช้คนละคนกับผู้ใช้ที่รันเว็บ และเว็บควรมีสิทธิ์แค่อ่าน ถ้าวันหนึ่งมีคนอัปไฟล์แปลกๆ เข้ามาได้ อย่างน้อยเขาก็เขียนทับ controller ของเราไม่ได้
umask ของแต่ละ process ต้องตรงกัน
แก้ mkdir อย่างเดียวยังไม่พอครับ เพราะโหมดที่ส่งเข้า mkdir() จะถูก umask ของ process ตัดอีกชั้นก่อนถึง kernel ถ้า php-fpm รันด้วย umask 0022 ที่เป็นค่าเริ่มต้น group write bit จะโดนตัดออกตั้งแต่ยังไม่ทันไปถึง ACL
# ดู umask จริงของ process ที่รันอยู่ ไม่ใช่ของ shell เรา
cat /proc/$(pgrep -f 'php-fpm: pool app' | head -1)/status | grep -i umask
cat /proc/$(systemctl show -p MainPID --value app-queue)/status | grep -i umask
ถ้าไม่ได้ 0007 ก็ต้องไปตั้งให้ตรงกันทั้งสองฝั่ง ฝั่ง systemd ใช้ UMask=0007 ในไฟล์ unit ตามตัวอย่างข้างบน ส่วนฝั่ง php-fpm ตั้งในไฟล์ pool
; /etc/php/8.2/fpm/pool.d/app.conf
process.priority = 0
; ตัด other ทิ้ง แต่ปล่อย group ผ่าน
php_admin_value[upload_tmp_dir] = /srv/app/storage/tmp
แล้วตั้ง UMask=0007 ใน override ของ service php-fpm เอาอีกที ผมชอบวิธีตั้งที่ระดับ systemd มากกว่าตั้งใน php เพราะมันครอบทุกอย่างที่ process นั้นสร้าง รวมถึงไฟล์ที่ไลบรารีภายนอกสร้างเองโดยที่โค้ดเราไม่ได้เรียก chmod ตาม
ลบไฟล์ต้องมีสิทธิ์เขียนที่โฟลเดอร์ ไม่ใช่ที่ไฟล์
ข้อนี้เป็นเรื่องที่คนพลาดกันเยอะ และผมเองก็เคยเข้าใจผิดอยู่หลายปี — การลบไฟล์ไม่ได้ขอสิทธิ์ที่ตัวไฟล์ แต่ขอสิทธิ์เขียนที่โฟลเดอร์ที่ไฟล์นั้นอยู่ เพราะการลบคือการแก้ไขรายการชื่อในไดเรกทอรี ไม่ใช่การแก้เนื้อไฟล์
ผลในทางปฏิบัติคือ ถ้าเว็บสร้างโฟลเดอร์ไว้ที่ 0755 ต่อให้ไฟล์ข้างในเป็น 0666 เปิดหราขนาดไหน คิวก็ลบไฟล์นั้นไม่ได้อยู่ดี
sudo -u webuser mkdir -m 0755 /srv/app/storage/app/private/demo02
sudo -u webuser install -m 0666 /dev/null /srv/app/storage/app/private/demo02/a.tmp
sudo -u appuser rm /srv/app/storage/app/private/demo02/a.tmp
# rm: cannot remove 'a.tmp': Permission denied
sudo -u appuser truncate -s 0 /srv/app/storage/app/private/demo02/a.tmp
# สำเร็จ — เขียนได้ แต่ลบไม่ได้
เขียนทับเนื้อไฟล์ได้ แต่ลบไม่ได้ อาการแบบนี้อ่านจาก log ของแอปแล้วงงมาก เพราะขั้นตอนหลักทำงานผ่านหมด ไปตายตอนเก็บกวาด
ลบไม่สำเร็จแบบเงียบสนิท
ทีนี้มาถึงจุดที่ทำให้เรื่องทั้งหมดข้างบนกลายเป็นบั๊กที่มองไม่เห็นเลย — API ลบไฟล์ของ framework หลายตัวถูกออกแบบมาให้ "ไม่โยน exception" เป็นค่าเริ่มต้น
// คืน false เฉยๆ ไม่มี exception ไม่มี log
Storage::disk('private')->delete($relative);
// unlink ของ PHP ดังกว่า แต่ดังแค่ระดับ warning ซึ่งมักถูกกลืนไปกับ error handler
@unlink($absolute);
โค้ดที่เรียกแบบนี้แล้วไม่เช็ค return คือโค้ดที่บอกเราว่า "ลบแล้ว" ทุกครั้งไม่ว่าจะลบได้จริงหรือไม่ ผมเปลี่ยนมาเขียนแบบนี้แทน
$ok = Storage::disk('private')->delete($relative);
if (! $ok) {
// log แค่ path กับผลลัพธ์ ไม่ต้องเอาเนื้อหาในไฟล์มาใส่
Log::warning('temp cleanup failed', [
'path' => $relative,
'owner' => function_exists('posix_getpwuid')
? posix_getpwuid(fileowner($absolute))['name'] ?? '?'
: '?',
'dir' => substr(sprintf('%o', fileperms(dirname($absolute))), -4),
]);
}
การเก็บโหมดของโฟลเดอร์แม่ลง log ด้วยเป็นสิ่งที่ผมเพิ่มเข้าไปทีหลังแล้วรู้สึกว่าคุ้มมาก เพราะวันที่เกิดปัญหาขึ้นจริง บรรทัดเดียวนี้บอกทุกอย่าง ไม่ต้องไปนั่งเดาว่าโฟลเดอร์ไหนเกิดมาผิดโหมด
ชุดคำสั่งตรวจหลัง deploy
ผมรวบเป็นลำดับสั้นๆ ที่รันได้ทุกครั้งหลังขึ้นของใหม่ ใช้เวลาไม่ถึงนาทีแต่กันเคสนี้ได้หมด
- ไล่หาโฟลเดอร์ที่ ACL ถูกกดตายทั้งต้นไม้ ด้วยการ grep หา mask ที่เป็นศูนย์ ถ้าเจอแม้แต่ตัวเดียวแปลว่ามี mkdir ที่ไหนสักแห่งยังส่งโหมดผิดอยู่
- ทดสอบเขียนจริงในนามของทั้งสองบัญชี อย่าทดสอบด้วย root เพราะ root ผ่านทุกอย่างและจะให้ผลลวง
- ทดสอบลบจริงด้วย ไม่ใช่ทดสอบแค่เขียน เพราะสิทธิ์คนละชุดกันอย่างที่เล่าไปข้างบน
- ยืนยัน umask ของ process ที่รันอยู่จริงจาก proc ไม่ใช่ค่าที่เราคิดว่าตั้งไว้ในไฟล์ config
- เช็คว่าโฟลเดอร์ที่เขียนได้ทั้งหมดอยู่นอกเส้นทางที่ web server เสิร์ฟ และไม่มีโฟลเดอร์โค้ดไหนที่ผู้ใช้ของเว็บเขียนได้
คำสั่งสำหรับข้อ 1 กับ 2 ที่ผมใช้ประจำ
# 1) หาโฟลเดอร์ที่ mask ตาย
find /srv/app/storage -type d -exec sh -c \
'getfacl -p "$1" 2>/dev/null | grep -q "^mask::---" && echo "MASK DEAD: $1"' _ {} \;
# 2) ทดสอบเขียน/ลบสองทาง
for u in webuser appuser; do
sudo -u "$u" sh -c '
d=/srv/app/storage/app/private/_probe
mkdir -p -m 0770 "$d" && : > "$d/'"$u"'.probe" \
&& echo "[OK] '"$u"' write" || echo "[ERR] '"$u"' write"
'
done
for u in webuser appuser; do
sudo -u "$u" sh -c 'rm -f /srv/app/storage/app/private/_probe/*.probe' \
&& echo "[OK] $u delete" || echo "[ERR] $u delete"
done
root ข้าม ACL ข้าม mask ข้ามทุกอย่าง ผลที่ได้เลยเป็น OK เสมอไม่ว่าระบบจะพังแค่ไหน ทุกครั้งที่ต้องพิสูจน์เรื่องสิทธิ์ไฟล์ ให้สวมบทเป็นผู้ใช้ที่รันโค้ดนั้นจริงๆ ด้วย sudo -u เสมอ ข้อนี้ใช้ได้กับทั้งเรื่องไฟล์และเรื่องไฟร์วอลล์ขาออก ซึ่งเป็นกับดักตัวเดียวกันคนละหน้าตา
สรุปที่ผมยึดไว้
กฎที่ผมเขียนติดไว้ในเอกสารของโปรเจกต์มีแค่สี่ข้อ โฟลเดอร์ร่วมใช้ 0770 เสมอ ห้ามมี 0700 โผล่ในโค้ดที่สร้างโฟลเดอร์ใต้ storage · ไฟล์ร่วมใช้ 0660 · umask ต้องเป็น 0007 ทั้งฝั่งเว็บและฝั่งคิว · และทุกครั้งที่เขียนโค้ดลบไฟล์ต้องเช็คค่าที่คืนกลับมาแล้ว log เมื่อไม่สำเร็จ
ที่เหลือเป็นเรื่องของการตรวจ ซึ่งผมยัดชุดคำสั่งข้างบนไว้ท้ายสคริปต์ deploy เลย เสียเวลาไม่ถึงนาที แต่ครั้งที่มันจับได้ครั้งเดียวก็คุ้มค่าเวลาที่ต้องมานั่งไล่หาสาเหตุทั้งคืนแล้ว ใครจะปรับเลขหรือปรับชื่อ group ไปตามระบบตัวเองก็ตามสะดวกครับ หลักการเดียวกันหมด ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย