1 февраля 2014 в 00:09

О безопасности в Meteor и не только (часть 2)

Если вас не испугала первая часть, предлагаю продолжить разговор о механизмах безопасности Meteor. Начав с loginToken, выдаваемого клиенту, правил allow/deny при модификации базы данных клиентом, коснемся доверенного и недоверенного кода, серверных методов, использования HTTPS и пакета force-ssl, пакета browser-policy (Content Security Policy и X-Frame-Options), и закончим встроенным механизмом валидации данных (функция check() и пакет audit-arguments-check).

loginToken


После авторизации клиент получает временный токен, авторизующий текущего пользователя, который сохраняется в localStorage:
> localStorage.getItem("Meteor.loginToken")
  "eEg4T3fNPGLns7MfY"

Строго говоря, сохраняется он в объекте Meteor._locaStorage, который является оберткой window.localStorage для поддерживающих его браузеров.
Также можно узнать этот токен и через объект Accounts:
Accounts._storedLoginToken()

Этот же токен сохраняется на сервере в коллекции Meteor.users:
> Meteor.user().services.resume
  {
    "loginTokens": [  {
          "token":"DXC3BqekpPy97fmYs",
          "when":"2014-01-31T10:53:54.347Z"
        } ]
  }

Разумеется, в консоли браузера это поле доступно только в том случае, если оно явно опубликовано.

Любой браузер, у которого есть пара токен + идентификатор пользователя, считается авторизованным. Чтобы в этом убедиться, можно залогиниться в браузере и получить текущие loginToken и userId:
localStorage.getItem("Meteor.loginToken");
localStorage.getItem("Meteor.userId");

Затем установить их в другом браузере:
localStorage.setItem("Meteor.loginToken", "'+loginToken+'");
localStorage.setItem("Meteor.userId", "'+userId+'");

И через несколько мгновений сессия браузера будет авторизована.

Время жизни токена

Токен существует до момента выхода пользователя из системы, либо истечения таймаута, задаваемого параметром (по умолчанию — 60 дней):
Accounts.config({loginExpirationInDays: 60})


Ограничение прав клиента на изменение коллекции — правила allow/deny


Если мы попробуем изменить поддокумент services, сделать это из браузера не получится:
> Meteor.users.update({ _id: Meteor.userId() }, {$set: { "services.test": "test" } })
  undefined
  update failed: Access denied 

Происходит это из-за того, что на сервере доступ к данному документу ограничен правилами allow/deny. Посмотрим, как этот механизм реализован в исходниках пакета accounts-base:
Meteor.users.allow({
  // clients can modify the profile field of their own document, and
  // nothing else.
  update: function (userId, user, fields, modifier) {
    // make sure it is our record
    if (user._id !== userId)
      return false;
    // user can only modify the 'profile' field. sets to multiple
    // sub-keys (eg profile.foo and profile.bar) are merged into entry
    // in the fields list.
    if (fields.length !== 1 || fields[0] !== 'profile')
      return false;
    return true;
  },
  fetch: ['_id'] // we only look at _id.
});

Из кода видно, что разрешены изменения только документа, userId которого совпадают с текущим пользователем, и можно вносить изменения только в поддокументе profile. Параметр fetch сообщает Meteor, что для проверки полномочий не требуется получать модифицируемый документ целиком (он может быть большим), для проверки достаточно только одного поля _id. Так как правило allow объялвено только для операции update, операции insert и remove для клиента запрещены:
> Meteor.users.insert({})
  "qs8HbcSDjgbgb3vgS"
  insert failed: Access denied. No allow validators set on restricted collection for method 'insert'.

Правилом deny можно запретить операцию, разрешенную allow. Т.е.если одно из правил allow (может задаваться более одного правила) вернуло true, то это разрешение может быть перекрыто, если одно из правил deny вернет true, и запись в этом случае будет запрещена, несмотря на правила allow.

Проверка прав доступа к поддокументам

С операцией update возможности проверки несколько ограничены. Например, если необходимо запретить запись в какое-либо поле поддокумента, например, doc.field1, но разрешить в другое его поле, например, doc.field2 нашей коллекции test, сделать это просто так не получится. Посмотрим, какие параметры передаются в этом случае в правило, добавив на сервере вывод входных параметров правил allow и deny:
Test.allow({
  update: function (userId, document, fields, modifier) {
    console.log('Test.allow(): userId:', userId, '; document:', document, '; fields:', fields, '; modifier:' , modifier);
    return true;
  }
});

Test.deny({
  update: function (userId, document, fields, modifier) {
    console.log('Test.deny(): userId:', userId, '; document:', document, '; fields:', fields, '; modifier:' , modifier);
    return false;
  }
});           

И выполним операцию update для поля doc.field1, предварительно узнав _id одного из документов (убедитесь, что у коллекции Test нужные поля опубликованы, задав в коде нашего примера переменную projection = {}, иначе результат не будет виден):
> Test.findOne({_id: "FG7FaQqYgB7Rs9RDy"})
  Object {_id: "FG7FaQqYgB7Rs9RDy", name: "First", value: 1}
> Test.update({_id:"FG7FaQqYgB7Rs9RDy"}, { $set: { "doc.field1": "value1" } } )
  undefined
> Test.findOne({_id: "FG7FaQqYgB7Rs9RDy"})
    Object {_id: "FG7FaQqYgB7Rs9RDy", name: "First", value: 1, doc: Object}
> Test.findOne({_id: "FG7FaQqYgB7Rs9RDy"}).doc.field1
  "value1"

В логе сервера будет выведено:
I20140131-13:31:27.582(4)? Test.deny(): userId: kL7Fkuk29ci4vz8q4 ; document: { _id: 'FG7FaQqYgB7Rs9RDy', name: 'First', value: 1 } ; fields: [ 'doc' ] ; modifier: { '$set': { 'doc.field1': 'value1' } }
I20140131-13:31:27.582(4)? Test.allow(): userId: kL7Fkuk29ci4vz8q4 ; document: { _id: 'FG7FaQqYgB7Rs9RDy', name: 'First', value: 1 } ; fields: [ 'doc' ] ; modifier: { '$set': { 'doc.field1': 'value1' } }

В параметре fields передается пассив полей только самого вернего уровня, т.е.на основании него определить права доступа к полю doc (и всем его поддокументам), но применить разные права к полям doc.field1 и doc.field на основании этого массива невозможно. Для этого можно использовать параметр modifier, в котором передается объект, содержащий операцию MongoDb, и, чтобы не проводить полный анализ операции, разрешить только какой-то жесткий его формат и запрещая все его остальные варианты как-то так:
Test.allow({
  update: function (userId, user, fields, modifier) {
    console.log('Test.allow(): userId:', userId, '; document:', document, '; fields:', fields, '; modifier:' , modifier);
    var setData = modifier["$set"];
    return setData && Object.keys(setData).length===1 && setData["doc.field1"];
  }
});

Разумеется, работают правила allow/deny только если пакет insecure убран из проекта. К слову, эти обработчики также можно использовать для на стороне сервера изменений, производимых клиентом

Доверенный и недоверенный код


До сих пор мы изменяли запись по её идентификатору. Дело в том, что клиент не может выполнить операцию update, указывая в селекторе запроса что-либо другое, например:
Test.update({ value: 1 }, { $set: { "doc.field1": "value1" } } )
Error: Not permitted. Untrusted code may only update documents by ID. [403]

Происходит это из-за того, что Meteor разделяет доверенный и недоверенный код. Доверенным считается код, выполняемый на сервере, включая серверные методы, вызываемые клиентом. Недоверенный — код, выполняемый на стороне клиента в браузере.
Недоверенному коду разрешена модификация документов только по одному, с указанием _id документа и проверкой правил allow/deny. Также ему запрещена операция upsert (вставка документа при его отстутсвии). Операция remove аналогичным образом может быть применена только к отдельному документу, с указанием его _id. Подробнее см документацию docs.meteor.com/#update и docs.meteor.com/#remove.

Серверные методы


В качестве альтернативы прямому доступу клиента к базе данных можно использовать серверные методы. Так как код, выполняемый на сервере, считается доверенным, можно логику критичных операций разместить на сервере, запретив изменения соответствующих коллекций на клиенте. Например, добавим на сервере:
Meteor.startup(function() {
  Meteor.methods({
    testMethod: function(data) {
      console.log('testMethod(): data:', data);
      return 'testMethod finished (data:',data,')';
    }
  });
});

И вызовем со стороны клиента, передав последним параметром callback, вызываемые при завершении выполнения метода:
> Meteor.call('testMethod', 'test data', function(err, result) {console.log(err, result);})
  undefined
  undefined "testMethod finished (data:test data)"


HTTPS и пакет force-ssl


Сам по себе Meteor не включает поддержки HTTPS, и для него необходим промежуточный сервер, терминирующий SSL, на котором размещается сертификат. Встроенный пакет force-ssl позволяет перенаправить подключение по протоколу HTTP на HTTPS URL, за исключение подключений с localhost.
При использовании Nginx в этом пакете нет необходимости, так как перенаправление можно реализовать следующим образом
(пример настройки Nginx на localhost в качестве прокси для Meteor, включая генерацию автоподписанного сертификата)
Сгенерировать ключ и сертификат

$ openssl genrsa -des3 -out localhost.key 1024
$ openssl req -new -key localhost.key -out localhost.csr
Common Name (eg, YOUR name) :localhost
$ openssl x509 -req -days 1024 -in localhost.csr -signkey localhost.key -out localhost.crt

Скопируем сертификат и ключ в папку /etc/nginx/ssl
$ mkdir /etc/nginx/ssl
$ cp ./localhost.key /etc/nginx/ssl
$ cp ./localhost.crt /etc/nginx/ssl

Конфигурация Nginx

Создать файл /etc/nginx/sites-available/meteor.conf (если Nginx ставится «с нуля», необходимо удалить или перенастроить расположенный в том же каталоге файл default, в котором прописаны те же порты):
server {
  listen 80;
  server_name localhost;
  # $scheme will get the http protocol
  # and 301 is best practice for tablet, phone, desktop and seo
  # return 301 $scheme://example.com$request_uri;
  # We want to redirect people to the https site when they come to the http site.
  return 301 https://localhost$request_uri;
}

server {
  listen   443;
  server_name localhost;
  client_max_body_size 500M;

  access_log /var/log/nginx/meteorapp.access.log;
  error_log /var/log/nginx/meteorapp.error.log;

  location / {
    proxy_pass http://localhost:3000;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
  }

  ssl on;
  ssl_certificate /etc/nginx/ssl/localhost.crt;
  ssl_certificate_key /etc/nginx/ssl/localhost.key;
  ssl_verify_depth 3;
}

Создать ссылку:
ln -s /etc/nginx/sites-available/meteor.conf /etc/nginx/sites-enabled/meteor.conf

Рестартовать Nginx:
$ sudo service nginx restart



Пакет browser-policy, Content Security Policy и X-Frame-Options


Фактически за browser-policy скрывается два других пакета, каждый из которых может быть использован по отдельности, browser-policy-content и browser-policy-framing. Первый из них предоставляет интерфейс для определения правил Content Security Policy, с помощью которых задается белый список источники для загрузки различных типов ресурсов. Второй — параметра X-Frame-Origin, разрешающего отображать страницу внутри тегов frame или iframe, в зависимости от URI сайта, который пытаетcя это делать (на данный момент указание URI источника в X-Frame-Origin поддерживается только Firefox и IE 8+).
Добавление пакета включает политику по умолчанию, при этом загрузка контента разрешается только с того же сайта, что и сама страница, запросы XMLHTTPRequest и соединения WebSocket могут направляться на любый сайты. Кроме этого, блокируются функции типа eval() и приложение может быть включено в frame и iframe только тем же сайтом, с которого оно загружено.

При этом к заголовку ответа сервера при загрузке страницы добавляются следующие параметры:
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src * 'self'; img-src data: 'self'; style-src 'self' 'unsafe-inline';
x-frame-options: SAMEORIGIN

И, в нашем примере, изображения пользователя с внешних сайтов (Google и Facebook) перестанут отображаться со следующим сообщением в консоли:
Refused to load the image 'https://lh6.googleusercontent.com/-aCxpjiDMNcM/AAAAAAAAAAI/AAAAAAAAJMY/9hZytqLLZ6Q/photo.jpg' because it violates the following Content Security Policy directive: "img-src data: 'self'".

Чтобы изображения с внешних сайтов снова начали отображаться, необходимо на сервере добавить следующие строки:
Meteor.startup(function() {
  BrowserPolicy.content.allowImageOrigin("https://*.googleusercontent.com");
  BrowserPolicy.content.allowImageOrigin("http://profile.ak.fbcdn.net");
  BrowserPolicy.content.allowImageOrigin("http://graph.facebook.com");
});

При этом заголовок станет выглядеть так:
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src * 'self'; img-src data: 'self' https://*.googleusercontent.com http://profile.ak.fbcdn.net http://graph.facebook.com; style-src 'self' 'unsafe-inline';
x-frame-options: SAMEORIGIN

В дополнение к ограничениям, установленным по умолчанию, в документации Meteor рекомендуется запрещать выполнение inline Javascript на странице, вызвав BrowserPolicy.content.disallowInlineScripts() на стороне сервера (конечно, если не используется inline Javascript).

Валидация данных: функция check() и пакет audit-arguments-check


В Meteor предусмотрен механизм валидации данных, передаваемых серверным методам и функциям publish. Для этого предназначна функция check(), которой передается проверяемое значние и шаблон для проверки. Шаблон может быть явным указанием типа, либо объектом Match, определяющим более сложные правила проверки (см.http://docs.meteor.com/#match)
Установка пакета audit-argument-checks блокирует выполнение методов и функций публикации, которым были переданы данные, не прошедшие валидации.
Если валидация не требуется, можно вызвать функцию check со следующим параметром
check(arguments, [Match.Any])

Добавим пакет
$ mrt add audit-argument-checks

Теперь попытка вызвать серверный метод вернет ошибку:
> Meteor.call('testMethod', 'test data', function(err, result) {console.log(err, result);})
  undefined
  errorClass {error: 500, reason: "Internal server error", details: undefined, message: "Internal server error [500]", errorType:"Meteor.Error"…}
  undefined 

И на сервере:
 Exception while invoking method 'testMethod' Error: Did not check() all arguments during call to 'testMethod'

После добавления валидации в серверный метод он вновь начнет корректно отрабатывать:
  check(data, String);


Вместо заключения


Пытаясь поделиться своими наработками я совершенно не заметил, насколько большой по объему получился материал, при том, что по сути своей смог затронуть только очень и очень небольшую часть Meteor.
Надеюсь, этот текст поможет поближе познакомиться с Meteor и узнать о нем что-то новое.
Alexander Lykoshin @Alex_L
карма
9,0
рейтинг 0,0
Самое читаемое Разработка

Комментарии (8)

  • +1
    Хотелось бы поблагодарить автора. Хорошая статья. Побольше бы их по Метеору. Кстати, в песочнице лежит одна статья: http://habrahabr.ru/sandbox/77384/. Может кто-нибудь из проходящих поделится инвайтом. Я к данной статье отношения не имею, и сам бы поделился, но все свои раздал. (Сорри, за оффтоп.)
    • 0
      К сожалению, очень слабенькая статья из песочницы, не охватывает даже части официальной документации по iron-router.
      Нет описания хуков, подписок, хелперов и мн. другого.
      Лучше бы сделали перевод оф. дукемантации, там все достаточно понятно и практически нет необходимости писать отсебятину.
      github.com/EventedMind/iron-router
      • +1
        Статья простая, согласен. Но, как говорится, велика беда — начало. У меня тоже первая статья на хабре очень слабая была. Главное — желание писать. Тем более автор обещает продолжать.
  • +1
    Спасибо за статью. Очень рад популяризации этого фреймворка. Уже скоро год, как начали разрабатывать проект на Метеоре, не перестает радовать.
    Кроме официальных доков очень рекомендую www.eventedmind.com — большое количество скринкастов и туториалов от одного из главных активистов комьюнити.
    • +3
      Благодарю на добром слове. Я еще вот тут почитываю: meteorhacks.com/. Eventedmind ресурс действительно очень хороший, но лично я скринкасты плохо перевариваю, плюс излишняя коммерциализированность напрягает.
  • +1
    В дополнение к комментарию RedFox можно упомянуть книгу discovermeteor.com и отличный исходник HackerNews клона telesc.pe, от этого же автора.
  • 0
    К выше упомянутым ссылкам, хочу добавить еще одну очень полезную:
    Сборник ссылок на различные материалы по Meteor yauh.de/articles/376/best-learning-resources-for-meteorjs

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.