Vì sao giới hạn chi tiêu cho gọi mô hình ngôn ngữ bị qua mặt, và ba cách khóa lại
Ba lỗi khiến giới hạn miễn phí trước lệnh gọi trả phí không giữ được lời hứa: khóa đếm giả mạo được, cửa API phụ bỏ qua CloudFront, và bộ đếm đua nhau trong cơn bùng nổ — cùng ba cách sửa bằng mã.
Bạn đặt một giới hạn miễn phí trước một lệnh gọi trả phí. Không có lỗi nào hiện ra. Hóa đơn vẫn tăng.
Đó là tình huống của một thiết kế quen thuộc: một hàm Lambda chạy sau CloudFront và API Gateway, trước lệnh gọi mô hình ngôn ngữ có trả phí. Chủ dịch vụ cho mỗi người dùng ẩn danh một số lượt gọi miễn phí mỗi ngày, rồi chặn lại. Nghe đơn giản, nhưng ba chỗ trong thiết kế này đều có thể bị qua mặt. Bài này đi qua từng lỗi và cách sửa, theo một bài phân tích có kèm mã nguồn và thử nghiệm của tác giả gốc.
Lỗi thứ nhất: khóa đếm bằng một tiêu đề mà người gọi tự chọn
Cách làm ngây thơ là đếm lượt gọi theo địa chỉ IP lấy từ tiêu đề X-Forwarded-For, hoặc theo mã người dùng mà phía máy khách tự gửi lên. Cả hai đều do người gọi kiểm soát, nên đều không đáng tin.
Vấn đề nằm ở cách CloudFront xử lý tiêu đề này. Khi yêu cầu của người xem đã mang sẵn X-Forwarded-For, CloudFront sẽ ghi thêm địa chỉ IP thật của người xem vào cuối tiêu đề trước khi chuyển tới máy chủ gốc. Giá trị mà người gọi tự ghi nằm ở đầu. Đọc phần tử đầu tiên, bạn nhận được một giá trị do kẻ tấn công chọn. Đọc phần tử cuối cùng cũng không khá hơn: API Gateway lại ghi thêm địa chỉ của máy chủ CloudFront vào sau đó. Tóm lại, đừng phân tích tiêu đề này để làm khóa đếm.
Hậu quả rất lặng lẽ. Kẻ tấn công chỉ cần đổi giá trị tiêu đề ở mỗi yêu cầu, mỗi yêu cầu lại trông như một địa chỉ IP mới, và giới hạn theo IP không bao giờ chạm ngưỡng. Một trăm lệnh gọi trả phí có thể đi qua, mà hệ thống vẫn tưởng đó là một trăm người khác nhau. Mã người dùng do máy khách tự gửi cũng là cùng một lỗi, chỉ khác tên gọi: người gọi chọn khóa, tức là người gọi chọn ngăn đếm.
Cách sửa thứ nhất: khóa đếm bằng địa chỉ người xem của CloudFront
Hãy đếm theo tiêu đề CloudFront-Viewer-Address. Tiêu đề này do CloudFront suy ra từ chính kết nối của người xem, gồm địa chỉ IP và cổng nguồn, ví dụ 198.51.100.10:46532. Khi chính sách yêu cầu gốc (origin request policy) của bạn chuyển tiếp tiêu đề này, CloudFront sẽ ghi đè mọi giá trị mà người xem tự gửi. Người gọi không thể tự chọn nó.
Có hai điều kiện đi kèm. Thứ nhất, tiêu đề phải tới được hàm Lambda của bạn. Đây là tiêu đề vị trí người xem, nên phải chuyển tiếp bằng origin request policy, không phải bằng chính sách bộ nhớ đệm (cache policy). Chính sách quản lý AllViewerExceptHostHeader chuyển tiếp mọi tiêu đề vị trí người xem và bỏ tiêu đề Host, đúng thứ mà API Gateway và Lambda cần. Hãy kiểm tra thực tế rằng tiêu đề có tới được hàm xử lý, và chặn ngay khi nó vắng mặt, thay vì cho qua.
Thứ hai, cắt bỏ cổng nguồn, và coi giá trị vắng mặt là không có khóa, chứ không phải là vé miễn phí. Đoạn mã khóa dưới đây lấy từ mã đang chạy trong thực tế:
export function ipKey(event: APIGatewayProxyEventV2): string | null {
const viewer = event.headers['cloudfront-viewer-address'];
if (viewer) return `ip:${viewer.replace(/:\d+$/, '')}`;
if (process.env.NODE_ENV !== 'production') return 'ip:dev';
return null;
}
Lưu ý rằng hàm này chỉ chặn chặt khi bạn đặt NODE_ENV thành production, vì Lambda không tự đặt biến này cho bạn. Một lưu ý nữa: tên tiêu đề đi tới Lambda ở dạng chữ thường trong gói tin HTTP API phiên bản 2, nên hãy đọc cloudfront-viewer-address viết thường.
Còn một kẽ hở: địa chỉ IPv6. Theo mặc định, CloudFront bật IPv6, và một máy có thể nắm nhiều địa chỉ cùng lúc, mỗi địa chỉ lại có một ngăn đếm riêng mà không cần giả mạo tiêu đề nào. Với người xem IPv6, hãy khóa theo tiền tố /64 hoặc rộng hơn, thay vì theo từng địa chỉ đầy đủ.
Lỗi thứ hai: địa chỉ API công khai là một cửa sau
Sửa xong khóa đếm vẫn chưa đủ, vì còn một lối vào khác. Địa chỉ mà API Gateway cấp cho API HTTP của bạn là địa chỉ công khai theo mặc định: ai có nó cũng gọi trực tiếp được. Bạn có thể tắt địa chỉ mặc định này, nhưng khi đó API chỉ trả lời trên một tên miền riêng, mà tên miền đó cũng truy cập được từ internet như thường. Tắt đi chỉ dời cửa sau sang chỗ khác, chứ không đóng nó.
Người gọi trực tiếp bỏ qua CloudFront hoàn toàn, nên có thể tự gửi bất kỳ giá trị CloudFront-Viewer-Address nào mình muốn. Khi đó cách sửa thứ nhất chỉ còn đúng với lưu lượng thật sự đi qua CloudFront. Đi thẳng tới API, tiêu đề địa chỉ người xem lại do người gọi kiểm soát, và bạn quay về lỗi thứ nhất.
Địa chỉ IP nguồn trong sự kiện Lambda cũng không cứu được bạn ở đây: đó là địa chỉ của kết nối TCP gần nhất tới API Gateway, tức là nút biên của CloudFront, không phải người xem. Cho phép toàn bộ dải địa chỉ của CloudFront thì cũng chỉ là cho phép mọi trang web dùng CloudFront, chứ không phải chỉ trang của bạn.
Cách sửa thứ hai: khóa cổng nguồn bằng một tiêu đề bí mật
Hãy khóa API chỉ chấp nhận lưu lượng đi qua CloudFront, bằng một bí mật mà chỉ CloudFront mới trình ra được. Cách làm: cấu hình một tiêu đề gốc tùy chỉnh, ví dụ X-Origin-Verify, mang một giá trị ngẫu nhiên dài, gắn trên nguồn gốc API. CloudFront thêm tiêu đề này vào mọi yêu cầu gửi tới nguồn gốc và ghi đè tiêu đề cùng tên mà người xem gửi. Người gọi thẳng tới API cũng gửi được tiêu đề này, nhưng không biết giá trị bí mật. Đây là mẫu mà AWS mô tả để ngăn người gọi đi vòng qua CloudFront: dùng HTTPS ở cả hai chặng, và xoay giá trị định kỳ, trong lúc chuyển đổi thì chấp nhận cả giá trị cũ lẫn mới.
Hàm Lambda so sánh tiêu đề với bí mật kỳ vọng trong thời gian hằng số, và từ chối mọi thứ khác. Phép so sánh thời gian hằng số sẽ báo lỗi nếu hai vùng đệm khác độ dài, nên phải kiểm tra độ dài trước:
function viaCloudFront(event: APIGatewayProxyEventV2): boolean {
const expected = process.env.ORIGIN_VERIFY;
if (expected === undefined) return true;
if (expected === '') return false;
const sent = event.headers?.['x-origin-verify'];
if (typeof sent !== 'string') return false;
const a = Buffer.from(sent, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
Hai quy tắc vận hành đi kèm. Bí mật rỗng nghĩa là cấu hình sai, phải từ chối. Bí mật chưa đặt nghĩa là đang chạy local hoặc kiểm thử, nên hàm trả về cho qua: hãy chắc chắn mọi ngăn xếp đã triển khai đều đặt biến này.
Việc từ chối dùng mã 401, không phải 403, cũng có lý do. Một lệnh gọi trực tiếp không qua CloudFront sẽ nhận đúng mã mà API trả. Nhưng một yêu cầu đi qua CloudFront vẫn có thể trượt kiểm tra, ví dụ đang giữa đợt xoay bí mật, và phản hồi lỗi tùy chỉnh của CloudFront áp cho toàn bộ trang, không áp theo từng hành vi. Trang mẫu trong bài gốc ánh xạ 403 thành trang 404 của nó, nên một lỗi 403 từ API sẽ tới máy khách dưới dạng trang HTML đó, kèm mã 404. CloudFront chỉ có thể ánh xạ lại một số mã nhất định, trong đó không có 401 và 429, nên API từ chối bằng 401 và chặn giới hạn bằng 429 để chúng đi xuyên qua không bị đổi dạng.
Với API REST, bạn còn có thêm lựa chọn là kiểm tra tiêu đề trong một quy tắc tường lửa ứng dụng web. Các lựa chọn khác gồm xác thực lẫn nhau TLS ở tầng gốc tới một tên miền tùy chỉnh có yêu cầu xác thực, hoặc dùng địa chỉ hàm Lambda sau CloudFront với kiểm soát truy cập gốc. Với API HTTP, tiêu đề khóa là cách đơn giản nhất.
Lỗi thứ ba: bộ đếm vượt giới hạn trong cơn bùng nổ
Khóa đúng, cửa đóng, bộ đếm vẫn có thể vượt giới hạn, theo hai cách.
Cách thứ nhất là đếm trong bộ nhớ. Trên Lambda tiêu chuẩn, mỗi yêu cầu đồng thời nhận một môi trường thực thi riêng, và mỗi môi trường xử lý một yêu cầu tại một thời điểm. Một bảng băm trong phạm vi mô-đun vì vậy chỉ tồn tại theo từng container. Với N container đang ấm, giới hạn hiệu dụng của bạn ít nhất gấp N lần mức đã đặt. Mỗi lần đếm lại đặt lại khi Lambda thay container, chuyện xảy ra vài giờ một lần ngay cả khi lưu lượng ổn định.
Cách thứ hai tinh vi hơn: ngay cả với kho lưu trữ chung, việc đọc rồi quyết định rồi mới ghi vẫn để lại một kẽ hở. Nhiều yêu cầu cùng đọc thấy "chưa chạm giới hạn" trước khi bất kỳ lần ghi nào xảy ra. Tất cả đều qua kiểm tra, tất cả đều thực hiện lệnh gọi trả phí. Giới hạn vượt đúng bằng số yêu cầu chen vào kẽ hở đó.
// KHÔNG LÀM THẾ NÀY: hai bước có kẽ hở ở giữa.
const used = await countFor(key); // đọc
if (used >= CAP) return capped();
await model.invoke(prompt); // lệnh gọi trả phí
await increment(key); // ghi, sau khi đã gọi
Cách sửa thứ ba: một lệnh cập nhật có điều kiện duy nhất
Hãy gộp đọc, kiểm tra và tăng đếm thành một lệnh UpdateItem có điều kiện duy nhất trong DynamoDB. DynamoDB áp dụng các lần ghi lên một mục theo thứ tự nhận được. Nó đánh giá điều kiện dựa trên phiên bản mới nhất của mục. Nhờ vậy, các lệnh gọi đồng thời xếp hàng trên cùng một mục thay vì đua nhau.
await ddb.send(new UpdateItemCommand({
TableName: table,
Key: { k: { S: key } },
UpdateExpression: 'ADD #n :one SET expiresAt = if_not_exists(expiresAt, :ttl)',
ConditionExpression: 'attribute_not_exists(#n) OR #n < :cap',
ExpressionAttributeNames: { '#n': 'n' },
ExpressionAttributeValues: {
':one': { N: '1' }, ':cap': { N: String(cap) }, ':ttl': { N: String(ttl) },
},
ReturnValues: 'UPDATED_NEW',
}));
// ConditionalCheckFailedException nghĩa là đã chạm giới hạn, trả về 429.
Phép ADD trên một số sẽ cộng vào giá trị hiện tại, hoặc tạo thuộc tính với giá trị đó khi mục mới. if_not_exists chỉ đặt thời gian hết hạn ở lần gọi đầu tiên. Khi số đếm đã chạm giới hạn, điều kiện sai, DynamoDB ném ngoại lệ ConditionalCheckFailedException, và hàm xử lý trả về 429. Không cơn bùng nổ nào vượt qua được giới hạn.
Ba giới hạn còn lại cần nhớ. Lệnh cập nhật này không có tính lũy đẳng. Nếu lệnh đã ghi nhưng phản hồi bị mất, bộ SDK của AWS có thể thử lại và đếm lệnh đó hai lần. Khi đó giới hạn sẽ lệch về hướng chặn sớm. Nó đếm số lệnh gọi, không đếm số tiền: hãy giới hạn chi phí của mỗi lệnh gọi, ví dụ số token đầu ra và kích thước đầu vào, hoặc giữ trước trường hợp xấu nhất rồi quyết toán sau. Và nó giới hạn theo từng địa chỉ, không giới hạn tổng: nhiều địa chỉ thật nghĩa là nhiều ngăn đếm, nên hãy giữ thêm một trần tổng theo ngày phía sau nó. Cuối cùng, vì lý do riêng tư, chỉ lưu trữ hàm băm có khóa của địa chỉ IP, không lưu địa chỉ thật, kèm thời gian hết hạn để DynamoDB tự xóa dòng.
Danh sách kiểm tra trước khi triển khai
- Đếm theo
CloudFront-Viewer-Address, đã cắt cổng, IPv6 theo tiền tố /64. Không bao giờ dùngX-Forwarded-Forhay mã người dùng do máy khách gửi. - Chuyển tiếp tiêu đề bằng origin request policy, và chặn ngay khi tiêu đề vắng mặt.
- Khóa API vào CloudFront bằng tiêu đề gốc bí mật, so sánh trong thời gian hằng số, và kiểm tra rằng mọi ngăn xếp đã triển khai đều đặt bí mật này.
- Từ chối bằng mã mà CloudFront không ánh xạ lại, như 401 và 429, không dùng mã mà trang của bạn đã ánh xạ.
- Đếm bằng một lệnh cập nhật có điều kiện duy nhất, không dùng bộ đếm trong bộ nhớ hay đọc-rồi-ghi.
- Giới hạn chi phí mỗi lệnh gọi, và giữ một trần tổng theo ngày phía sau giới hạn theo địa chỉ.
- Chỉ lưu hàm băm có khóa của địa chỉ, kèm thời gian hết hạn.
Ba lỗi này tạo thành một chuỗi: sửa khóa đếm mà để hở cửa sau thì vô ích, đóng cửa sau mà đếm bằng cách đọc-rồi-ghi thì vẫn vượt giới hạn trong cơn bùng nổ. Chỉ khi cả ba chỗ đều được khóa, giới hạn miễn phí mới giữ đúng lời hứa của nó.
Nguồn chính: bài phân tích của tác giả gốc trên DEV Community, "Your LLM spend cap is bypassable". Tác giả cho biết mình đang xây dựng một bộ khởi đầu SaaS serverless trên AWS, hiện còn trong giai đoạn phát triển và chưa bán ra. Bài này trình bày lại ba lỗi và ba cách sửa theo bài gốc, bỏ phần giới thiệu sản phẩm và phần thử nghiệm trực tiếp của tác giả.