fix(core): fix raw invoke body for isolation pattern (#10167)

* fix(core): fix raw invoke body for isolation pattern

The `isolation` pattern requests are made using JSON but the payload could be raw bytes, so we send the original `Content-Type`  from frontend and make sure to deserialize the payload using that one instead of `Content-Type` from request headers

* clippy

* disable plist embed in generate_context in tests

* change file

* docs [skip ci]

* move unused_variables [skip ci]

* last commit regression [skip ci]

* fix test

* add example, do not text encode raw request

* check type instead of contenttype

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
Amr Bashir
2024-07-12 15:52:53 +03:00
committed by GitHub
parent c01e87ad46
commit 4c239729c3
28 changed files with 389 additions and 27 deletions

View File

@@ -0,0 +1,6 @@
---
"tauri-macros": "patch"
"tauri-codegen": "patch"
---
Add support for `test = true` in `generate_context!` macro to skip some code generation that could affect some tests, for now it only skips empedding a plist on macOS.

View File

@@ -0,0 +1,5 @@
---
"tauri": "patch:bug"
---
Fix deserialization of raw invoke requests when using `isolation` pattern.

View File

@@ -0,0 +1,5 @@
---
"tauri-utils": "patch:feat"
---
Add `RawIsolationPayload::content_type` method.

View File

@@ -129,6 +129,7 @@ impl CodegenContext {
root: quote::quote!(::tauri),
capabilities: self.capabilities,
assets: None,
test: false,
})?;
// get the full output file path

View File

@@ -43,6 +43,8 @@ pub struct ContextData {
pub capabilities: Option<Vec<PathBuf>>,
/// The custom assets implementation
pub assets: Option<Expr>,
/// Skip runtime-only types generation for tests (e.g. embed-plist usage).
pub test: bool,
}
fn inject_script_hashes(document: &NodeRef, key: &AssetKey, csp_hashes: &mut CspHashes) {
@@ -140,8 +142,12 @@ pub fn context_codegen(data: ContextData) -> EmbeddedAssetsResult<TokenStream> {
root,
capabilities: additional_capabilities,
assets,
test,
} = data;
#[allow(unused_variables)]
let running_tests = test;
let target = std::env::var("TAURI_ENV_TARGET_TRIPLE")
.as_deref()
.map(Target::from_triple)
@@ -291,7 +297,7 @@ pub fn context_codegen(data: ContextData) -> EmbeddedAssetsResult<TokenStream> {
};
#[cfg(target_os = "macos")]
let info_plist = if target == Target::MacOS && dev {
let info_plist = if target == Target::MacOS && dev && !running_tests {
let info_plist_path = config_parent.join("Info.plist");
let mut info_plist = if info_plist_path.exists() {
plist::Value::from_file(&info_plist_path)

View File

@@ -8,7 +8,7 @@ use std::path::PathBuf;
use syn::{
parse::{Parse, ParseBuffer},
punctuated::Punctuated,
Expr, ExprLit, Lit, LitStr, Meta, PathArguments, PathSegment, Token,
Expr, ExprLit, Lit, LitBool, LitStr, Meta, PathArguments, PathSegment, Token,
};
use tauri_codegen::{context_codegen, get_config, ContextData};
use tauri_utils::{config::parse::does_supported_file_name_exist, platform::Target};
@@ -18,6 +18,7 @@ pub(crate) struct ContextItems {
root: syn::Path,
capabilities: Option<Vec<PathBuf>>,
assets: Option<Expr>,
test: bool,
}
impl Parse for ContextItems {
@@ -31,6 +32,7 @@ impl Parse for ContextItems {
let mut root = None;
let mut capabilities = None;
let mut assets = None;
let mut test = false;
let config_file = input.parse::<LitStr>().ok().map(|raw| {
let _ = input.parse::<Token![,]>();
let path = PathBuf::from(raw.value());
@@ -93,6 +95,17 @@ impl Parse for ContextItems {
"assets" => {
assets.replace(v.value);
}
"test" => {
if let Expr::Lit(ExprLit {
lit: Lit::Bool(LitBool { value, .. }),
..
}) = v.value
{
test = value;
} else {
return Err(syn::Error::new(input.span(), "unexpected value for test"));
}
}
name => {
return Err(syn::Error::new(
input.span(),
@@ -105,6 +118,8 @@ impl Parse for ContextItems {
return Err(syn::Error::new(input.span(), "unexpected list input"));
}
}
let _ = input.parse::<Token![,]>();
}
Ok(Self {
@@ -128,6 +143,7 @@ impl Parse for ContextItems {
}),
capabilities,
assets,
test,
})
}
}
@@ -142,6 +158,7 @@ pub(crate) fn generate_context(context: ContextItems) -> TokenStream {
root: context.root.to_token_stream(),
capabilities: context.capabilities,
assets: context.assets,
test: context.test,
})
.and_then(|data| context_codegen(data).map_err(|e| e.to_string()));

View File

@@ -124,7 +124,7 @@ impl<'a> Builder<'a> {
acl::build::generate_docs(
&permissions,
&autogenerated,
&name.strip_prefix("tauri-plugin-").unwrap_or(&name),
name.strip_prefix("tauri-plugin-").unwrap_or(&name),
)?;
}

View File

@@ -37,20 +37,27 @@
* @param {object} data
* @return {Promise<{nonce: number[], payload: number[]}>}
*/
async function encrypt(data) {
async function encrypt(payload) {
const algorithm = Object.create(null)
algorithm.name = 'AES-GCM'
algorithm.iv = window.crypto.getRandomValues(new Uint8Array(12))
const encoder = new TextEncoder()
const encoded = encoder.encode(__RAW_process_ipc_message_fn__(data).data)
const { contentType, data } = __RAW_process_ipc_message_fn__(payload)
const message =
typeof data === 'string'
? new TextEncoder().encode(data)
: ArrayBuffer.isView(data) || data instanceof ArrayBuffer
? data
: new Uint8Array(data)
return window.crypto.subtle
.encrypt(algorithm, aesGcmKey, encoded)
.encrypt(algorithm, aesGcmKey, message)
.then((payload) => {
const result = Object.create(null)
result.nonce = Array.from(new Uint8Array(algorithm.iv))
result.payload = Array.from(new Uint8Array(payload))
result.contentType = contentType
return result
})
}
@@ -66,7 +73,9 @@
const keys = data.payload ? Object.keys(data.payload) : []
return (
keys.length > 0 &&
keys.every((key) => key === 'nonce' || key === 'payload')
keys.every(
(key) => key === 'nonce' || key === 'payload' || key === 'contentType'
)
)
}
return false

View File

@@ -73,6 +73,14 @@ impl AesGcmPair {
pub fn key(&self) -> &Aes256Gcm {
&self.key
}
#[doc(hidden)]
pub fn encrypt(&self, nonce: &[u8; 12], payload: &[u8]) -> Result<Vec<u8>, Error> {
self
.key
.encrypt(nonce.into(), payload)
.map_err(|_| self::Error::Aes)
}
}
/// All cryptographic keys required for Isolation encryption
@@ -97,7 +105,7 @@ impl Keys {
/// Decrypts a message using the generated keys.
pub fn decrypt(&self, raw: RawIsolationPayload<'_>) -> Result<Vec<u8>, Error> {
let RawIsolationPayload { nonce, payload } = raw;
let RawIsolationPayload { nonce, payload, .. } = raw;
let nonce: [u8; 12] = nonce.as_ref().try_into()?;
self
.aes_gcm
@@ -109,9 +117,18 @@ impl Keys {
/// Raw representation of
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawIsolationPayload<'a> {
nonce: Cow<'a, [u8]>,
payload: Cow<'a, [u8]>,
content_type: Cow<'a, str>,
}
impl<'a> RawIsolationPayload<'a> {
/// Content type of this payload.
pub fn content_type(&self) -> &Cow<'a, str> {
&self.content_type
}
}
impl<'a> TryFrom<&'a Vec<u8>> for RawIsolationPayload<'a> {

View File

@@ -39,7 +39,9 @@
const keys = Object.keys(event.data.payload || {})
return (
keys.length > 0 &&
keys.every((key) => key === 'nonce' || key === 'payload')
keys.every(
(key) => key === 'contentType' || key === 'nonce' || key === 'payload'
)
)
}
return false

View File

@@ -46,6 +46,7 @@ pub type OwnedInvokeResponder<R> =
/// Possible values of an IPC payload.
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(PartialEq))]
pub enum InvokeBody {
/// Json payload.
Json(JsonValue),

View File

@@ -388,6 +388,15 @@ fn parse_invoke_request<R: Runtime>(
// so we must ignore it because some commands use the IPC for faster response
let has_payload = !body.is_empty();
#[allow(unused_mut)]
let mut content_type = parts
.headers
.get(http::header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok())
.map(|mime| mime.parse())
.unwrap_or(Ok(mime::APPLICATION_OCTET_STREAM))
.map_err(|_| "unknown content type")?;
#[cfg(feature = "isolation")]
if let crate::Pattern::Isolation { crypto_keys, .. } = &*manager.pattern {
// if the platform does not support request body, we ignore it
@@ -395,8 +404,18 @@ fn parse_invoke_request<R: Runtime>(
#[cfg(feature = "tracing")]
let _span = tracing::trace_span!("ipc::request::decrypt_isolation_payload").entered();
body = crate::utils::pattern::isolation::RawIsolationPayload::try_from(&body)
.and_then(|raw| crypto_keys.decrypt(raw))
(body, content_type) = crate::utils::pattern::isolation::RawIsolationPayload::try_from(&body)
.and_then(|raw| {
let content_type = raw.content_type().clone();
crypto_keys.decrypt(raw).map(|decrypted| {
(
decrypted,
content_type
.parse()
.unwrap_or(mime::APPLICATION_OCTET_STREAM),
)
})
})
.map_err(|e| e.to_string())?;
}
}
@@ -440,14 +459,6 @@ fn parse_invoke_request<R: Runtime>(
.map_err(|_| "Tauri error header value must be a numeric string")?,
);
let content_type = parts
.headers
.get(http::header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok())
.map(|mime| mime.parse())
.unwrap_or(Ok(mime::APPLICATION_OCTET_STREAM))
.map_err(|_| "unknown content type")?;
#[cfg(feature = "tracing")]
let span = tracing::trace_span!("ipc::request::deserialize").entered();
@@ -481,3 +492,194 @@ fn parse_invoke_request<R: Runtime>(
Ok(payload)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
use crate::{manager::AppManager, plugin::PluginStore, StateManager, Wry};
use http::header::*;
use serde_json::json;
use tauri_macros::generate_context;
#[test]
fn parse_invoke_request() {
let context = generate_context!("test/fixture/src-tauri/tauri.conf.json", crate, test = true);
let manager: AppManager<Wry> = AppManager::with_handlers(
context,
PluginStore::default(),
Box::new(|_| false),
None,
Default::default(),
StateManager::new(),
Default::default(),
Default::default(),
Default::default(),
(None, "".into()),
crate::generate_invoke_key().unwrap(),
);
let cmd = "write_something";
let url = "tauri://localhost";
let invoke_key = "1234ahdsjkl123";
let callback = 12378123;
let error = 6243;
let headers = HeaderMap::from_iter(vec![
(
CONTENT_TYPE,
HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap(),
),
(
HeaderName::from_str(TAURI_INVOKE_KEY_HEADER_NAME).unwrap(),
HeaderValue::from_str(invoke_key).unwrap(),
),
(
HeaderName::from_str(TAURI_CALLBACK_HEADER_NAME).unwrap(),
HeaderValue::from_str(&callback.to_string()).unwrap(),
),
(
HeaderName::from_str(TAURI_ERROR_HEADER_NAME).unwrap(),
HeaderValue::from_str(&error.to_string()).unwrap(),
),
(ORIGIN, HeaderValue::from_str("tauri://localhost").unwrap()),
]);
let mut request = Request::builder().uri(format!("ipc://localhost/{cmd}"));
*request.headers_mut().unwrap() = headers.clone();
let body = vec![123, 31, 45];
let request = request.body(body.clone()).unwrap();
let invoke_request = super::parse_invoke_request(&manager, request).unwrap();
assert_eq!(invoke_request.cmd, cmd);
assert_eq!(invoke_request.callback.0, callback);
assert_eq!(invoke_request.error.0, error);
assert_eq!(invoke_request.invoke_key, invoke_key);
assert_eq!(invoke_request.url, url.parse().unwrap());
assert_eq!(invoke_request.headers, headers);
assert_eq!(invoke_request.body, InvokeBody::Raw(body));
let body = json!({
"key": 1,
"anotherKey": "asda",
});
let mut headers = headers.clone();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(mime::APPLICATION_JSON.as_ref()).unwrap(),
);
let mut request = Request::builder().uri(format!("ipc://localhost/{cmd}"));
*request.headers_mut().unwrap() = headers.clone();
let request = request.body(serde_json::to_vec(&body).unwrap()).unwrap();
let invoke_request = super::parse_invoke_request(&manager, request).unwrap();
assert_eq!(invoke_request.headers, headers);
assert_eq!(invoke_request.body, InvokeBody::Json(body));
}
#[test]
#[cfg(feature = "isolation")]
fn parse_invoke_request_isolation() {
let context = generate_context!(
"test/fixture/isolation/src-tauri/tauri.conf.json",
crate,
test = false
);
let crate::pattern::Pattern::Isolation { crypto_keys, .. } = &context.pattern else {
unreachable!()
};
let mut nonce = [0u8; 12];
getrandom::getrandom(&mut nonce).unwrap();
let body_raw = vec![1, 41, 65, 12, 78];
let body_bytes = crypto_keys.aes_gcm().encrypt(&nonce, &body_raw).unwrap();
let isolation_payload_raw = json!({
"nonce": nonce,
"payload": body_bytes,
"contentType": mime::APPLICATION_OCTET_STREAM.to_string(),
});
let body_json = json!({
"key": 1,
"anotherKey": "string"
});
let body_bytes = crypto_keys
.aes_gcm()
.encrypt(&nonce, &serde_json::to_vec(&body_json).unwrap())
.unwrap();
let isolation_payload_json = json!({
"nonce": nonce,
"payload": body_bytes,
"contentType": mime::APPLICATION_JSON.to_string(),
});
let manager: AppManager<Wry> = AppManager::with_handlers(
context,
PluginStore::default(),
Box::new(|_| false),
None,
Default::default(),
StateManager::new(),
Default::default(),
Default::default(),
Default::default(),
(None, "".into()),
crate::generate_invoke_key().unwrap(),
);
let cmd = "write_something";
let url = "tauri://localhost";
let invoke_key = "1234ahdsjkl123";
let callback = 12378123;
let error = 6243;
let headers = HeaderMap::from_iter(vec![
(
CONTENT_TYPE,
HeaderValue::from_str(mime::APPLICATION_JSON.as_ref()).unwrap(),
),
(
HeaderName::from_str(TAURI_INVOKE_KEY_HEADER_NAME).unwrap(),
HeaderValue::from_str(invoke_key).unwrap(),
),
(
HeaderName::from_str(TAURI_CALLBACK_HEADER_NAME).unwrap(),
HeaderValue::from_str(&callback.to_string()).unwrap(),
),
(
HeaderName::from_str(TAURI_ERROR_HEADER_NAME).unwrap(),
HeaderValue::from_str(&error.to_string()).unwrap(),
),
(ORIGIN, HeaderValue::from_str("tauri://localhost").unwrap()),
]);
let mut request = Request::builder().uri(format!("ipc://localhost/{cmd}"));
*request.headers_mut().unwrap() = headers.clone();
let body = serde_json::to_vec(&isolation_payload_raw).unwrap();
let request = request.body(body).unwrap();
let invoke_request = super::parse_invoke_request(&manager, request).unwrap();
assert_eq!(invoke_request.cmd, cmd);
assert_eq!(invoke_request.callback.0, callback);
assert_eq!(invoke_request.error.0, error);
assert_eq!(invoke_request.invoke_key, invoke_key);
assert_eq!(invoke_request.url, url.parse().unwrap());
assert_eq!(invoke_request.headers, headers);
assert_eq!(invoke_request.body, InvokeBody::Raw(body_raw));
let mut request = Request::builder().uri(format!("ipc://localhost/{cmd}"));
*request.headers_mut().unwrap() = headers.clone();
let body = serde_json::to_vec(&isolation_payload_json).unwrap();
let request = request.body(body).unwrap();
let invoke_request = super::parse_invoke_request(&manager, request).unwrap();
assert_eq!(invoke_request.headers, headers);
assert_eq!(invoke_request.body, InvokeBody::Json(body_json));
}
}

View File

@@ -692,7 +692,7 @@ mod test {
#[test]
fn check_get_url() {
let context = generate_context!("test/fixture/src-tauri/tauri.conf.json", crate);
let context = generate_context!("test/fixture/src-tauri/tauri.conf.json", crate, test = true);
let manager: AppManager<Wry> = AppManager::with_handlers(
context,
PluginStore::default(),

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
#![cfg(target_os = "macos")]
#![allow(deprecated)]
use crate::utils::config::WindowEffectsConfig;

View File

@@ -0,0 +1,6 @@
<html>
<head></head>
<body>
<iframe id="mainframe"></iframe>
</body>
</html>

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Isolation Secure Script</title>
</head>
<body>
<script src="index.js"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
window.__TAURI_ISOLATION_HOOK__ = (payload, options) => {
console.log('hook', payload, options)
return payload
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

View File

@@ -0,0 +1,27 @@
{
"$schema": "../../../../../../core/tauri-config-schema/schema.json",
"identifier": "isolation.tauri.example",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:4000"
},
"app": {
"windows": [
{
"title": "Isolation Tauri App"
}
],
"security": {
"csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self'; connect-src ipc: http://ipc.localhost",
"pattern": {
"use": "isolation",
"options": {
"dir": "../isolation-dist"
}
}
}
},
"bundle": {
"active": true
}
}

View File

@@ -10,9 +10,11 @@ fn main() {
"app-menu",
tauri_build::InlinedPlugin::new().commands(&["toggle", "popup"]),
)
.app_manifest(
tauri_build::AppManifest::new().commands(&["log_operation", "perform_request"]),
),
.app_manifest(tauri_build::AppManifest::new().commands(&[
"log_operation",
"perform_request",
"echo",
])),
)
.expect("failed to run tauri-build");
}

View File

@@ -16,6 +16,7 @@
]
},
"allow-perform-request",
"allow-echo",
"app-menu:default",
"sample:allow-ping-scoped",
"sample:global-scope",
@@ -68,4 +69,4 @@
"webview:allow-create-webview-window",
"webview:allow-print"
]
}
}

View File

@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-echo"
description = "Enables the echo command without any pre-configured scope."
commands.allow = ["echo"]
[[permission]]
identifier = "deny-echo"
description = "Denies the echo command without any pre-configured scope."
commands.deny = ["echo"]

View File

@@ -45,3 +45,8 @@ pub fn perform_request(endpoint: String, body: RequestBody) -> ApiResponse {
message: "message response".into(),
}
}
#[command]
pub fn echo(request: tauri::ipc::Request<'_>) -> tauri::ipc::Response {
tauri::ipc::Response::new(request.body().clone())
}

View File

@@ -141,6 +141,7 @@ pub fn run_app<R: Runtime, F: FnOnce(&App<R>) + Send + 'static>(
.invoke_handler(tauri::generate_handler![
cmd::log_operation,
cmd::perform_request,
cmd::echo
])
.build(tauri::tauri_build_context!())
.expect("error while building tauri application");

View File

@@ -83,12 +83,22 @@
let messages = writable([])
let consoleTextEl
async function onMessage(value) {
const valueStr =
typeof value === 'string'
? value
: JSON.stringify(
value instanceof ArrayBuffer
? Array.from(new Uint8Array(value))
: value,
null,
1
)
messages.update((r) => [
...r,
{
html:
`<pre><strong class="text-accent dark:text-darkAccent">[${new Date().toLocaleTimeString()}]:</strong> ` +
(typeof value === 'string' ? value : JSON.stringify(value, null, 1)) +
valueStr +
'</pre>'
}
])

View File

@@ -36,6 +36,16 @@
.catch(onMessage)
}
function echo() {
invoke('echo', {
message: 'Tauri JSON request!'
})
.then(onMessage)
.catch(onMessage)
invoke('echo', [1, 2, 3]).then(onMessage).catch(onMessage)
}
function emitEvent() {
webviewWindow.emit('js-event', 'this is the payload string')
}
@@ -49,4 +59,5 @@
<button class="btn" id="event" on:click={emitEvent}>
Send event to Rust
</button>
<button class="btn" id="request" on:click={echo}> Echo </button>
</div>