Một chuỗi vô hại trong ngôn ngữ này có thể là một câu lệnh trong ngôn ngữ khác. </script> chỉ là mấy ký tự bình thường với JSON — nhưng dán nó vào một trang HTML thì trình duyệt đọc nó như lệnh đóng thẻ. Vì thoát ký tự luôn là chuyện tương đối với người đọc kế tiếp: cách JSON tự bảo vệ mình không cứu được bạn khi bộ phân tích HTML mới là kẻ đọc trước. Đây đúng cái hình dạng của SQL injection ở bài trước — dữ liệu vượt biên vào một ngữ cảnh đọc nó như cú pháp — và nó là phần quan trọng nhất của bài hôm nay.

Jackson là thư viện JSON mặc định của hệ sinh thái Java — Spring Boot dùng nó, và phần lớn dự án cũng vậy. Bài này gói bốn thứ dùng hằng ngày, và một cái bẫy bảo mật tôi từng gặp trong mã thật.

record ánh xạ không cần chú thích

record Nguoi(String ten, int tuoi, List<String> soDienThoai) {}
  ghi : {"ten":"Minh","tuoi":30,"soDienThoai":["090","091"]}
  đọc : Nguoi[ten=Minh, tuoi=30, soDienThoai=[090, 091]]

Không @JsonCreator, không @JsonProperty, không hàm khởi tạo rỗng. Jackson hỗ trợ record từ phiên bản 2.12, và nó đọc tên thành phần trực tiếp từ metadata của lớp.

Đây là lý do record thay thế DTO viết tay gọn nhất — như bài về record đã nói. Với lớp thường, Jackson cần hàm khởi tạo không tham số cộng setter, hoặc chú thích tường minh.

Trường thiếu và trường thừa: hai hành vi khác nhau

Thiếu thì im lặng:

  thiếu tuổi : Nguoi[ten=Lan, tuoi=0, soDienThoai=null]

int về 0, kiểu tham chiếu về null. Không cảnh báo gì.

Đây là chỗ nguy hiểm hơn vẻ ngoài: nếu API bên kia đổi tên một trường, dữ liệu của bạn im lặng thành 0 hoặc null, và lỗi hiện ra ở đâu đó rất xa. Với những trường bắt buộc, hãy kiểm tra trong hàm khởi tạo rút gọn của record:

record Nguoi(String ten, int tuoi) {
    Nguoi { Objects.requireNonNull(ten, "thiếu trường 'ten' trong JSON"); }
}

Thừa thì ném lỗi:

  mặc định : UnrecognizedPropertyException
  tắt kiểm : Nguoi[ten=Lan, tuoi=5, soDienThoai=null]

Mặc định Jackson từ chối JSON có trường lạ. Tắt bằng:

new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

Chọn cái nào? Với API bên ngoài mà bạn không kiểm soát, hãy tắt — bên kia thêm trường mới là chuyện bình thường và không nên làm gãy hệ thống của bạn. Với cấu hình nội bộ, hãy giữ bật — trường lạ thường là gõ nhầm, và báo lỗi sớm tốt hơn im lặng bỏ qua.

Spring Boot mặc định tắt kiểm tra này, nên nhiều người không biết nó tồn tại.

Ngày giờ cần module riêng

  không module : InvalidDefinitionException
  có module    : {"ma":"D1","taoLuc":"2026-06-30T10:15:30Z","ngaySinh":"1995-03-15"}

Jackson lõi không biết java.time. Phải đăng ký module:

new ObjectMapper()
    .registerModule(new JavaTimeModule())
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

Dòng thứ hai quan trọng: không có nó, Instant được ghi thành một con số dấu phẩy động (1782123330.000000000) thay vì chuỗi ISO-8601. Con số đó vẫn đọc lại được bằng Jackson, nhưng bất kỳ hệ thống nào khác nhìn vào cũng bó tay.

Spring Boot đăng ký sẵn module này, nên bạn chỉ gặp lỗi khi tự tạo ObjectMapper.

Đổi tên trường

record CoTenKhac(@JsonProperty("full_name") String hoTen,
                 @JsonProperty("is_active") boolean hoatDong) {}
  {"full_name":"Nguyễn Văn A","is_active":true}

Rất hay cần khi API bên ngoài dùng snake_case còn Java dùng camelCase. Đổi cho cả lớp thì dùng @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class), hoặc đặt mặc định trên ObjectMapper.

Cái bẫy: nhúng JSON vào thẻ <script>

Đây là phần quan trọng nhất của bài.

var doc = new Nguoi("</script><script>alert(1)</script>", 1, List.of());
m.writeValueAsString(doc);
  Jackson mặc định : {"ten":"</script><script>alert(1)</script>","tuoi":1,...}

Jackson không thoát <, > và & — và nó đúng, vì chúng hợp lệ trong JSON.

Nhưng nếu bạn dán chuỗi này vào một trang HTML:

<script>
  const duLieu = {"ten":"</script><script>alert(1)</script>", ...};
</script>

Trình duyệt gặp </script> bên trong chuỗi và đóng thẻ ngay tại đó — vì bộ phân tích HTML chạy trước bộ phân tích JavaScript và nó không biết gì về chuỗi JSON. Phần còn lại trở thành một thẻ script mới, và mã của kẻ tấn công chạy.

Đây là XSS lưu trữ, và nó không cần gì đặc biệt: chỉ cần một trường dữ liệu do người dùng nhập được hiển thị lại.

Cách chặn là thoát ba ký tự đó thành dạng unicode:

mapper.getFactory().setCharacterEscapes(new HtmlEscapes());
  có escape HTML : {"ten":"</script><script>alert(1)</script>",...}

< vẫn là dấu < với bộ phân tích JavaScript, nên dữ liệu không đổi. Nhưng bộ phân tích HTML không nhận ra nó là thẻ, nên script không bị đóng sớm.

Quy tắc: JSON nhúng vào HTML phải thoát <, >, &. JSON trả về qua HTTP với Content-Type: application/json thì không cần — trình duyệt không phân tích nó như HTML. Chỉ cần cẩn thận đúng ở chỗ chèn vào trang.

Cùng nguyên tắc áp dụng cho JSON-LD trong thẻ <script type="application/ld+json"> — một chỗ rất hay gặp khi làm SEO.

Đọc cây khi không có lớp

JsonNode cay = m.readTree(json);
cay.at("/a/b/1").asInt();
  at("/a/b/1")   : 2
  thiếu đường dẫn: '' (không NPE)

readTree trả về một cây JsonNode, và at(...) dùng cú pháp JSON Pointer để đi sâu.

Điểm hay: đường dẫn không tồn tại trả về một node rỗng, không ném NPE. asText() cho chuỗi rỗng, asInt() cho 0. Rất tiện khi xử lý JSON có cấu trúc không cố định.

Nhưng đó cũng là bẫy: dữ liệu thiếu và dữ liệu bằng 0 trông giống nhau. Cần phân biệt thì dùng cay.at(...).isMissingNode().

Vài mẹo dùng hằng ngày

@JsonIgnore bỏ một trường khi ghi. Nhớ dùng cho mật khẩu, token — đúng nguyên tắc ở bài toString.

@JsonInclude(NON_NULL) bỏ hẳn các trường null khỏi JSON, làm payload gọn hơn nhiều.

ObjectMapper an toàn luồng sau khi cấu hình xong — hãy tạo một cái dùng chung, đừng new trong mỗi request. Tạo mới mỗi lần là một lỗi hiệu năng thường gặp.

readValue(json, new TypeReference<List<Nguoi>>() {}) cho kiểu generic — đúng mẫu type token đã nói ở bài xoá kiểu.

Và một cảnh báo nhắc lại từ bài serialization: đừng bật enableDefaultTyping với dữ liệu không tin cậy. Nó ghi tên lớp vào JSON rồi tạo đối tượng theo tên đó lúc đọc — tái tạo đúng lỗ hổng của Java serialization.

Muốn tự thấy cái bẫy chính trong ba mươi giây: ghi một đối tượng có trường chứa chuỗi </script> ra JSON, rồi dán kết quả vào một tệp HTML giữa hai thẻ <script>. Mở bằng trình duyệt và xem mã nguồn đã render — bạn sẽ thấy thẻ bị đóng sớm, và hiểu vì sao mọi trang hiển thị nội dung do người dùng nhập đều cần đề phòng chỗ này.

Mẫu số chung

Cái bẫy "hợp lệ trong JSON, thành cú pháp trong HTML" không phải lỗi của Jackson, cũng không phải của Java — nó có ở chính ngôn ngữ đẻ ra JSON. JSON.stringify của JavaScript cũng không thoát <, > cho HTML, nên nhét kết quả vào một thẻ <script> server-side là đúng cái XSS y hệt; cách chữa kinh điển là đổi </ thành <\/ — JavaScript hiểu hai dạng như nhau nên dữ liệu không đổi, còn trình phân tích HTML thì không còn thấy thẻ đóng. Python json.dumps cũng vậy — bản thân nó JSON đúng, nhưng để nhúng an toàn thì các khung như Django, Jinja có bộ lọc riêng thoát HTML. Đây là hệ quả của một quy tắc phổ quát: bộ tuần tự hoá chỉ thoát cho ngữ pháp của chính nó, không cho ngữ pháp bao quanh — nên an toàn luôn phụ thuộc nơi bạn chèn vào, chứ không phải bản thân chuỗi.

Còn phần ánh xạ record thì cũng là một mô hình chung, với cùng một cái bẫy trường-thiếu. Go dùng thẻ struct json:"full_name" để đổi tên, và trường thiếu về giá trị zero trong im lặng — đúng cái int về 0 của Jackson. Rust với serde và Python với pydantic đi xa hơn: pydantic kiểm tra và ném lỗi khi thiếu trường bắt buộc, thay vì lặng lẽ điền mặc định.

Điểm chung, và là thứ đáng mang theo, gồm hai vế. Vế bảo mật: thoát ký tự là tương đối với ngữ cảnh đọc kế tiếp — dữ liệu an toàn để trả qua HTTP dạng application/json chưa chắc an toàn khi dán vào một thẻ <script>, vì bộ phân tích HTML đọc trước và không hiểu JSON; đây đúng là SQL injection đội lốt khác, và cách chống cũng đúng một tinh thần: thoát cho đích đến, không cho nguồn. Vế thiết kế: trường thiếu trong JSON cần một chính sách tường minh — mọi ngôn ngữ mặc định hoặc lặng lẽ điền giá trị zero, hoặc bắt bạn tự kiểm; đừng để một trường bắt buộc bị đổi tên âm thầm biến thành 0 rồi nổ ở nơi khác.

Ngày mai: java.time — chọn giữa Instant, LocalDate và ZonedDateTime, và vì sao nên lưu Instant còn hiển thị theo múi giờ người đọc.