Thư thật từ Gmail, sao luật chuyển tiếp lại bỏ qua?
🌱 mầmgieo #email#dmarc#regex#bai-hoc
Viết cho Ai viết luật lọc thư, bộ phân loại, hay bất cứ đoạn code nào đi tìm một chữ trong một chuỗi có cấu trúc.
Mình từng gõ if "zip" in content_type để đoán loại tệp mà chẳng nghĩ ngợi gì. Đọc xong nhật ký sửa lỗi của một máy chủ thư thì mình hết dám: cùng một kiểu lỗi đó, nó sập hai lần.
Chuyện xảy ra ở một máy chủ thư nhỏ lo hộp thư cho mấy trang web. Mỗi hộp thư giữ thư lại, rồi gửi một bản sang hộp Gmail người giữ trang. Luật gửi đi rất cẩn thận: chỉ thư nào qua được phép kiểm chống giả mạo DMARC mới đi. Vậy mà gần 11 ngày liền, lá thư thật nào từ Gmail hay Outlook tới cũng sẽ kẹt lại.
Số về DNS và hai luật trong bài, mình đo lại ngày 9/11 bằng scripts/garden/substring-check.ts. Số thư thì lấy từ nhật ký, vì mình chẳng có quyền mở hộp thư: chỗ nào vậy mình ghi rõ. Phần đo và bản nháp có AI làm cùng.
English: Real mail from Gmail: why did the forward rule skip it?.
Luật chuyển tiếp này canh cái gì
DMARC là một dòng mà mỗi tên miền tự đăng lên DNS, nói "thư mang tên tôi mà kiểm chẳng khớp thì xử sao". Có ba mức: p=none (cứ nhận, chỉ báo lại), p=quarantine (bỏ vào thư rác), p=reject (từ chối). Máy chủ nhận thư kiểm xong thì ghi kết quả vào đầu thư, trong dòng Authentication-Results: dmarc=pass là khớp, dmarc=fail là chẳng khớp, dmarc=none là tên miền đó chẳng đăng gì.
Chỉ chuyển tiếp thư đạt là một quyết định đúng. Chuyển tiếp nghĩa là gửi lại thư của người lạ từ địa chỉ IP của mình. Thư giả mà đi qua mình thì Gmail đổ lỗi cho IP mình, và từ chối luôn bằng mã 550. Luật viết bằng Sieve, một ngôn ngữ lọc thư chạy ngay trên máy chủ, và nói vầy: thấy dmarc=fail, dmarc=none hay hai mã lỗi temperror, permerror thì chỉ giữ lại; chẳng thấy dmarc=pass thì cũng giữ lại; còn lại thì gửi đi.
Luật đó chạy từ tối 28/10. Thư nào cũng còn nằm trong hộp, nên chẳng mất gì. Chỉ là bản gửi sang Gmail thì chẳng tới.
Chữ dmarc=none nằm ở chỗ không ai ngờ
Máy chủ này ghi kết quả cho một lá thư Gmail hợp lệ như sau (tên máy chủ mình thay bằng mx.example.net):
mx.example.net; dmarc=pass header.from=gmail.com policy.dmarc=none
Kết quả là dmarc=pass. Nhưng cuối dòng có thêm policy.dmarc=none, tức là ghi chú "tên miền gửi đang đăng mức p=none". Luật cũ hỏi bằng :contains của Sieve: "trong dòng có chữ dmarc=none không?", và chữ đó nằm gọn trong policy.dmarc=none. Luật đọc ghi chú về chính sách của người gửi, rồi tưởng đó là kết quả kiểm.
Mình tra DNS sáng nay: gmail.com, outlook.com và hotmail.com đều đăng p=none, còn yahoo.com đăng p=reject. Vậy thư Yahoo đi qua bình thường, thư Gmail thì kẹt. Thư có đi hay không chẳng phụ thuộc thư thật hay giả, mà phụ thuộc người gửi đăng mức gì. Mấy hộp thư phổ biến nhất lại đúng là mấy cái kẹt.
Bạn thử dưới đây. Bảng chạy đúng hai luật đó, phần tô màu là chỗ luật cũ dừng lại. Dán một dòng Authentication-Results bạn có vào ô cuối mà xem.
| dòng kết quả | luật cũ | luật mới |
|---|---|---|
mx.example.net; dmarc=pass header.from=gmail.com policy.dmarc=none | chỉ giữ lại | gửi đi |
mx.example.net; dmarc=pass header.from=yahoo.com policy.dmarc=reject | gửi đi | gửi đi |
mx.example.net; dmarc=fail header.from=gmail.com policy.dmarc=none | chỉ giữ lại | chỉ giữ lại |
mx.example.net; dmarc=none header.from=example.org | chỉ giữ lại | chỉ giữ lại |
Sao mấy bài kiểm tra vẫn xanh
Phần mình thấy đáng học nhất nằm ở đây. Luật này có hai bài kiểm tra, và cả hai đều đạt suốt 11 ngày.
- Bài thứ nhất tự gửi một lá thư sạch qua cổng gửi có đăng nhập. Thư kiểu đó chẳng mang dòng
Authentication-Resultsnào, nên luật cho đi. - Bài thứ hai chờ thư trả lời từ một dịch vụ kiểm thư bên ngoài. Tên miền dịch vụ đó đăng
p=quarantine, nên ghi chú cuối dòng làpolicy.dmarc=quarantine, chẳng có chữnonenào để vấp.
Hai bài kiểm tra đều đúng, chỉ là chẳng bài nào dùng loại thư mà người ta hay gửi nhất. Lỗi lộ ra khi có người cần chứng minh một hộp thư mới nhận và chuyển tiếp thư từ ngoài vào. Một lá thư từ tên miền p=none đến lúc 07:27:59 giờ UTC ngày 8/11, rồi nằm im trong hộp. Sửa xong, gửi lại đúng loại thư đó lúc 09:07:23 thì nó tới Gmail sau 15 giây.
May là thư vào còn ít. Nhật ký đếm: từ 28/10, cả 8 hộp thư chạy luật cũ nhận 3 lá thư ngoài đạt DMARC. 1 lá đi (từ tên miền p=quarantine), 2 lá kẹt, cả hai từ cùng một chỗ bán dịch vụ SEO, mà tên miền chỗ đó đăng p=none (mình tra lại, đúng vậy). Chẳng lá nào từ Gmail, và theo nhật ký thì chẳng thư bạn đọc nào bị lỡ. Mấy con số đếm thư này mình chẳng tự đếm lại nổi, mình chép từ nhật ký.
Lần thứ hai, cùng một cái bẫy
Dòng cuối của nhật ký sửa lỗi viết: "lại cái bẫy chuỗi con", tức một đoạn chữ nằm lọt trong chữ khác, "lần này lệch sang một giao thức". Mình lật lại thì thấy lần trước, hồi tháng 8.
Cũng máy chủ đó có một bộ phân loại báo cáo. Hộp thư báo cáo nhận về hai loại: báo cáo DMARC đóng gói application/zip, và báo cáo TLS-RPT (báo kết nối mã hóa tới máy chủ mình có trục trặc gì không) đóng gói application/tlsrpt+gzip. Code nhận DMARC bằng "zip" in ctype. Mà gzip có chứa zip.
Vậy là mọi báo cáo TLS-RPT đều bị xếp nhầm vào DMARC, rồi đọc như XML, ra 0 dòng, và chẳng báo lỗi gì. Một cổng kiểm cứ báo "chưa nhận báo cáo TLS-RPT nào" từ ngày viết ra. Đếm tay trong hộp thư thì có 4 báo cáo nằm sẵn. Số báo cáo DMARC cũng sai theo: "10 báo cáo" thật ra là 6 báo cáo thật cộng 4 cái xếp nhầm. Một lỗi, hai con số sai, mà chẳng con số nào trông đáng ngờ.
Hai lần đó giống nhau ở chỗ: code muốn hỏi "giá trị này là gì", nhưng lại hỏi "chuỗi này có chứa mấy chữ này không". Hai câu đó trùng nhau cho tới khi có ai đặt chữ đó vào một chỗ khác trong chuỗi. Trong đầu thư có policy.dmarc=, smtp.mailfrom=, header.from=. Trong kiểu nội dung có hậu tố +gzip, +json, +xml. Mấy chuỗi có cấu trúc kiểu này lúc nào cũng có chỗ cho một chữ lạc vào.
Sửa sao cho khỏi sập lần ba
Cả hai lần, bản sửa đều hỏi đúng đơn vị. Luật thư giờ chỉ nhận dmarc=… khi nó đứng đầu dòng, hoặc ngay sau dấu ; hay khoảng trắng:
(^|[;[:space:]])dmarc=(fail|none|temperror|permerror)
policy.dmarc=none có dấu chấm đứng trước, nên chẳng khớp. Bộ phân loại thì so nguyên kiểu nội dung, kiểm TLS-RPT trước, và thêm một ngăn "chưa phân loại" có cảnh báo, để lần sau xếp nhầm thì ít ra cũng có tiếng.
Mình thấy đáng giữ hơn cả là hai thói quen đi kèm:
- Kiểm bằng thư người ta hay gửi nhất. Giờ có một bài kiểm riêng gửi thư từ một tên miền
p=none, đúng dạng thư Gmail mang tới. - Viết thử lại lỗi cũ và xem bài kiểm có đỏ không. Lần tháng 8, người sửa cố tình đặt lại
"zip" in ctypevào code, và bài kiểm đỏ ngay. Một bài kiểm chưa đỏ lần nào thì mình chưa biết nó có canh gì thật không.
Bạn tự dò code mình sao
Tìm mấy chỗ hỏi "có chứa" trên một chuỗi có cấu trúc. Trong JavaScript là .includes(, Python là in, SQL là LIKE '%…%', Sieve là :contains. Rồi hỏi từng chỗ ba câu:
- Chuỗi này có cấu trúc không: đầu thư, kiểu nội dung, URL, đường dẫn, danh sách cách nhau bằng dấu phẩy?
- Chữ mình tìm có thể nằm trong một chữ khác dài hơn không? Thử thêm tiền tố và hậu tố:
policy.,+g,x-. - Bài kiểm của mình có dùng giá trị thật mà người ta hay gặp nhất không, hay chỉ dùng giá trị mình tự đặt?
Muốn chắc thì tách chuỗi theo đúng luật của nó rồi so từng phần. Với một kiểu nội dung, chỉ vầy là đủ:
const [type] = ctype.toLowerCase().split(";");
const isDmarc = type.trim() === "application/zip";
const isTlsRpt = type.trim() === "application/tlsrpt+gzip";
Chưa biết gì
- Luật mới vẫn là so chữ, chỉ là so khéo hơn. Một bộ đọc đúng chuẩn
Authentication-Resultssẽ chắc hơn, nhưng mình chưa biết Sieve có cách nào gọn để làm vậy. - Mình chỉ thấy dạng dòng kết quả từ đúng một loại máy chủ thư. Máy chủ khác ghi chính sách theo cách khác, như Gmail ghi
(p=NONE sp=QUARANTINE dis=NONE)trong ngoặc. Luật nào đọc dòng của nhiều máy chủ thì phải thử từng dạng, mà mình chưa thử. - Thư từ tên miền chẳng đăng DMARC vẫn chẳng đi, cả theo luật mới. Đó là cái giá người viết luật đã chọn và ghi rõ. Bao nhiêu thư thật rơi vào nhóm đó thì mình chưa biết.
- Còn code của mình: hôm nay mình đếm 44 chỗ
.includes(trong 22 tệp code của chính mảnh vườn này, mà chưa ngồi đọc từng chỗ. Chắc có chỗ đang chờ sập.
Bạn từng bị một chữ con lừa chưa? Viết cho mình một lá thư bên dưới nha.
Gửi Ba rồng một lá thư
Đọc xong muốn nói gì thì viết cho mình nha. Thư chỉ tới tay mình, chẳng tự hiện lên trang. Lá nào hay mà bạn cho phép, mình chép lại dưới bài, không kèm tên bạn.
Từ terminal cũng gửi được: curl -d 'msg=…' olragon.com/garden/substring-vi/letter