Статьи

Легкий выход JSON HTML / Javascript в Scalatra

 

Логотип-scalatraScalatra — это небольшая веб-инфраструктура, которая позволяет легко создавать Rest API для веб-приложений. Мы используем его в двух проектах для обслуживания данных JSON, используемых интерфейсами на основе AngularJS. И в одном из этих проектов у нас была проблема с текстовыми сообщениями, которые могли содержать элементы Html / Javascript. Пользователь может сохранить сообщение, а затем это сообщение может быть использовано на двух страницах. Во-первых, нам нужно отобразить экранированные данные (вид только для чтения), а во-вторых (страница редактирования) мы хотели отобразить сообщение точно так же, как его набрал пользователь.
Поэтому в основном нам нужен был простой и декларативный способ определить, какие конечные точки Scalatra Rest должны возвращать экранированные и не экранированные данные JSON.

Базовый проект Scalatra

Чтобы показать, как это можно сделать, нам нужен простой пример проекта с использованием Scalatra. Я не хотел создавать его с нуля, поэтому я удалил некоторые вещи, связанные с Swagger, из проекта моего коллеги Кшиштофа Цизельского ( его пост о Scalatra и Swagger ), и я был готов пойти :)Полная фиксация с этим базовым проектом доступна здесь , но самый интересный класс показан ниже:

class ExampleServlet() extends ScalatraServlet with JacksonJsonSupport with JValueResult {
 
  protected implicit val jsonFormats: Formats = DefaultFormats
 
  val messages = List(
    Message(1, "<script><alert>Script hacker</alert></script>", "Johny Hacker", new Date()),
    Message(2, "Decent message", "John Smith", new Date()),
    Message(3, "Message with <b>bolded fragment</b>", "Kate Norton", new Date())
  )
 
  before() {
    contentType = formats("json")
  }
 
  get("/") {
    messages
  }
 
  get("/:id") {
    val id = params("id").toInt;
    val messageOptional = messages.find((m: Message) => m.id == id)
 
    log("optional =" + messageOptional)
    messageOptional match {
      case Some(e) => e
      case _ =>
    }
  }
}
 
case class Message(id: Int, text: String, author: String, created: Date)

Хорошо, так что у нас здесь? Сервис Simple Rest, который возвращает JSON. Метод get без каких-либо параметров возвращает список сообщений, а метод get с переданным идентификатором возвращает одно сообщение с заданным идентификатором. Для простоты у меня есть жестко закодированный список из трех сообщений, два из которых содержат некоторые элементы Html / JS.

Определение Готово

Всегда хорошо иметь четкое определение Done, поэтому в нашем случае мы хотим, чтобы наши методы делали две вещи:

  • Get / rest / example без параметров должен возвращать список сообщений с экранированным содержимым
  • Get / rest / example / 1 (с параметром) должен возвращать отдельные сообщения без экранирования

Детали реализации

Чтобы добавить экранирование, нам нужно найти место в Scalatra, где объекты, возвращаемые методом, конвертируются в Json. После некоторых копаний я нашел черту JacksonJsonOutput с методом writeJon :

trait JacksonJsonOutput extends JsonOutput[JValue] with jackson.JsonMethods {
  protected def writeJson(json: JValue, writer: Writer) {
    mapper.writeValue(writer, json)
  }
}

Так что нам нужно переопределить метод и каким-то образом определить логику, чтобы выполнить фактическое экранирование для нас. К счастью, в JValue есть карта функций, которая позволяет применять переданную функцию к каждому значению в JSON. Таким образом, мы можем запустить метод escapeHtml4 из Apache Commons Lang для каждого значения String в JSON:

override def writeJson(json: JValue, writer: Writer) {
  val escapedJson = json.map((x: JValue) =>
    x match {     // let's check what type is this element
      case JString(y) => JString(escapeHtml4(y))    // if it is a String, escape it
      case _ => x        // keep value of other type as is
    }
  )
  mapper.writeValue(writer, escapedJson)
}

Как вы можете видеть, вся логика выполняется функцией map , нам нужно всего лишь добавить несколько строк кода, которые будут фильтровать и экранировать значения String в JSON. Пока все хорошо, но теперь мы избегаем всех методов из нашего Rest API. Это не совсем то, чего мы хотели достичь. Чтобы сделать это правильно, нам нужно определить механизм отключения экранирования для конкретного метода.

Обертывание маркерного объекта

Поэтому нам нужен маркер «что-то» (класс, интерфейс или признак), который сообщит методу writeJson, какие данные не следует экранировать.
И после попытки разных подходов оказалось, что единственное, что нам нужно, — это обернуть значение, возвращенное методом, в объект-маркер, а затем в writeJson проверить, обернуто ли оно или нет, и экранировать только не обернутые данные. Итак, рабочий процесс выглядит следующим образом:

  1. Если мы не хотим экранирования, оберните возвращаемый объект в NotEscapedJsonWrapper
  2. В writeJson проверьте, является ли объект для записи оболочкой. Если это так, только разверните его и напишите. Если это не оболочка, экранируйте все строки внутри объекта и обнуляйте данные после этого

Выглядит просто. Нам нужен простой класс-обёртка:

case class NotEscapedJsonWrapper[T](notEscapedData: T)

и нам нужно обернуть ответ этим объектом:

get("/:id") {
  val id = params("id").toInt;
  val messageOptional = messages.find((m: Message) => m.id == id)
 
  log("optional =" + messageOptional)
 
  messageOptional match {
    case Some(e) => NotEscapedJsonWrapper(e) // wrap data that we don't want to be escaped
    case _ =>
  }
}

И после этого мы должны изменить writeJson, чтобы проверить, обернут ли объект для записи маркером «not-escape-me» или нет.

override def writeJson(json: JValue, writer: Writer) {
  (json \ "notEscapedData") match {     // check if json contains field 'notEscapedData' meaning that it is wrapping object
    case JNothing => {                  // no wrapper, so we perform escaping
      val escapedJson = json.map((x: JValue) =>
        x match {
          case JString(y) => JString(escapeHtml4(y))
          case _ => x
        }
      )
      mapper.writeValue(writer, escapedJson)
    }
    case _ => {    // field 'notEscapedData' detected, unwrap and return clean object
      mapper.writeValue(writer, json \ "notEscapedData")
    }
  }
}

И после этого мы можем скомпилировать и запустить контейнер с нашим небольшим приложением. Когда мы введем / rest / example, мы получим список всех сообщений, в которых каждая строка должным образом экранирована. И если мы запросим / rest / example / 1, мы получим одно сообщение как не экранированные данные. Вот и все, у нас есть работа по побегу.

Резюме

В этом коротком посте я описал, как реализовать экранирование Html / Js в приложении на основе Scalatra довольно простым способом, который можно легко определить в одном общем месте без необходимости добавлять экранирование вручную в каждом методе, который нуждается в этом. Благодаря этому наше приложение намного безопаснее, и только явно объявленные методы могут возвращать потенциально небезопасные данные.
Исходный код этого небольшого примера приложения доступен на GitHub .