Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Документация

Песочница и capabilities

Приложение на Rust — это нативный код, скомпилированный в поставляющее его приложение так же, как и остальная его часть, и он работает с его уровнем доверия. Его вызовы сервисов NSH (fetch, изображения, веб-представления, строка состояния) проходят те же нативные проверки, что и вызовы из TSX, в рамках того, что разрешает хост. TSX и JavaScript, которые выполняет приложение, собственные или из загружаемого им подписанного пакета, работают в виртуальной машине QuickJS на каждое представление, а всё, что они могут делать за пределами этой VM, проходит через нативный рантайм NSH. Благодаря этому можно запускать код, который вы не писали, например сторонние мини-приложения или загружаемые обновления, по правилам, которые контролирует хост-приложение:

  • Лимиты ограничивают память, время и нативные ресурсы, доступные одному приложению.
  • Усиление защиты в релизных сборках убирает все способы превратить строки в код.
  • Capabilities перечисляют, до чего приложение может дотянуться: с каких хостов загружать данные, какие страницы может показывать веб-представление, может ли оно менять строку состояния. Приложение объявляет их сами; хост-приложение может выдать меньше.

Бандл содержит только код приложения: реактивный граф, рендерер и каждый импортируемый приложением модуль нативные. Поэтому ничто в JS не является доверенным, а каждый лимит и каждая проверка применяются в Rust; в JS нет кода, который приложение могло бы подменить, чтобы обойти их.

#Лимиты

Лимиты ограничивают VM: они действуют для TSX и JavaScript. Приложение, встроенное в другое приложение, работает в пределах лимитов, которые определяет его хост. Автономное приложение (упакованное с помощью nsh package, включая веб, или запущенное с помощью nsh run) — это ваш собственный код, как и нативное приложение: оно использует память и время, которые предоставляет устройство, а стек интерпретатора в 4096 КБ превращает глубокую рекурсию в RangeError, которую приложение может перехватить.

У встроенного приложения действуют следующие лимиты:

ЛимитПо умолчаниюЧто происходит при достижении
Куча JS256 МБVM останавливается.
Стек интерпретатора4096 КБ (это и максимум)Глубокая рекурсия выбрасывает RangeError, которую приложение может перехватить.
Время вычисления бандла10 сВычисление прерывается.
Время любого другого вызова в JS2 сВызов прерывается; приложение продолжает работу.
Хранимые узлы200 000VM останавливается.
Изменения за один раз (операции в очереди)8 000 000 значенийVM останавливается.
Операции в одной отрисовке canvas1 000 000VM останавливается.

Время ограничивается на каждый вызов, а не на приложение. «Вызов» — это одно действие, которое рантайм просит выполнить JS: обработать одно событие (включая вызванный им рендеринг), привязать одну строку списка, выполнить один таймер, выполнить колбэки requestAnimationFrame одного кадра, опустошить очередь микрозадач. Долго работающее приложение никогда не накапливает время в счёт лимита; прерывается только отдельный вызов, не вернувшийся вовремя. Вызовы, вложенные в другой вызов, разделяют бюджет внешнего.

Прерванный вызов завершается исключением, которое не может поглотить ни один catch, а об ошибке сообщается с указанием превышенного бюджета:

the script was interrupted: a call into JS exceeded its budget of 2000 ms

VM продолжает работать: следующее событие, таймер или кадр выполняются как обычно.

Нехватка памяти или превышение квоты останавливает VM. Об ошибке сообщается один раз (обработчику ошибок хост-приложения, а в сборках для разработки ещё и поверх приложения), после этого JS не выполняется, а представления остаются в прежнем состоянии. Квоты на узлы и операции существуют потому, что нативные представления и очередь изменений живут в нативной памяти, на которую лимит кучи JS не распространяется: без них приложение могло бы исчерпать память устройства через UI.

Хост-приложения задают лимиты для каждого представления: NshAppView.limits на iOS, NshView.setLimits на Android, AppOptions::limits / AppView::set_limits в приложении GPUI. Они применяются со следующей загрузки.

Чтобы увидеть в разработке, как TSX-приложение работает в рамках лимитов хост-приложения, запустите его с --limits: nsh run App.tsx --limits использует значения по умолчанию, указанные выше, а nsh run --limits memory=128MB,call=500ms задаёт эти два лимита так, как это делало бы хост-приложение, остальные при этом сохраняют значения по умолчанию.

#Усиление защиты

Релизные сборки (всё, что не загружается с dev-сервера) усиливаются при загрузке:

  • eval удаляется, а конструкторы Function, AsyncFunction, GeneratorFunction и AsyncGeneratorFunction выбрасывают исключение. NSH не выполняет код, созданный из строк:

    TypeError: the Function constructor is disabled: nsh runs no code from strings (development builds allow it)
  • Прототипы, общие для всех модулей (Object, Array, Function, String, Number, Promise, Map, типизированные массивы и остальные), заморожены, поэтому один модуль (или одна библиотека) не может изменить Array.prototype для всех остальных. Error.prototype остаётся доступным для записи, чтобы подклассы ошибок могли задавать this.name.

Заморозка прототипов обычно ломает распространённый паттерн, «override mistake»: присваивание Sub.prototype.constructor = Sub или obj.toString = … выбрасывает исключение, если унаследованное свойство заморожено. NSH превращает наиболее часто переопределяемые члены (constructor, toString, valueOf, toJSON, …) в аксессоры, сеттер которых определяет свойство на объекте, которому присваивают, поэтому такой код (и библиотеки вроде axios, которые его используют) продолжает работать.

Бандлы, которые отдают nsh dev / nsh run (с --release или без него), не усиливаются, поэтому их можно отлаживать с помощью eval. Проверяйте поведение релиза с помощью nsh test (он включает усиление) или упакованной сборки, прежде чем поставлять код, зависящий от динамического кода.

#Capabilities

Приложение объявляет, до чего оно может дотянуться за пределами своей VM, в nsh.json:

{
  "name": "Quotes",
  "id": "com.example.quotes",
  "version": "1.0.0",
  "build": 3,
  "capabilities": {
    "network": {
      "hosts": ["api.example.com", "*.cdn.example.com"],
      "http": [{ "host": "quotes.example.com", "methods": ["GET"], "pathPrefix": "/v1/" }],
      "sockets": ["feed.example.com:7000"]
    },
    "webview": { "hosts": ["*.example.com"], "input": true },
    "statusBar": true,
    "clipboard": true,
    "picker": true,
    "speech": true,
    "host": ["getUserInfo", "bridge.*"],
    "openUrl": { "schemes": ["https", "mailto"] }
  }
}

Приложению без "capabilities" разрешено всё: автономное приложение принадлежит его разработчику, и ничего не ограничивается. Сырые TCP-сокеты выдаются по имени в каждом приложении через network.sockets, а функции хоста — через host. Приложению с "capabilities" разрешено только то, что в нём перечислено: всё остальное запрещено. Неизвестные ключи — это ошибки сборки, поэтому опечатка обнаруживается при сборке, а не превращается в отсутствующее разрешение во время выполнения. Подписанный пакет от более новой версии NSH может объявлять capabilities, которых более старый рантайм не знает: в нём они не выдаются.

#network

Охватывает fetch (включая каждый редирект), WebSocket, удалённые изображения (<Image>, placeholder, изображения canvas, Image.getSize / Image.prefetch), источники видео и сырые TCP-сокеты (net.connect).

КлючТипОписание
hostsstring[]Хосты, к которым приложение может обращаться любым методом и по любой схеме (http, https, ws, wss).
httpobject[]Более точные разрешения: хост, суженный любым из ключей ниже.
http[].hoststringХост или шаблон хоста (обязательно).
http[].methodsstring[]Разрешённые методы, например ["GET", "HEAD"]. Если не указано: любые.
http[].schemesstring[]Разрешённые схемы (scheme: одна, в виде строки). Если не указано: любая из http, https, ws, wss.
http[].portnumberПорт; у URL без порта используется порт по умолчанию для его схемы (443 для https). Если не указано: любой.
http[].pathsstring[]Точные пути, например ["/v1/quote"] (запрос не входит в путь).
http[].pathPrefixesstring[]Префиксы путей, охватывающие целые сегменты: /v1 охватывает /v1 и /v1/quote, /v1/ — всё, что следует за ним. pathPrefix (один, в виде строки) и path_prefixes читаются одинаково.
socketsstring[]Адреса назначения для сырых TCP-соединений через net.connect, каждый — точная пара "host:port" ("[::1]:9000" для IPv6).

Правило http охватывает все пути, если в нём не указаны paths и pathPrefixes, а если указаны оба — путь, подходящий под любой из них.

Шаблоны хостов: * (любой хост), *.example.com (любой поддомен example.com, но не сам example.com) или точное имя хоста. При сопоставлении регистр и порт игнорируются. Хост берётся после последнего @, поэтому https://api.example.com@evil.io/ проверяется как evil.io.

Рукопожатие WebSocket — это HTTP GET: ws:// проверяется как http://, а wss:// — как https://, поэтому разрешения в hosts и http, допускающие GET для этого URL, допускают и WebSocket. Сырой сокет обходит все HTTP-правила, поэтому sockets перечисляет каждый хост и порт точно, и разрешает их только sockets.

URI asset: (собственные файлы приложения) разрешены всегда; изображения и видео могут также использовать URI data: и относительные пути.

#webview

КлючТипОписание
hostsstring[]Хосты, которые может показывать веб-представление и на которые оно может переходить (http и https). Без webview приложение вообще не может показывать веб-страницы.
inputbooleanRef веб-представления может передавать своей странице доверенный ввод (sendInput). Никогда не выдаётся, если не объявлено, в том числе для приложения без "capabilities".

Переходы верхнего уровня внутри веб-представления тоже проверяются делегатом навигации платформы до загрузки. Собственные подресурсы страницы (её скрипты, изображения, XHR) — дело веб-контента и не проверяются. about:blank и встроенный HTML (html) требуют лишь того, чтобы веб-представления были разрешены вообще: "webview": { "hosts": [] } допускает их, не допуская ни одной удалённой страницы.

input отделён от hosts, потому что доверенный ввод несёт пользовательскую активацию, которой injectJavaScript никогда не получает: тогда страница может открывать окна, записывать в буфер обмена, открывать системную панель выбора файлов, запускать WebAuthn или запросы разрешений и нажимать любые кнопки, проверяющие isTrusted. Без него ядро отбрасывает sendInput и записывает в лог, какой ключ нужно добавить.

#statusBar

"statusBar": true позволяет приложению менять строку состояния (statusBar страниц и диалогов в nsh/base и значение по умолчанию "statusBar" у nsh.json).

#clipboard

"clipboard": true позволяет приложению читать и записывать системный буфер обмена (Clipboard). Копирование из меню редактирования выделяемого текста — собственное действие пользователя и не требует разрешения.

#picker

"picker": true позволяет приложению показывать системный выбор фотографий (Picker): пользователь сам выбирает, что попадёт в приложение.

#speech

"speech": true позволяет приложению распознавать речь пользователя (Speech): микрофон и системный распознаватель речи, транскрипция которого попадает в приложение. Система по-прежнему один раз спрашивает пользователя.

#openUrl

Позволяет приложению открывать URL за его пределами (openUrl, ссылки TextView с links="open"): "openUrl": true для всех схем, "openUrl": { "schemes": ["https", "mailto"] } для перечисленных схем. URL, схема которого не разрешена, отклоняется с ERR_ACCESS_DENIED (permission "openUrl", resource — URL). URL, которые выполняли бы код или обращались бы к собственным данным приложения (javascript:, data:, file:, …), никогда не открываются, независимо от разрешения.

#Storage

Каждое приложение может хранить свои данные на устройстве с помощью Storage без какого-либо разрешения: хранилище содержит только данные этого приложения, а место хранения определяет встраивающее приложение.

#host

host перечисляет функции приложения, встраивающего NSH, которые может вызывать host.call(name, args) из nsh: точные имена ("getUserInfo") или префиксы, оканчивающиеся на * ("bridge.*" соответствует bridge.call и bridge.share). "host": true разрешает все функции. Функция, которой нет в списке, отклоняется, в том числе в приложении без "capabilities": функции хоста никогда не выдаются по умолчанию, а сборка приложения, импортирующего host из nsh без host, предупреждает, что каждый вызов будет отклонён. Имена содержат от 1 до 128 символов из A–Z, a–z, 0–9, _, ., : и -, без ..; вызов любого другого имени отклоняется до чтения разрешений. Оканчивайте префикс разделителем ("bridge.*"): "bridge*" соответствует и bridgeAdmin. События хоста (host.on(event, listener)) доходят до слушателей приложения в том виде, в каком их отправляет хост.

import { host } from "nsh";

if (host.available()) {
  const user = await host.call("getUserInfo");
  const stop = host.on("loginChanged", (payload) => console.log(payload));
  stop();
}

host.available() равно true на платформах, встраивающих хост; на остальных host.call отклоняется с code "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM".

#Как выглядит отказ

ЗапросРезультат
fetchПромис отклоняется с TypeError, как при любом неудачном запросе; его code равно ERR_ACCESS_DENIED, permission "network" и resource URL (запроса или редиректа); запрос никогда не доходит до сети.
Image.getSize, Image.prefetchПромис отклоняется с ERR_ACCESS_DENIED, permission "network" и resource URL; изображение не запрашивается.
Удалённое изображение, источник видео, источник web view или htmlНичего не показывается, а onError элемента сообщает об этом с code "ERR_ACCESS_DENIED". Переход web view на хост, к которому нет доступа, отменяется и сообщается так же.
Изменение Status bar, изображение на canvasНичего не меняется и не рисуется; отказ записывается в лог.
ClipboardКаждая функция Clipboard отклоняется с ERR_ACCESS_DENIED, permission "clipboard" и resource ""; системный буфер обмена никогда не затрагивается.
PickerpickImages ничего не показывает и отклоняется с ERR_ACCESS_DENIED, permission "picker" и resource "".
SpeechSpeech.start выбрасывает ERR_ACCESS_DENIED, permission "speech" и resource ""; микрофон остаётся выключенным.
openUrlПромис отклоняется с ERR_ACCESS_DENIED, permission "openUrl" и resource URL; ничего не открывается.
Вызов хостаhost.call отклоняется с ERR_ACCESS_DENIED, permission "host" и resource имя функции (host.post выбрасывает его); вызов не доходит до хоста.
Файлы (fs/promises)Промис отклоняется с ERR_ACCESS_DENIED, permission "fs" и resource путь; диск никогда не затрагивается.
Процессы (process)run отклоняется, а spawn и exit выбрасывают ошибку с ERR_ACCESS_DENIED, permission "process" и resource команду ("exit" для exit); дочерний процесс не запускается.
TCP-сокет (net.connect)Промис отклоняется с ERR_ACCESS_DENIED, permission "network" и resource "host:port", до любого DNS-запроса.
WebSocketconnect модуля websocket отклоняется с ERR_ACCESS_DENIED, permission "network" и resource URL; глобальный WebSocket вызывает error, а затем close (код 1006), как при неудачном соединении.

Ошибки стандартных модулей несут code "ERR_ACCESS_DENIED", поэтому приложение отличает отказ от неудачной операции, а их permission и resource называют capability и то, что было запрошено. Модули nsh отказывают так же: их промисы отклоняются с ERR_ACCESS_DENIED, permission и resource (fetch с TypeError, классом любого неудачного запроса).

import { Clipboard } from "nsh";

try {
  await Clipboard.writeText(link);
} catch (error) {
  if (error.code !== "ERR_ACCESS_DENIED") throw error;
  console.warn(`${error.permission} is not granted`); // "clipboard"
}

Компоненты на Rust получают те же отказы в терминах Rust: отклонённое чтение clipboard возвращает то же, что и пустой clipboard, отклонённый выбор — ни одного файла, отклонённый open_url false, а отклонённый fetch возвращает Err с сообщением.

Каждый отказ записывается в лог один раз с указанием, какой ключ нужно добавить:

fetch GET https://evil.example.org/x is not granted: add `evil.example.org` to capabilities.network in nsh.json

Редиректы выполняет NSH, а не HTTP-стек платформы, поэтому каждый переход проверяется на соответствие capabilities. (Потоковое тело запроса не отправляется дважды, поэтому редирект 307 / 308 при потоковой выгрузке завершается ошибкой.)

#Куда попадает декларация

Декларация следует за приложением, куда бы оно ни попало:

  • nsh build и nsh package записывают её рядом с бандлом как capabilities.json;
  • nsh publish помещает её в заголовок подписанного пакета, и она защищена подписью;
  • nsh dev отправляет её с каждой сборкой, поэтому в разработке действуют те же правила. Изменение capabilities перезагружает приложение вместо горячего патча.

#Гранты: что разрешает хост-приложение

Хост-приложение, встраивающее NSH, может выдать не больше некоторых capabilities в той же JSON-форме:

// iOS, in Rust (nsh_platform_ios::AppView)
let grant = nsh_engine::Capabilities::from_json(&serde_json::json!({
    "network": { "hosts": ["api.example.com"] }
}))?;
app.set_granted_capabilities(Some(grant));
// iOS
try app.grantCapabilities(#"{ "network": { "hosts": ["api.example.com"] } }"#)
// Android
nsh.setGrantedCapabilities("{ \"network\": { \"hosts\": [\"api.example.com\"] } }");

Запрос должен быть разрешён и декларацией приложения, и грантом хоста. При приведённом выше гранте приложение, которое объявляет "hosts": ["*"], всё равно сможет обращаться только к api.example.com и не сможет показывать веб-страницы или менять Status bar. Без гранта (nil / null, по умолчанию) действует то, что объявляет каждое приложение.

Гранты применяются со следующей загрузки. Типичная схема для сторонних мини-приложений: выдать каждому только его собственный API-хост, запускать его из подписанного пакета, декларацию которого вы проверили, и оставить лимиты по умолчанию или снизить их.

#Итог

УровеньКто обеспечиваетКто задаёт
Лимиты (heap, stack, время, узлы)runtime, для каждой VMхост-приложение для каждого view (встраиваемые приложения)
Hardeningruntime, при загрузкевсегда включён вне разработки
Capabilitiesruntime (fetch, изображения, видео, web view, Status bar, файлы, процессы, сокеты)приложение (nsh.json) и хост-приложение (грант)
Целостность кодапроверка подписиnsh publish; доверенные корни хост-приложения

#См. также