เคยสร้าง web server ของตัวเองด้วย Ruby ไหม?
เรามี server หลายตัวแล้ว เช่น:
- Puma
- Thin
- Unicorn
แต่ผมคิดว่า นี่คือ exercise เรียนรู้ที่ยอดเยี่ยม ถ้าอยากรู้ว่า web server 簡単ทำงานอย่างไร
ในบทความนี้ คุณจะได้เรียนรู้วิธีทำ ทีละขั้นตอน!
ขั้นตอน 1: รอรับ Connection
เริ่มที่ไหนดี?
สิ่งแรกคือต้อง listen สำหรับ connection ใหม่บน TCP port 80
ผมเขียน post เกี่ยว network programming ใน Ruby ไปแล้ว จึงไม่解釋ที่นี่
แค่ให้โค้ด:
require 'socket'
server = TCPServer.new('localhost', 80)
loop {
client = server.accept
request = client.readpartial(2048)
puts request
}
รันโค้ดนี้ จะได้ server ที่รับ connection บน port 80 ยังไม่ทำอะไรมาก แต่มอง request ที่เข้าไปได้
หมายเหตุ: ใช้ port 80 บน Linux/Mac ต้องมีสิทธิ์ root ถ้าไม่มี ใช้ port อื่นเหนือ 1024 ผมชอบ 8080 🙂
วิธีง่ายสร้าง request คือใช้ browser หรือ curl
ทำแล้วจะเห็น output ใน server:
GET / HTTP/1.1
Host: localhost
User-Agent: curl/7.49.1
Accept: */*
นี่คือ HTTP request HTTP เป็น plain-text protocol ใช้สื่อสารระหว่าง browser และ web server
Spec อยู่ที่: https://tools.ietf.org/html/rfc7230
ขั้นตอน 2: Parse Request
ต้องแยก request เป็นส่วนๆ ที่ server เข้าใจ
สร้าง parser เอง หรือใช้ที่มีอยู่ เราจะสร้างเอง จึงต้องเข้าใจส่วนต่างๆ ของ request
รูปนี้ช่วยให้เข้าใจ:

GET Request
Headers ใช้สำหรับ caching, virtual hosting, compression แต่สำหรับ basic implementation เราสามารถข้ามได้
สร้าง HTTP parser 簡単 โดยใช้ข้อเท็จจริงว่า request data แยกด้วย new lines (\r\n) ไม่ทำ error/validity checking เพื่อให้ simple
โค้ดที่ผมเขียน:
def parse(request)
method, path, version = request.lines[0].split
{
path: path,
method: method,
headers: parse_headers(request)
}
end
def parse_headers(request)
headers = {}
request.lines[1..-1].each do |line|
return headers if line == "\r\n"
header, value = line.split
header = normalize(header)
headers[header] = value
end
def normalize(header)
header.gsub(":", "").downcase.to_sym
end
end
จะคืนค่า hash พร้อม parsed request data ตอนนี้มี request ในรูปแบบใช้งานได้ สร้าง response ให้ client
ขั้นตอน 3: เตรียมและส่ง Response
ต้องเช็คว่า requested resource มีอยู่จริง คือเช็คว่าไฟล์มีอยู่
โค้ด:
SERVER_ROOT = "/tmp/web-server/"
def prepare_response(request)
if request.fetch(:path) == "/"
respond_with(SERVER_ROOT + "index.html")
else
respond_with(SERVER_ROOT + request.fetch(:path))
end
end
def respond_with(path)
if File.exist?(path)
send_ok_response(File.binread(path))
else
send_file_not_found
end
end
มี 2 สิ่งเกิดขึ้น:
- ถ้า path เป็น
/สมมติว่าไฟล์คือindex.html - ถ้าเจอไฟล์ ส่งเนื้อหาไฟล์พร้อม OK response
แต่ถ้าไม่เจอ ส่ง response 404 Not Found ทั่วไป
ตาราง HTTP Response Codes ที่พบบ่อย
อ้างอิง
| Code | คำอธิบาย |
|---|---|
| 200 | OK |
| 301 | Moved permanently |
| 302 | Found |
| 304 | Not Modified |
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not found |
| 500 | Internal Server Error |
| 502 | Bad Gateway |
Response Class และเมธอด
นี่คือเมธอด "send" ที่ใช้ในตัวอย่างก่อนหน้า:
def send_ok_response(data)
Response.new(code: 200, data: data)
end
def send_file_not_found
Response.new(code: 404)
end
และนี่คือคลาส Response:
class Response
attr_reader :code
def initialize(code:, data: "")
@response = "HTTP/1.1 #{code}\r\n" +
"Content-Length: #{data.size}\r\n" +
"\r\n" +
"#{data}\r\n"
@code = code
end
def send(client)
client.write(@response)
end
end
Response สร้างจาก template และ string interpolation
ตอนนี้แค่รวมทุกอย่างใน connection-accepting loop จะได้ functional server
loop {
client = server.accept
request = client.readpartial(2048)
request = RequestParser.new.parse(request)
response = ResponsePreparer.new.prepare(request)
puts "#{client.peeraddr[3]} #{request.fetch(:path)} - #{response.code}"
response.send(client)
client.close
}
ลองเพิ่ม HTML files ใต้ SERVER_ROOT และควรโหลดได้จาก browser จะ serve static assets รวมรูปภาพด้วย
當然 web server จริงมีฟีเจอร์เยอะกว่านี้มาก
รายการ บางส่วน ที่ขาดไป ลอง implement เองเป็น exercise (practice is the mother of skill!):
- Virtual hosting
- Mime types
- Data compression
- Access control
- Multi-threading
- Request validation
- Query string parsing
- POST body parsing
- Browser caching (response code 304)
- Redirects
บทเรียนเรื่อง Security
รับ input จาก user แล้วทำอะไรสักอย่าง อันตรายเสมอ ใน project นี้ user input คือ HTTP request
เราเผลอเปิดช่องโหว่ "path traversal" คนจะอ่านไฟล์ได้ทุกไฟล์ที่ web server user เข้าถึงได้ แม้จะอยู่นอก SERVER_ROOT
บรรทัดที่ทำให้เกิด issue:
File.binread(path)
ลอง exploit เองดู จะต้องทำ "manual" HTTP request เพราะ HTTP clients (รวม curl) จะ pre-process URL และตัดส่วนที่ trigger vulnerability
เครื่องมือหนึ่งคือ netcat
Exploit ที่เป็นไปได้:
$ nc localhost 8080
GET ../../etc/passwd HTTP/1.1
จะคืนเนื้อหา /etc/passwd ถ้าคุณอยู่บน Unix-based system เหตุผลคือ double dot (..) ให้ขึ้นไปหนึ่ง directory "escape" SERVER_ROOT
วิธีแก้หนึ่งคือ "compress" multiple dots เป็นหนึ่ง:
path.gsub!(/\.+/, ".")
คิดเรื่อง security ให้ใส่ "hacker hat" ลองหาวิธีโจมตีโซลูชันของตัวเอง เช่นถ้าแค่ path.gsub!("..", ".") สามารถ bypass ด้วย triple dots (...)
โค้ดเสร็จและทำงานได้
รู้ว่าโค้ดกระจัดกระจายในบทความ ถ้าหา finished, working code...
ลิงก์:
https://gist.github.com/matugm/efe0a1c4fc53310f7ac93dcd1f041f6c#file-web-server-rb
Enjoy!
สรุป
ในบทความนี้ คุณเรียนรู้การ listen connection, HTTP request long อย่างไร และ parse ยังไง สร้าง response ด้วย response code และเนื้อหาไฟล์ (ถ้ามี)
และสุดท้าย เรียนรู้ช่องโหว่ "path traversal" และวิธีป้องกัน
หวังว่าคุณเพลิดเพลินและเรียนรู้สิ่งใหม่! อย่าลืมสมัครรับข่าวสารด้านล่าง จะไม่พลาดบทความใหม่ 🙂