From a1ace407712569b7fab350dfea279dcfaa211e21 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 29 Oct 2026 23:20:00 -0400 Subject: [PATCH 1/6] ci: build published variants from source and pin the napi workflow Engine-variant releases staged the tool binaries and DotNetBridge committed under template/framework unless a manual run set rebuild_framework, so a tag release published binaries built off CI under a CI provenance attestation. Tag releases now always rebuild the shared framework, clear the committed tool binaries first and fail if a tool is missing. windows-napi.yml gets a read-only default token, actions pinned to the SHAs npm_release.yml already uses, and npm provenance for the root and per-triple packages. --- .github/workflows/npm_release.yml | 16 +++++++++++++--- .github/workflows/windows-napi.yml | 24 ++++++++++++++++-------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/.github/workflows/npm_release.yml b/.github/workflows/npm_release.yml index b5c4179..19d47f4 100644 --- a/.github/workflows/npm_release.yml +++ b/.github/workflows/npm_release.yml @@ -25,7 +25,7 @@ on: required: false default: "" rebuild_framework: - description: "Engine variants: rebuild the shared framework (tools + dotnet-bridge) from source instead of shipping the copies committed under template/framework. (The classic build always rebuilds it.)" + description: "Engine variants: rebuild the shared framework (tools + dotnet-bridge) from source instead of shipping the copies committed under template/framework. Tag releases always rebuild, as does the classic build." type: boolean required: false default: false @@ -169,20 +169,30 @@ jobs: # The classic build publishes ManifestMerger and dotnet-typings-gen with `dotnet publish` # every time; a variant only needs the SDK when it regenerates the shared framework. - name: Setup .NET - if: ${{ matrix.engine == 'classic' || inputs.rebuild_framework }} + if: ${{ matrix.engine == 'classic' || github.event_name == 'push' || inputs.rebuild_framework }} uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: # dotnet-typings-gen targets net10.0; ManifestMerger is netstandard2.0 and builds on it. dotnet-version: "10.x" - name: Rebuild shared framework - if: ${{ matrix.engine != 'classic' && inputs.rebuild_framework }} + # Tag releases always rebuild, so a published variant never ships binaries built off CI. + if: ${{ matrix.engine != 'classic' && (github.event_name == 'push' || inputs.rebuild_framework) }} shell: pwsh # -SkipRelease -SkipDevtools: the classic V8 nativescript.dll is not staged into a variant # (the engine cdylib takes its place), so building it here would be thrown away. # -SkipArm64: the variants are x64-only (`cpu: ["x64"]` in their package.json). run: | $ErrorActionPreference = 'Stop' + # Clear the committed tool binaries so only this run's output can be staged. + $tools = 'template/framework/tools' + Get-ChildItem $tools -Recurse -File -Include *.exe, *.dll, *.pdb, *.json | + Remove-Item -Force ./template/build.ps1 -SkipArm64 -SkipRelease -SkipDevtools + $expected = @('ManifestMerger/ManifestMerger.dll', 'dotnet-typings-gen/dotnet-typings-gen.dll', + 'dotnet-tool-x64.exe', 'nsbundle_pack-x64.exe', 'typings-generator-x64.exe') | + ForEach-Object { Join-Path $tools $_ } + $missing = $expected | Where-Object { -not (Test-Path $_) } + if ($missing) { throw "Framework rebuild missing: $($missing -join ', ')" } # The runtime DLLs and tool binaries are committed under template/framework so the variants # can stage them without a classic build. A classic release must ship what THIS run built, # so clear them first: with the committed copies gone, the checks after the build can only diff --git a/.github/workflows/windows-napi.yml b/.github/workflows/windows-napi.yml index 4da5ef3..f90431f 100644 --- a/.github/workflows/windows-napi.yml +++ b/.github/workflows/windows-napi.yml @@ -14,6 +14,9 @@ on: - '.github/workflows/windows-napi.yml' workflow_dispatch: +permissions: + contents: read + env: CARGO_TERM_COLOR: always @@ -28,14 +31,14 @@ jobs: - target: aarch64-pc-windows-msvc name: build ${{ matrix.settings.target }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 20 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: targets: ${{ matrix.settings.target }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: key: ${{ matrix.settings.target }} - name: Install npm dependencies @@ -52,7 +55,7 @@ jobs: # the authoritative run is the local one on a client SKU. continue-on-error: true run: npm test - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: bindings-${{ matrix.settings.target }} path: windows-napi/windows.*.node @@ -63,9 +66,12 @@ jobs: if: startsWith(github.ref, 'refs/tags/windows-napi-v') needs: build runs-on: windows-latest + permissions: + contents: read + id-token: write # npm provenance steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 20 registry-url: https://registry.npmjs.org @@ -73,7 +79,7 @@ jobs: working-directory: windows-napi run: npm ci - name: Download all binding artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: windows-napi/artifacts - name: Distribute artifacts into npm/ sub-packages @@ -86,3 +92,5 @@ jobs: run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Applies to the sub-package publishes `napi prepublish` runs as well. + NPM_CONFIG_PROVENANCE: "true" From 99544f91f7609ee0f0456724d3e09eafcd372aac Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 29 Oct 2026 23:20:01 -0400 Subject: [PATCH 2/6] fix(devtools): refuse inspector connections from web pages Any page in the developer's browser could open a WebSocket to the fixed ws://127.0.0.1:43000/devtools/page/runtime path during `ns debug` and run arbitrary code through the inspector. The handshake now requires a devtools:// Origin or none (VS Code and other CDP clients send none), and both the handshake and /json reject a non-loopback Host, which defeats DNS rebinding. --- runtime-devtools/src/lib.rs | 86 ++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/runtime-devtools/src/lib.rs b/runtime-devtools/src/lib.rs index 495b3de..634befa 100644 --- a/runtime-devtools/src/lib.rs +++ b/runtime-devtools/src/lib.rs @@ -499,7 +499,7 @@ fn handle_connection( // Peek at the request line to route without consuming any bytes. // TcpStream::peek() leaves data in the kernel receive buffer so that // tungstenite can re-read the full HTTP upgrade request. - let mut peek_buf = [0u8; 256]; + let mut peek_buf = [0u8; 2048]; let n = match stream.peek(&mut peek_buf) { Ok(n) => n, Err(_) => return, @@ -511,16 +511,70 @@ fn handle_connection( .unwrap_or("/") .to_string(); + if !host_allowed(header_value(&peek_str, "host")) { + let mut stream = stream; + let _ = stream.write_all( + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + return; + } + if path.starts_with("/json") { serve_json(stream, &path, ws_url); } else { stream.set_nonblocking(false).ok(); - if let Ok(ws) = tungstenite::accept(stream) { + if let Ok(ws) = tungstenite::accept_hdr(stream, check_handshake) { handle_ws(ws, inbound_tx, outbound_rx); } } } +/// Any web page can open a WebSocket to localhost, and an inspector session runs arbitrary +/// code, so the handshake must come from a DevTools frontend or a non-browser client. +fn check_handshake( + req: &tungstenite::handshake::server::Request, + resp: tungstenite::handshake::server::Response, +) -> Result +{ + // A header that isn't valid text maps to "", which neither check accepts. + let header = |name: &str| req.headers().get(name).map(|v| v.to_str().unwrap_or("")); + if origin_allowed(header("origin")) && host_allowed(header("host")) { + return Ok(resp); + } + let mut err = tungstenite::handshake::server::ErrorResponse::new(Some("Forbidden".into())); + *err.status_mut() = tungstenite::http::StatusCode::FORBIDDEN; + Err(err) +} + +/// Browsers always send `Origin` on WebSocket handshakes; CDP clients such as VS Code don't. +fn origin_allowed(origin: Option<&str>) -> bool { + match origin { + None => true, + Some(o) => o.starts_with("devtools://") || o.starts_with("chrome-devtools://"), + } +} + +/// Rejects DNS rebinding, where `Host` names the attacker's domain resolved to loopback. +fn host_allowed(host: Option<&str>) -> bool { + let Some(host) = host else { return true }; + let name = match host.strip_prefix('[') { + Some(rest) => rest.split(']').next().unwrap_or(""), + None => host.rsplit_once(':').map_or(host, |(name, _)| name), + }; + name.eq_ignore_ascii_case("localhost") + || name.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +fn header_value<'a>(head: &'a str, name: &str) -> Option<&'a str> { + head.split("\r\n") + .skip(1) + .take_while(|line| !line.is_empty()) + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.trim().eq_ignore_ascii_case(name).then(|| value.trim()) + }) +} + fn serve_json(mut stream: TcpStream, path: &str, ws_url: &str) { let body = if path == "/json/version" { build_version_json(ws_url) @@ -608,6 +662,34 @@ mod tests { assert_eq!(cfg.port, 43000); } + #[test] + fn handshake_rejects_web_origins() { + assert!(origin_allowed(None)); + assert!(origin_allowed(Some("devtools://devtools"))); + assert!(!origin_allowed(Some("https://example.com"))); + assert!(!origin_allowed(Some("null"))); + assert!(!origin_allowed(Some(""))); + } + + #[test] + fn host_must_be_loopback() { + assert!(host_allowed(None)); + assert!(host_allowed(Some("127.0.0.1:43000"))); + assert!(host_allowed(Some("localhost:43000"))); + assert!(host_allowed(Some("LOCALHOST"))); + assert!(host_allowed(Some("[::1]:43000"))); + assert!(!host_allowed(Some("evil.example:43000"))); + assert!(!host_allowed(Some("localhost.evil.example"))); + assert!(!host_allowed(Some(""))); + } + + #[test] + fn header_value_reads_request_headers() { + let req = "GET /json HTTP/1.1\r\nhost: evil.example\r\nX: y\r\n\r\nbody: no"; + assert_eq!(header_value(req, "Host"), Some("evil.example")); + assert_eq!(header_value(req, "body"), None); + } + #[test] fn version_json_is_valid() { let ws = "ws://127.0.0.1:43000/devtools/page/runtime"; From 87d43f08d673f6ff18cef4352bfc148488563868 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 29 Oct 2026 23:20:01 -0400 Subject: [PATCH 3/6] fix(quickjs): honor view offsets and embedded NULs in the napi shim napi_get_typedarray_info and napi_get_dataview_info returned the ArrayBuffer's base pointer instead of the view's first byte, so native code read and wrote the wrong bytes of a subarray. napi_get_value_string_utf16 and script evaluation used strlen, cutting strings at an embedded NUL. --- .../vendor/napi/quickjs/quickjs-api.c | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/windows-quickjs/vendor/napi/quickjs/quickjs-api.c b/packages/windows-quickjs/vendor/napi/quickjs/quickjs-api.c index 4ef9fd1..76e05b5 100644 --- a/packages/windows-quickjs/vendor/napi/quickjs/quickjs-api.c +++ b/packages/windows-quickjs/vendor/napi/quickjs/quickjs-api.c @@ -1776,11 +1776,18 @@ napi_status napi_get_typedarray_info(napi_env env, JS_FreeValue(env->context, len); } + JSValue byteOffset = JS_GetPropertyStr(env->context, value, "byteOffset"); + uint32_t offset = 0; + JS_ToUint32(env->context, &offset, byteOffset); + JS_FreeValue(env->context, byteOffset); + if (data || arraybuffer) { JSValue jsArrayBuffer = JS_GetPropertyStr(env->context, value, "buffer"); if (data) { - size_t bufferSize; - *data = JS_GetArrayBuffer(env->context, &bufferSize, jsArrayBuffer); + // Node-API reports the view's first byte, not the start of its buffer. + size_t bufferSize = 0; + uint8_t *base = JS_GetArrayBuffer(env->context, &bufferSize, jsArrayBuffer); + *data = (base != NULL && offset <= bufferSize) ? base + offset : NULL; } if (arraybuffer) { @@ -1791,11 +1798,7 @@ napi_status napi_get_typedarray_info(napi_env env, } if (byte_offset) { - JSValue byteOffset = JS_GetPropertyStr(env->context, value, "byteOffset"); - uint32_t cValue = 0; - JS_ToUint32(env->context, &cValue, byteOffset); - JS_FreeValue(env->context, byteOffset); - *byte_offset = cValue; + *byte_offset = offset; } return napi_clear_last_error(env); @@ -1822,11 +1825,18 @@ napi_status napi_get_dataview_info(napi_env env, JS_FreeValue(env->context, byteLength); } + JSValue byteOffset = JS_GetPropertyStr(env->context, value, "byteOffset"); + uint32_t offset = 0; + JS_ToUint32(env->context, &offset, byteOffset); + JS_FreeValue(env->context, byteOffset); + if (data || arraybuffer) { JSValue jsArrayBuffer = JS_GetPropertyStr(env->context, value, "buffer"); if (data) { - size_t bufferSize; - *data = JS_GetArrayBuffer(env->context, &bufferSize, jsArrayBuffer); + // Node-API reports the view's first byte, not the start of its buffer. + size_t bufferSize = 0; + uint8_t *base = JS_GetArrayBuffer(env->context, &bufferSize, jsArrayBuffer); + *data = (base != NULL && offset <= bufferSize) ? base + offset : NULL; } if (arraybuffer) { @@ -1837,11 +1847,7 @@ napi_status napi_get_dataview_info(napi_env env, } if (byte_offset) { - JSValue byteOffset = JS_GetPropertyStr(env->context, value, "byteOffset"); - uint32_t cValue = 0; - JS_ToUint32(env->context, &cValue, byteOffset); - JS_FreeValue(env->context, byteOffset); - *byte_offset = cValue; + *byte_offset = offset; } return napi_clear_last_error(env); @@ -2216,7 +2222,8 @@ napi_status napi_get_value_string_utf16(napi_env env, return napi_set_last_error(env, napi_string_expected, NULL, 0, NULL); } - int ret = Utf8ToUtf16Length(str, strlen(str)); + // `l` is the full length: strlen would stop at an embedded NUL. + int ret = Utf8ToUtf16Length(str, l); if (ret == -1) { JS_FreeCString(env->context, str); return napi_set_last_error(env, napi_generic_failure, NULL, 0, NULL); @@ -2228,7 +2235,7 @@ napi_status napi_get_value_string_utf16(napi_env env, if (buf != NULL) { memset(buf, 0, sizeof(char16_t) * bufsize); - Utf8ToUtf16(str, strlen(str), buf, bufsize); + Utf8ToUtf16(str, l, buf, bufsize); } JS_FreeCString(env->context, str); @@ -4399,9 +4406,10 @@ napi_status qjs_execute_script(napi_env env, CHECK_ARG(script) JSValue eval_result; - const char *cScript = JS_ToCString(env->context, *((JSValue *) script)); + size_t script_len = 0; + const char *cScript = JS_ToCStringLen(env->context, &script_len, *((JSValue *) script)); js_enter(env); - eval_result = JS_Eval(env->context, cScript, strlen(cScript), file, JS_EVAL_TYPE_GLOBAL); + eval_result = JS_Eval(env->context, cScript, script_len, file, JS_EVAL_TYPE_GLOBAL); JS_FreeCString(env->context, cScript); js_exit(env); if (JS_IsException(eval_result)) { From 9a47b059ad595d77f1d57ac420a6f338b15c42a4 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 29 Oct 2026 23:20:01 -0400 Subject: [PATCH 4/6] feat(source-protect): seal app.nsbundle with a per-build key Without --key-hex every bundle was sealed with the one default key compiled into the public runtime DLL, so a key extracted once opened any app's bundle. `nsbundle_pack --write-key ` seals with a fresh random key and writes it beside the project; the template embeds it as the NSBundleKey resource and RuntimeHost passes it to runtime_set_bundle_key before runtime_init. The CLI passes --write-key when no key is given. The container parser also bounds a corrupt entry count's allocation and checks blob offsets without overflow, so a damaged bundle fails to load instead of aborting the app. --- runtime/src/bin/nsbundle_pack.rs | 32 ++++++++++++--- runtime/src/source_protect.rs | 40 +++++++++++++++---- .../framework/__PROJECT_NAME__/RuntimeHost.cs | 16 +++++--- .../__PROJECT_NAME__/__PROJECT_NAME__.csproj | 3 ++ 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/runtime/src/bin/nsbundle_pack.rs b/runtime/src/bin/nsbundle_pack.rs index 6840b4d..03d92f8 100644 --- a/runtime/src/bin/nsbundle_pack.rs +++ b/runtime/src/bin/nsbundle_pack.rs @@ -2,12 +2,14 @@ //! `package.json`/...) into an encrypted `app.nsbundle` container (see `runtime::source_protect`). //! //! Usage: -//! nsbundle_pack --input --output [--key-hex <64 hex chars>] +//! nsbundle_pack --input --output [--key-hex <64 hex chars> | --write-key ] //! -//! Without `--key-hex`, the container is sealed with the compiled-in default pepper -//! (`key_mode = 0`) — no further runtime setup needed. With `--key-hex`, the container is sealed +//! Without a key option, the container is sealed with the compiled-in default pepper +//! (`key_mode = 0`), which every app shares. With `--key-hex`, the container is sealed //! `key_mode = 1` (custom key); the app must call `runtime_set_bundle_key(sameHex)` before -//! `runtime_init`, or the sealed bundle cannot be opened at startup. +//! `runtime_init`, or the sealed bundle cannot be opened at startup. `--write-key` seals with a +//! fresh random key and writes its hex to ``, which the app template compiles into the +//! exe and passes to `runtime_set_bundle_key`. //! //! This is the packer half of the CLI contract documented for the NativeScript CLI's //! `ns build windows`/`ns deploy windows`: invoke this after webpack finishes, pointing `--input` @@ -21,7 +23,7 @@ use runtime::source_protect; fn print_usage() { eprintln!( - "Usage: nsbundle_pack --input --output [--key-hex <64 hex chars>]" + "Usage: nsbundle_pack --input --output [--key-hex <64 hex chars> | --write-key ]" ); } @@ -29,6 +31,7 @@ fn main() -> ExitCode { let mut input: Option = None; let mut output: Option = None; let mut key_hex: Option = None; + let mut write_key: Option = None; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -36,6 +39,7 @@ fn main() -> ExitCode { "--input" => input = args.next().map(PathBuf::from), "--output" => output = args.next().map(PathBuf::from), "--key-hex" => key_hex = args.next(), + "--write-key" => write_key = args.next().map(PathBuf::from), "-h" | "--help" => { print_usage(); return ExitCode::SUCCESS; @@ -58,6 +62,24 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } + if let Some(path) = write_key { + if key_hex.is_some() { + eprintln!("--key-hex and --write-key are mutually exclusive"); + return ExitCode::FAILURE; + } + let mut key = [0u8; 32]; + if getrandom::getrandom(&mut key).is_err() { + eprintln!("The OS random number generator is unavailable"); + return ExitCode::FAILURE; + } + let hex: String = key.iter().map(|b| format!("{b:02x}")).collect(); + if let Err(err) = std::fs::write(&path, &hex) { + eprintln!("Failed to write {}: {err}", path.display()); + return ExitCode::FAILURE; + } + key_hex = Some(hex); + } + let (key_mode, key) = match key_hex { Some(hex) => match source_protect::parse_key_hex(&hex) { Some(key) => (source_protect::KEY_MODE_CUSTOM, key), diff --git a/runtime/src/source_protect.rs b/runtime/src/source_protect.rs index dc2f6d5..ad0f0b7 100644 --- a/runtime/src/source_protect.rs +++ b/runtime/src/source_protect.rs @@ -236,7 +236,8 @@ fn open_and_decrypt(path: &Path) -> Result>, String> { blob_len: u64, nonce: [u8; 12], } - let mut toc = Vec::with_capacity(entry_count); + // Each TOC entry takes at least 30 bytes, which bounds a corrupt count's allocation. + let mut toc = Vec::with_capacity(entry_count.min(data.len() / 30)); for _ in 0..entry_count { if cursor + 2 > data.len() { return Err("truncated TOC".to_string()); @@ -265,12 +266,15 @@ fn open_and_decrypt(path: &Path) -> Result>, String> { let blob_section_start = cursor; let mut table = HashMap::with_capacity(toc.len()); for entry in toc { - let start = blob_section_start + entry.blob_offset as usize; - let end = start + entry.blob_len as usize; - if end > data.len() { - return Err(format!("blob out of range for {}", entry.path)); - } - let plaintext = open(&key, &entry.nonce, entry.path.as_bytes(), &data[start..end]) + let blob = usize::try_from(entry.blob_offset) + .ok() + .and_then(|offset| blob_section_start.checked_add(offset)) + .and_then(|start| { + let end = start.checked_add(usize::try_from(entry.blob_len).ok()?)?; + data.get(start..end) + }) + .ok_or_else(|| format!("blob out of range for {}", entry.path))?; + let plaintext = open(&key, &entry.nonce, entry.path.as_bytes(), blob) .map_err(|_| format!("failed to decrypt {} (tampered container or wrong key)", entry.path))?; table.insert(entry.path.to_lowercase(), plaintext); } @@ -439,6 +443,28 @@ mod tests { assert!(open(&key, &nonce, b"a.js", &data[blob_start..blob_end]).is_err()); } + #[test] + fn corrupt_counts_and_offsets_fail_cleanly() { + let input = scratch_dir("corrupt_in"); + fs::write(input.join("a.js"), b"a").unwrap(); + let output = scratch_dir("corrupt_out").join("app.nsbundle"); + pack_directory(&input, &output, KEY_MODE_DEFAULT, default_key()).unwrap(); + let packed = fs::read(&output).unwrap(); + + let mut data = packed.clone(); + LittleEndian::write_u32(&mut data[24..28], u32::MAX); + fs::write(&output, &data).unwrap(); + assert!(open_and_decrypt(&output).is_err()); + + let offset_at = HEADER_LEN + 2 + "a.js".len(); + for value in [u64::MAX, u64::MAX - 8] { + let mut data = packed.clone(); + LittleEndian::write_u64(&mut data[offset_at..offset_at + 8], value); + fs::write(&output, &data).unwrap(); + assert!(open_and_decrypt(&output).is_err()); + } + } + #[test] fn aad_binds_ciphertext_to_its_path() { let key = [3u8; 32]; diff --git a/template/framework/__PROJECT_NAME__/RuntimeHost.cs b/template/framework/__PROJECT_NAME__/RuntimeHost.cs index 311ae90..a495240 100644 --- a/template/framework/__PROJECT_NAME__/RuntimeHost.cs +++ b/template/framework/__PROJECT_NAME__/RuntimeHost.cs @@ -32,11 +32,7 @@ internal sealed class RuntimeHost : IDisposable [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_local_folder))] private static extern void runtime_set_local_folder([MarshalAs(UnmanagedType.LPUTF8Str)] string localFolder); - // Escape hatch for apps packed with `nsbundle_pack --key-hex ` (custom-key - // app.nsbundle containers). Must be called before runtime_init if used at all: apps - // packed with the default pepper (no --key-hex) never need to call this. Not wired to a - // default call site here; an app author supplies their own key material and calls this - // from Initialize() before runtime_init(...). + // Must be called before runtime_init, which opens app.nsbundle. [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_bundle_key))] private static extern int runtime_set_bundle_key([MarshalAs(UnmanagedType.LPUTF8Str)] string keyHex); @@ -252,6 +248,15 @@ internal static string LocalFolderPath } } + private static void ApplyBundleKey() + { + using var stream = typeof(RuntimeHost).Assembly.GetManifestResourceStream("NSBundleKey"); + if (stream == null) return; + using var reader = new StreamReader(stream); + try { runtime_set_bundle_key(reader.ReadToEnd().Trim()); } + catch (EntryPointNotFoundException) { } + } + public void Initialize() { if (_initialized) return; @@ -259,6 +264,7 @@ public void Initialize() try { attached = AttachConsole(ATTACH_PARENT_PROCESS); } catch { } runtime_install_ctrlc_handler(0); runtime_set_local_folder(LocalFolderPath); + ApplyBundleKey(); _runtime = runtime_init(AppContext.BaseDirectory); #if DEBUG TrySetDebugBuild(); diff --git a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj index 46fb7b7..42af8fb 100644 --- a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj +++ b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj @@ -87,6 +87,9 @@ PreserveNewest + + PreserveNewest From 7f82c69157816685495e7ec65a5e7b045df85442 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 29 Oct 2026 23:20:02 -0400 Subject: [PATCH 5/6] chore(template): drop the privateNetworkClientServer capability A runFullTrust app doesn't run in an AppContainer, so the capability gated nothing and only added a "home or work networks" disclosure to the Store listing. internetClient stays; an app or plugin that wants the capability can add it through the manifest merger. --- template/framework/__PROJECT_NAME__/Package.appxmanifest | 1 - 1 file changed, 1 deletion(-) diff --git a/template/framework/__PROJECT_NAME__/Package.appxmanifest b/template/framework/__PROJECT_NAME__/Package.appxmanifest index c72e764..e43dca5 100644 --- a/template/framework/__PROJECT_NAME__/Package.appxmanifest +++ b/template/framework/__PROJECT_NAME__/Package.appxmanifest @@ -30,6 +30,5 @@ - From 11b0bb96b4a124355486716da9b8d08d8c72876f Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 29 Oct 2026 23:20:18 -0400 Subject: [PATCH 6/6] fix(runtime): harden marshaling, release logging and remote loading Marshaling (both the classic and napi engines): - A pointer comes only from an External, a BigInt or null. A Number or string `handle` or `__native_ptr` is refused, since JSON can carry those: `{"handle": 1094795585}` passed to an Object parameter used to be AddRef'd as a COM pointer. - Interface and class parameters QueryInterface every pointer, boxed primitives included, and no longer fall back to the raw pointer when that fails, so a string passed as a Uri can't be called through the wrong vtable. Delegate parameters accept JS delegates the runtime made and QueryInterface anything else; GenericMethodCall fails instead of calling a slot on the wrong interface. - Shapes the call layout can't express (non-byte arrays, received arrays, array returns, structs larger than the return buffer, JS delegates taking arrays) fail at construction instead of corrupting the libffi call. - Struct buffers must cover the struct, serialized structs are padded to their ABI size, and IReference struct fields are pointer-sized. - Buffer arguments are rechecked before the call, since parsing a later argument can run JS that detaches or resizes them. - Only two-field wrappers are read as a DeclarationFFI, and URL accessors check their receiver, so a URL passed to WinRT or `instanceof` is no longer read as the wrong type. Release builds keep app console output out of the Windows Event Log and the on-disk trace log, and the trace log no longer falls back to a hardcoded user directory. Remote modules: allowlist entries match by origin and path boundary rather than string prefix, the response's final URL must also pass the gate after redirects, the gate reads the entry's package.json for script entries too, and NSWinRT.dynamicImport goes through the native loader. WebSocket subprotocols must be HTTP tokens and a message is capped at 100 MiB. Also: crypto.getRandomValues and crypto.randomUUID backed by the OS RNG, and NSWinRT.win32.bind loads a bare DLL name from the app directory and System32 only. --- packages/test/security-check.js | 65 ++++++++++ runtime/src/esm_http.rs | 56 ++++++++- runtime/src/esm_http_test.rs | 16 +++ runtime/src/generic_method_call.rs | 52 ++++++-- runtime/src/global_fns.rs | 69 ++++------- runtime/src/globals/console.rs | 4 + runtime/src/globals/url.rs | 132 +++++++++++++------- runtime/src/helpers.rs | 26 ++++ runtime/src/interop_test.rs | 128 +++++++++++++++++++ runtime/src/js/crypto.js | 48 ++++++++ runtime/src/lib.rs | 132 +++++++++++++------- runtime/src/method_call.rs | 116 ++++++++++++----- runtime/src/napi_engine/delegate.rs | 17 +++ runtime/src/napi_engine/interop.rs | 29 ++++- runtime/src/napi_engine/value.rs | 97 ++++++--------- runtime/src/ns_proxy.rs | 22 ++-- runtime/src/property_call.rs | 111 ++++++++++++++--- runtime/src/value.rs | 185 ++++++++++++---------------- runtime/src/websocket.rs | 11 ++ runtime/src/win32.rs | 13 +- runtime/src/winhttp.rs | 46 ++++++- windows-napi/index.d.ts | 4 +- windows-napi/src/value_test.rs | 6 +- windows-napi/test/value-test2.js | 14 ++- 24 files changed, 1004 insertions(+), 395 deletions(-) create mode 100644 packages/test/security-check.js create mode 100644 runtime/src/js/crypto.js diff --git a/packages/test/security-check.js b/packages/test/security-check.js new file mode 100644 index 0000000..ce1170c --- /dev/null +++ b/packages/test/security-check.js @@ -0,0 +1,65 @@ +// Standalone-engine check that untrusted-shaped values can't reach native code as pointers or +// mistyped objects, and that the safe-looking APIs handle views, NULs and randomness correctly: +// nativescript-windows packages/test/security-check.js +// Prints "security-check: N passed, M failed"; throws (nonzero exit) on any failure. +'use strict'; + +var pass = 0, fail = 0; +function check(name, ok) { + if (ok) { pass += 1; } + else { fail += 1; console.error('FAIL ' + name); } +} +function throws(fn) { + try { fn(); } catch (_) { return true; } + return false; +} + +// JSON can carry numbers and strings, which must never be read as native pointers. +var ps = new Windows.Foundation.Collections.PropertySet(); +ps.Insert('ok', 'a string boxes fine'); +check('string boxes', ps.Lookup('ok') === 'a string boxes fine'); +['{"handle": 1094795585}', '{"handle": "0x41414141"}', '{"__native_ptr": "0x4141414141"}', + '{"__native_ptr": 1094795585}'].forEach(function (json) { + check('rejects ' + json, throws(function () { ps.Insert('k', JSON.parse(json)); })); +}); + +// Interface parameters only take objects implementing the interface. +var client = new Windows.Web.Http.HttpClient(); +client.GetStringAsync(new Windows.Foundation.Uri('https://example.invalid/')).Cancel(); +check('string is not a Uri', throws(function () { client.GetStringAsync('https://example.com'); })); +check('number is not a Uri', throws(function () { client.GetStringAsync(42); })); +check('PropertySet is not a Uri', throws(function () { client.GetStringAsync(new Windows.Foundation.Collections.PropertySet()); })); + +// Struct buffers must cover the struct. +var PV = Windows.Foundation.PropertyValue; +check('8-byte Point accepted', !throws(function () { PV.CreatePoint(new Float32Array([1, 2])); })); +check('2-byte Point rejected', throws(function () { PV.CreatePoint(new Uint8Array(2)); })); + +// Delegate parameters only take delegates. +var pool = Windows.System.Threading.ThreadPool; +check('function delegate', !throws(function () { pool.RunAsync(function () {}); })); +check('asDelegate handle', !throws(function () { + pool.RunAsync(NSWinRT.asDelegate('Windows.System.Threading.WorkItemHandler', function () {})); +})); +check('PropertySet is not a delegate', throws(function () { pool.RunAsync(new Windows.Foundation.Collections.PropertySet()); })); + +// A view's bytes start at its byteOffset. +var CB = Windows.Security.Cryptography.CryptographicBuffer; +var bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); +var hex = CB.EncodeToHexString(CB.CreateFromByteArray(bytes.subarray(4))); +check('view offset honored (' + hex + ')', hex === '05060708'); + +// Strings keep embedded NULs. +var withNul = Windows.Data.Json.JsonValue.CreateStringValue('a\u0000b').GetString(); +check('embedded NUL kept', withNul.length === 3); + +// Web Crypto randomness. +var r1 = crypto.getRandomValues(new Uint8Array(32)); +var r2 = crypto.getRandomValues(new Uint8Array(32)); +check('random fills differ', r1.some(function (v, i) { return v !== r2[i]; })); +check('float array rejected', throws(function () { crypto.getRandomValues(new Float32Array(2)); })); +check('quota enforced', throws(function () { crypto.getRandomValues(new Uint8Array(65537)); })); +check('randomUUID v4', /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(crypto.randomUUID())); + +console.log('security-check: ' + pass + ' passed, ' + fail + ' failed'); +if (fail) { throw new Error('security-check: ' + fail + ' failed'); } diff --git a/runtime/src/esm_http.rs b/runtime/src/esm_http.rs index d48d8de..facbf63 100644 --- a/runtime/src/esm_http.rs +++ b/runtime/src/esm_http.rs @@ -258,13 +258,27 @@ pub(crate) fn mark_keys_for_cache_bust(keys: &[String]) { /// Remote-module gate: debug hosts (`set_debug_build`) always allow; release builds need /// `security.allowRemoteModules` in the app's package.json, then an allowlist match -/// (`security.remoteModuleAllowlist` URL prefixes; empty allows every URL). +/// (`security.remoteModuleAllowlist` URLs; empty allows every URL). pub(crate) fn remote_url_allowed(url: &str) -> bool { if DEBUG_BUILD.load(std::sync::atomic::Ordering::Relaxed) || cfg!(test) { return true; } let (allowed, allowlist) = SECURITY.get().cloned().unwrap_or((false, Vec::new())); - allowed && (allowlist.is_empty() || allowlist.iter().any(|p| url.starts_with(p.as_str()))) + allowed && (allowlist.is_empty() || allowlist.iter().any(|entry| allowlist_matches(url, entry))) +} + +/// An allowlist entry admits URLs with its scheme, host and port whose path is the entry's path +/// or lies under it, so `https://cdn.example.com` doesn't admit `https://cdn.example.com.evil`. +pub(crate) fn allowlist_matches(url: &str, entry: &str) -> bool { + let (Ok(url), Ok(entry)) = (url::Url::parse(url), url::Url::parse(entry)) else { + return false; + }; + if url.origin() != entry.origin() { + return false; + } + let prefix = entry.path().trim_end_matches('/'); + let path = url.path(); + path == prefix || (path.starts_with(prefix) && path[prefix.len()..].starts_with('/')) } static SECURITY: OnceLock<(bool, Vec)> = OnceLock::new(); @@ -277,6 +291,11 @@ pub fn set_debug_build(debug: bool) { DEBUG_BUILD.store(debug, std::sync::atomic::Ordering::Relaxed); } +/// Whether the host marked itself as a debug build via [`set_debug_build`]. +pub(crate) fn is_debug_build() -> bool { + DEBUG_BUILD.load(std::sync::atomic::Ordering::Relaxed) +} + /// Read the release-build security settings from the app's package.json, once. pub(crate) fn init_security_from_app_dir(app_dir: &std::path::Path) { let _ = SECURITY.get_or_init(|| { @@ -340,6 +359,13 @@ pub(crate) fn fetch_module(url: &str, key: &str) -> Result String { mod tests { use super::*; + #[test] + fn allowlist_matches_origin_and_path_boundary() { + let cdn = "https://cdn.example.com"; + assert!(allowlist_matches("https://cdn.example.com/a.js", cdn)); + assert!(allowlist_matches("https://cdn.example.com:443/a.js", cdn)); + assert!(!allowlist_matches("https://cdn.example.com.evil.net/a.js", cdn)); + assert!(!allowlist_matches("https://cdn.example.com@evil.net/a.js", cdn)); + assert!(!allowlist_matches("http://cdn.example.com/a.js", cdn)); + assert!(!allowlist_matches("https://cdn.example.com:8443/a.js", cdn)); + let lib = "https://cdn.example.com/lib/"; + assert!(allowlist_matches("https://cdn.example.com/lib/a.js", lib)); + assert!(allowlist_matches("https://cdn.example.com/lib", lib)); + assert!(!allowlist_matches("https://cdn.example.com/library.js", "https://cdn.example.com/lib")); + assert!(!allowlist_matches("https://cdn.example.com/lib/../evil.js", lib)); + assert!(!allowlist_matches("", lib)); + } + + #[test] + fn websocket_subprotocols_must_be_tokens() { + assert!(crate::winhttp::is_http_token("graphql-ws")); + assert!(crate::winhttp::is_http_token("v1.chat")); + assert!(!crate::winhttp::is_http_token("")); + assert!(!crate::winhttp::is_http_token("a\r\nX-Injected: 1")); + assert!(!crate::winhttp::is_http_token("a, b")); + } + #[test] fn canonical_keys_follow_the_configured_vocabulary() { clear_thread_vocabulary(); diff --git a/runtime/src/esm_http_test.rs b/runtime/src/esm_http_test.rs index 4580d1c..2a99554 100644 --- a/runtime/src/esm_http_test.rs +++ b/runtime/src/esm_http_test.rs @@ -294,3 +294,19 @@ fn native_websocket_round_trip() { ); assert_eq!(refused, "ok:1006:false"); } + +#[test] +fn legacy_dynamic_import_uses_the_module_loader() { + let server = TestServer::start(); + let o = server.origin.clone(); + server.route("/remote/mod.js", "text/javascript", "export default 'remote'; export const n = 2;"); + server.route("/remote/style.css", "text/css", "body{}"); + + let mut runtime = Runtime::new("."); + runtime.register_delegate_isolate_ptr(); + let got = await_js(&mut runtime, &format!("NSWinRT.dynamicImport('{o}/remote/mod.js').then(m => m.default + m.n)")); + assert_eq!(got, "ok:remote2"); + // The loader's MIME check applies too. + let css = await_js(&mut runtime, &format!("NSWinRT.dynamicImport('{o}/remote/style.css')")); + assert!(css.starts_with("err:"), "{css}"); +} diff --git a/runtime/src/generic_method_call.rs b/runtime/src/generic_method_call.rs index 6724fb7..1db2230 100644 --- a/runtime/src/generic_method_call.rs +++ b/runtime/src/generic_method_call.rs @@ -44,9 +44,14 @@ pub struct GenericMethodCall { /// Pre-computed interface IIDs for pointer-typed parameters (one per parse param, None if no QI needed). parameter_arg_iids: Vec>, /// Scratch buffer for value-type returns (same role as in MethodCall). - return_value_buf: [u8; 128], + return_value_buf: [u8; crate::helpers::RETURN_BUFFER_SIZE], + /// Set when the call can't be made safely; `call` then fails without touching the vtable. + init_error: Option, } +const E_NOINTERFACE: HRESULT = HRESULT(0x8000_4002u32 as i32); +const E_NOTIMPL: HRESULT = HRESULT(0x8000_4001u32 as i32); + #[inline] fn call_failure() -> HRESULT { // E_FAIL @@ -93,9 +98,12 @@ impl GenericMethodCall { ) }; - // Generic interface QI is best-effort: fall back to the original interface - // when the object doesn't implement the expected instantiation. + // Without the interface the vtable slot belongs to some other method, so the call fails. let qi_ok = result.is_ok() && !interface_ptr.is_null(); + let mut init_error = (!qi_ok).then_some(E_NOINTERFACE); + if !crate::helpers::return_layout_supported(&return_type) { + init_error = Some(E_NOTIMPL); + } let is_composition = !is_sealed; @@ -126,6 +134,9 @@ impl GenericMethodCall { let metadata = parameter.metadata().unwrap(); let signature = Signature::to_string(metadata, &type_); + if !crate::helpers::parameter_layout_supported(&signature) { + init_error = Some(E_NOTIMPL); + } let parse_native_type = NativeType::try_from(signature.as_str()); assert!(parse_native_type.is_ok()); @@ -207,19 +218,26 @@ impl GenericMethodCall { .iter() .cloned() .map(libffi::middle::Type::try_from) - .collect::, AnyError>>(); + .collect::, AnyError>>() + .unwrap_or_else(|_| { + init_error = Some(E_NOTIMPL); + Vec::new() + }); - assert!(params.is_ok()); - - let cif = Cif::new(params.unwrap(), Type::i32()); + let cif = Cif::new(params, Type::i32()); let effective_interface = if qi_ok { unsafe { IUnknown::from_raw(interface_ptr) } } else { interface.clone() }; - let vtable: *mut *mut c_void = unsafe { std::mem::transmute(effective_interface.vtable()) }; - let func = unsafe { *vtable.offset(index as isize) }; + let func = if qi_ok { + let vtable: *mut *mut c_void = + unsafe { std::mem::transmute(effective_interface.vtable()) }; + unsafe { *vtable.offset(index as isize) } + } else { + std::ptr::null_mut() + }; Self { cif, @@ -237,7 +255,8 @@ impl GenericMethodCall { parameters: method.parameters().to_vec(), return_type, parameter_arg_iids, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], + init_error, } } @@ -246,6 +265,9 @@ impl GenericMethodCall { scope: &mut v8::PinScope<'s, '_>, args: &v8::FunctionCallbackArguments, ) -> (HRESULT, *mut c_void, Vec>) { + if let Some(hr) = self.init_error { + return (hr, std::ptr::null_mut(), Vec::new()); + } let number_of_abi_parameters = self.number_of_abi_parameters; let mut arguments: Vec = Vec::with_capacity(number_of_abi_parameters); // Track parse-level types for each ABI argument slot. @@ -267,6 +289,7 @@ impl GenericMethodCall { }); argument_parse_types.push(None); + let mut buffer_args = Vec::new(); for (i, native_type) in self.parse_parameter_types.iter().enumerate() { let parameter = &self.parameters[i]; let param_sig_opt = parameter @@ -340,7 +363,7 @@ impl GenericMethodCall { Ok(pointer) } Ok((pointer, None)) => Ok(pointer), - Err(_) => ffi_parse_pointer_arg(scope, value), + Err(error) => Err(error), } } else { ffi_parse_pointer_arg(scope, value) @@ -352,6 +375,7 @@ impl GenericMethodCall { Ok(value) => value, Err(_) => return (call_failure(), std::ptr::null_mut(), Vec::new()), }; + buffer_args.push((value, unsafe { buffer_value.pointer }, byte_length)); arguments.push(NativeValue { u32_value: byte_length, @@ -362,7 +386,7 @@ impl GenericMethodCall { continue; } NativeType::Function => ffi_parse_function_arg(scope, value), - NativeType::Struct(_) => ffi_parse_struct_arg(scope, value), + NativeType::Struct(_) => ffi_parse_struct_arg(scope, value, native_type.size()), NativeType::String => ffi_parse_string_arg(scope, value), }; @@ -430,6 +454,10 @@ impl GenericMethodCall { } } + if !crate::value::buffers_unchanged(scope, &buffer_args) { + return (call_failure(), std::ptr::null_mut(), Vec::new()); + } + let prep = match crate::ffi::prepare_string_storage( &arguments, &self.parameter_types, diff --git a/runtime/src/global_fns.rs b/runtime/src/global_fns.rs index e354180..b70e9ba 100644 --- a/runtime/src/global_fns.rs +++ b/runtime/src/global_fns.rs @@ -2090,47 +2090,9 @@ const HELPER_SOURCE: &str = concat!(r#" }); } - try { - var sec = {}; - try { - var pkgText = globalThis.__nsReadTextFile( - (globalThis.__nsAppRoot || '') + '/package.json' - ); - sec = ((JSON.parse(pkgText) || {}).nativescript || {}).security || {}; - } catch (_) {} - - if (!sec.allowRemoteModules) { - return Promise.reject(new Error( - 'Remote module imports are disabled. ' + - 'Set nativescript.security.allowRemoteModules=true in package.json' - )); - } - - var allowlist = sec.remoteModuleAllowlist; - if (Array.isArray(allowlist) && allowlist.length > 0) { - var isAllowed = allowlist.some(function (p) { - return typeof p === 'string' && specifier.startsWith(p); - }); - if (!isAllowed) { - return Promise.reject(new Error( - 'Remote module URL is not in the allowlist: ' + specifier - )); - } - } - - if (moduleCache.has(specifier)) { - return Promise.resolve(moduleCache.get(specifier)); - } - - var uri = new Windows.Foundation.Uri(specifier); - var client = new Windows.Web.Http.HttpClient(); - - return NSWinRT.toPromise(client.getStringAsync(uri)).then(function (source) { - return executeRuntimeModule(String(source), specifier); - }); - } catch (e) { - return Promise.reject(e instanceof Error ? e : new Error(String(e))); - } + // Remote modules go through the native ES module loader: its gate applies the app's + // `security` settings in release builds and re-checks where redirects lead. + return import(specifier); } globalThis.__nsInvalidateModuleCacheEntry = function (resolvedPath) { @@ -2382,7 +2344,7 @@ const HELPER_SOURCE: &str = concat!(r#" globalThis.URLPattern = URLPattern; })(); -"#, include_str!("js/dotnet.js"), r#" +"#, include_str!("js/dotnet.js"), "\n", include_str!("js/crypto.js"), r#" (function () { if (typeof globalThis.__nsWin32Call !== 'function') return; @@ -4019,6 +3981,28 @@ pub(crate) fn handle_tns_uptime( retval.set_double(ts); } +/// `__nsRandomFill(view)`: fills an ArrayBufferView from the OS RNG, for `crypto.getRandomValues`. +pub(crate) fn handle_ns_random_fill( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + let Ok(view) = v8::Local::::try_from(args.get(0)) else { + crate::throw_js_error(scope, "__nsRandomFill expects an ArrayBufferView"); + return; + }; + let length = view.byte_length(); + let Some(data) = view.buffer(scope).and_then(|buffer| buffer.data()) else { + return; + }; + let bytes = unsafe { + std::slice::from_raw_parts_mut((data.as_ptr() as *mut u8).add(view.byte_offset()), length) + }; + if getrandom::getrandom(bytes).is_err() { + crate::throw_js_error(scope, "The OS random number generator is unavailable"); + } +} + pub(crate) fn handle_ns_uuid( scope: &mut v8::PinScope<'_, '_>, _args: v8::FunctionCallbackArguments, @@ -4401,6 +4385,7 @@ pub(crate) fn init_async_helpers( register!("__nsRequestFrame", crate::animation_frames::handle_request_frame); register!("__tns_uptime", handle_tns_uptime); register!("__nsUUID", handle_ns_uuid); + register!("__nsRandomFill", handle_ns_random_fill); register!("__nsIsUiThread", handle_is_ui_thread); register!("__nsThreadInfo", handle_thread_info); register!("__nsGetLastJsError", handle_get_last_js_error); diff --git a/runtime/src/globals/console.rs b/runtime/src/globals/console.rs index e691506..fc953dc 100644 --- a/runtime/src/globals/console.rs +++ b/runtime/src/globals/console.rs @@ -441,6 +441,10 @@ pub(crate) fn write_console(value: &str) { if crate::is_log_to_console() || std::env::var("NS_DEBUG").is_ok() { crate::debug_output(value); } + // App console output can carry user data: release builds keep it out of the Event Log. + if !crate::esm_http::is_debug_build() { + return; + } let event_type: REPORT_EVENT_TYPE = if value.starts_with("[ERROR]") { EVENTLOG_ERROR_TYPE } else if value.starts_with("[WARN]") { diff --git a/runtime/src/globals/url.rs b/runtime/src/globals/url.rs index e6612f6..34c78ea 100644 --- a/runtime/src/globals/url.rs +++ b/runtime/src/globals/url.rs @@ -1,4 +1,5 @@ use std::cell::RefCell; +use std::ffi::c_void; use url::Url; use v8::FunctionTemplate; @@ -12,12 +13,24 @@ pub(crate) fn clear_thread_url_ctor() { URL_CTOR.with(|c| *c.borrow_mut() = None); } +#[repr(C)] struct UrlData { + tag: DataTag, url: Url, } +/// First field of both data types, so an accessor can check which one an object carries. +#[repr(u32)] +#[derive(PartialEq, Eq, Clone, Copy)] +enum DataTag { + Url = 0x5552_4c31, + SearchParams = 0x5553_5031, +} + // URLSearchParams stores pairs as a Vec to preserve insertion order and allow duplicates. +#[repr(C)] struct SearchParamsData { + tag: DataTag, pairs: Vec<(String, String)>, } @@ -27,7 +40,7 @@ impl SearchParamsData { let pairs = form_urlencoded::parse(s.as_bytes()) .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect(); - Self { pairs } + Self { tag: DataTag::SearchParams, pairs } } fn to_string_value(&self) -> String { @@ -107,20 +120,37 @@ impl RetainAtPosition for Vec<(String, String)> { } } +/// The data pointer of a URL or URLSearchParams object, or None (with a TypeError thrown) for any +/// other receiver. Both templates have exactly one internal field; WinRT wrappers have two. +fn tagged_data_ptr( + scope: &mut v8::PinScope<'_, '_>, + obj: v8::Local, + tag: DataTag, +) -> Option<*mut c_void> { + let ptr = (obj.internal_field_count() == 1) + .then(|| obj.get_internal_field(scope, 0)) + .flatten() + .and_then(|field| v8::Local::::try_from(field).ok()) + .map(|ext| ext.value()) + .filter(|ptr| !ptr.is_null() && unsafe { *(*ptr as *const DataTag) } == tag); + if ptr.is_none() { + throw_type_error(scope, "Illegal invocation"); + } + ptr +} + unsafe fn url_data_ptr( scope: &mut v8::PinScope<'_, '_>, obj: v8::Local, -) -> *mut UrlData { - let field = obj.get_internal_field(scope, 0).unwrap(); - unsafe { field.cast::() }.value() as *mut UrlData +) -> Option<*mut UrlData> { + tagged_data_ptr(scope, obj, DataTag::Url).map(|ptr| ptr as *mut UrlData) } unsafe fn sp_data_ptr( scope: &mut v8::PinScope<'_, '_>, obj: v8::Local, -) -> *mut SearchParamsData { - let field = obj.get_internal_field(scope, 0).unwrap(); - unsafe { field.cast::() }.value() as *mut SearchParamsData +) -> Option<*mut SearchParamsData> { + tagged_data_ptr(scope, obj, DataTag::SearchParams).map(|ptr| ptr as *mut SearchParamsData) } fn to_v8_str<'s>(scope: &mut v8::PinScope<'s, '_>, s: &str) -> v8::Local<'s, v8::Value> { @@ -184,6 +214,11 @@ fn url_constructor( args: v8::FunctionCallbackArguments, _rv: v8::ReturnValue, ) { + // Without `new`, `this` is the receiver, which has no internal field to hold the data. + if !args.is_construct_call() { + throw_type_error(scope, "Failed to construct 'URL': Please use the 'new' operator"); + return; + } let Some(input) = arg_str(scope, &args, 0) else { throw_type_error(scope, "Failed to construct 'URL': 1 argument required"); return; @@ -195,7 +230,7 @@ fn url_constructor( }; match parse_url(&input, base.as_deref()) { Ok(url) => { - let data = Box::into_raw(Box::new(UrlData { url })); + let data = Box::into_raw(Box::new(UrlData { tag: DataTag::Url, url })); args.this() .set_internal_field(0, v8::External::new(scope, data as _).into()); } @@ -213,7 +248,7 @@ fn url_href_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { (*d).url.as_str().to_owned() }; rv.set(to_v8_str(scope, &s)); } @@ -222,7 +257,7 @@ fn url_protocol_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { whatwg_protocol(&(*d).url) }; rv.set(to_v8_str(scope, &s)); } @@ -231,7 +266,7 @@ fn url_username_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { (*d).url.username().to_owned() }; rv.set(to_v8_str(scope, &s)); } @@ -240,7 +275,7 @@ fn url_password_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { (*d).url.password().unwrap_or("").to_owned() }; rv.set(to_v8_str(scope, &s)); } @@ -249,7 +284,7 @@ fn url_host_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { whatwg_host(&(*d).url) }; rv.set(to_v8_str(scope, &s)); } @@ -258,7 +293,7 @@ fn url_hostname_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { (*d).url.host_str().unwrap_or("").to_owned() }; rv.set(to_v8_str(scope, &s)); } @@ -267,7 +302,7 @@ fn url_port_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { whatwg_port(&(*d).url) }; rv.set(to_v8_str(scope, &s)); } @@ -276,7 +311,7 @@ fn url_pathname_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { (*d).url.path().to_owned() }; rv.set(to_v8_str(scope, &s)); } @@ -285,7 +320,7 @@ fn url_search_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { whatwg_search(&(*d).url) }; rv.set(to_v8_str(scope, &s)); } @@ -294,7 +329,7 @@ fn url_hash_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { whatwg_hash(&(*d).url) }; rv.set(to_v8_str(scope, &s)); } @@ -303,7 +338,7 @@ fn url_origin_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { (*d).url.origin().ascii_serialization() }; rv.set(to_v8_str(scope, &s)); } @@ -316,7 +351,7 @@ fn url_href_set( let val = arg_str(scope, &args, 0).unwrap_or_default(); match Url::parse(&val) { Ok(new_url) => { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; unsafe { (*d).url = new_url }; } Err(_) => throw_type_error( @@ -333,7 +368,7 @@ fn url_protocol_set( ) { let val = arg_str(scope, &args, 0).unwrap_or_default(); let scheme = val.trim_end_matches(':'); - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let _ = unsafe { (*d).url.set_scheme(scheme) }; } @@ -343,7 +378,7 @@ fn url_username_set( _rv: v8::ReturnValue, ) { let val = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let _ = unsafe { (*d).url.set_username(&val) }; } @@ -353,7 +388,7 @@ fn url_password_set( _rv: v8::ReturnValue, ) { let val = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let pass = if val.is_empty() { None } else { @@ -368,7 +403,7 @@ fn url_host_set( _rv: v8::ReturnValue, ) { let val = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; // val may contain "hostname:port"; set_host handles this let _ = unsafe { (*d).url @@ -382,7 +417,7 @@ fn url_hostname_set( _rv: v8::ReturnValue, ) { let val = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let _ = unsafe { (*d).url .set_host(if val.is_empty() { None } else { Some(&val) }) @@ -395,7 +430,7 @@ fn url_port_set( _rv: v8::ReturnValue, ) { let val = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let port: Option = val.parse().ok(); let _ = unsafe { (*d).url.set_port(port) }; } @@ -406,7 +441,7 @@ fn url_pathname_set( _rv: v8::ReturnValue, ) { let val = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; unsafe { (*d).url.set_path(&val) }; } @@ -416,7 +451,7 @@ fn url_search_set( _rv: v8::ReturnValue, ) { let val = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let q = val.trim_start_matches('?'); unsafe { (*d).url @@ -430,7 +465,7 @@ fn url_hash_set( _rv: v8::ReturnValue, ) { let val = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let f = val.trim_start_matches('#'); unsafe { (*d).url @@ -443,7 +478,7 @@ fn url_to_string( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { url_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { url_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { (*d).url.as_str().to_owned() }; rv.set(to_v8_str(scope, &s)); } @@ -506,6 +541,13 @@ fn sp_constructor( args: v8::FunctionCallbackArguments, _rv: v8::ReturnValue, ) { + if !args.is_construct_call() { + throw_type_error( + scope, + "Failed to construct 'URLSearchParams': Please use the 'new' operator", + ); + return; + } let params = if args.length() == 0 || args.get(0).is_undefined() || args.get(0).is_null() { SearchParamsData::from_str("") } else { @@ -518,7 +560,7 @@ fn sp_constructor( SearchParamsData::from_str(&s) } else if init.is_array() { let arr = v8::Local::::try_from(init).unwrap(); - let mut p = SearchParamsData { pairs: Vec::new() }; + let mut p = SearchParamsData { tag: DataTag::SearchParams, pairs: Vec::new() }; for i in 0..arr.length() { let Some(item) = arr.get_index(scope, i) else { continue; @@ -540,7 +582,7 @@ fn sp_constructor( } p } else if init.is_object() { - let mut p = SearchParamsData { pairs: Vec::new() }; + let mut p = SearchParamsData { tag: DataTag::SearchParams, pairs: Vec::new() }; if let Some(json) = v8::json::stringify(scope, init) { let s = json.to_rust_string_lossy(scope); if let Ok(map) = @@ -578,7 +620,7 @@ fn sp_append( ) { let k = arg_str(scope, &args, 0).unwrap_or_default(); let v = arg_str(scope, &args, 1).unwrap_or_default(); - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; unsafe { (*d).append(&k, &v) }; } @@ -588,7 +630,7 @@ fn sp_delete( _rv: v8::ReturnValue, ) { let k = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; if args.length() > 1 && !args.get(1).is_undefined() { let v = arg_str(scope, &args, 1).unwrap_or_default(); unsafe { (*d).remove_pair(&k, &v) }; @@ -603,7 +645,7 @@ fn sp_get( mut rv: v8::ReturnValue, ) { let k = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; match unsafe { (*d).get(&k) } { Some(s) => rv.set(to_v8_str(scope, s)), None => rv.set(v8::null(scope).into()), @@ -616,7 +658,7 @@ fn sp_get_all( mut rv: v8::ReturnValue, ) { let k = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; let all: Vec = unsafe { (*d).get_all(&k) } .iter() .map(|s| s.to_string()) @@ -635,7 +677,7 @@ fn sp_has( mut rv: v8::ReturnValue, ) { let k = arg_str(scope, &args, 0).unwrap_or_default(); - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; let result = if args.length() > 1 && !args.get(1).is_undefined() { let v = arg_str(scope, &args, 1).unwrap_or_default(); unsafe { (*d).has_pair(&k, &v) } @@ -652,7 +694,7 @@ fn sp_set( ) { let k = arg_str(scope, &args, 0).unwrap_or_default(); let v = arg_str(scope, &args, 1).unwrap_or_default(); - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; unsafe { (*d).set(&k, &v) }; } @@ -661,7 +703,7 @@ fn sp_sort( args: v8::FunctionCallbackArguments, _rv: v8::ReturnValue, ) { - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; unsafe { (*d).sort() }; } @@ -670,7 +712,7 @@ fn sp_to_string( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; let s = unsafe { (*d).to_string_value() }; rv.set(to_v8_str(scope, &s)); } @@ -680,7 +722,7 @@ fn sp_size_get( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; rv.set_int32(unsafe { (*d).pairs.len() } as i32); } @@ -698,7 +740,7 @@ fn sp_for_each( } else { v8::undefined(scope).into() }; - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; let entries: Vec<(String, String)> = unsafe { (*d).pairs.clone() }; let sp = args.this(); for (k, v) in &entries { @@ -714,7 +756,7 @@ fn sp_keys( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; let keys: Vec = unsafe { (*d).pairs.iter().map(|(k, _)| k.clone()).collect() }; let arr = v8::Array::new(scope, keys.len() as i32); for (i, k) in keys.iter().enumerate() { @@ -729,7 +771,7 @@ fn sp_values( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; let vals: Vec = unsafe { (*d).pairs.iter().map(|(_, v)| v.clone()).collect() }; let arr = v8::Array::new(scope, vals.len() as i32); for (i, val) in vals.iter().enumerate() { @@ -744,7 +786,7 @@ fn sp_entries( args: v8::FunctionCallbackArguments, mut rv: v8::ReturnValue, ) { - let d = unsafe { sp_data_ptr(scope, args.this()) }; + let Some(d) = (unsafe { sp_data_ptr(scope, args.this()) }) else { return }; let pairs: Vec<(String, String)> = unsafe { (*d).pairs.clone() }; let arr = v8::Array::new(scope, pairs.len() as i32); for (i, (k, v)) in pairs.iter().enumerate() { diff --git a/runtime/src/helpers.rs b/runtime/src/helpers.rs index 9751b5c..5055b54 100644 --- a/runtime/src/helpers.rs +++ b/runtime/src/helpers.rs @@ -103,6 +103,32 @@ pub fn ireference_inner_type(sig: &str) -> Option<&str> { rest.strip_suffix('>') } +/// Whether the call layout can carry a parameter of this signature. The ABI passes an array as +/// `(count, pointer)`, or pointers to both for a received (`ByRef`) array. Only byte arrays get +/// that pair here, and any other array would shift every later argument. +pub(crate) fn parameter_layout_supported(signature: &str) -> bool { + match signature.trim().strip_suffix("[]") { + None => true, + Some(element) => matches!(element, "UInt8" | "Uint8" | "Int8" | "Byte" | "SByte"), + } +} + +/// Size of the scratch buffer a call writes its value-type return into. +pub(crate) const RETURN_BUFFER_SIZE: usize = 128; + +/// Whether the call layout can carry a return of this type: an array return takes two out slots, +/// and a value type must fit the return buffer. +pub(crate) fn return_layout_supported(return_type: &str) -> bool { + let return_type = return_type.trim(); + if return_type.ends_with("[]") { + return false; + } + let size = struct_native_type_for_sig(return_type) + .map_or(0, |t| t.size()) + .max(crate::property_call::sig_size_align_pub(return_type).0); + size <= RETURN_BUFFER_SIZE +} + /// Shared mapping from WinRT signature string to FFI `NativeType`. /// Used by `MethodCall` and `PropertyCall` during construction. #[inline] diff --git a/runtime/src/interop_test.rs b/runtime/src/interop_test.rs index ffd5233..18a1e3d 100644 --- a/runtime/src/interop_test.rs +++ b/runtime/src/interop_test.rs @@ -2741,3 +2741,131 @@ fn ireference_struct_parameters() { "#, ); } + +#[test] +fn json_values_never_become_native_pointers() { + run_js_assert( + "json_values_never_become_native_pointers", + r#" + const ps = new Windows.Foundation.Collections.PropertySet(); + ps.Insert('ok', 'a string boxes fine'); + const payloads = [ + '{"handle": 1094795585}', + '{"handle": "0x41414141"}', + '{"__native_ptr": "0x4141414141"}', + '{"__native_ptr": 1094795585}', + ]; + for (const json of payloads) { + let threw = false; + try { ps.Insert('k', JSON.parse(json)); } catch (_) { threw = true; } + if (!threw) throw new Error(`${json} was accepted as a WinRT object`); + } + "#, + ); +} + +#[test] +fn interface_arguments_must_implement_the_interface() { + run_js_assert( + "interface_arguments_must_implement_the_interface", + r#" + const client = new Windows.Web.Http.HttpClient(); + const op = client.GetStringAsync(new Windows.Foundation.Uri('https://example.invalid/')); + op.Cancel(); + const wrongTypes = [ + 'https://example.com', + 42, + new Windows.Foundation.Collections.PropertySet(), + new URL('https://example.com/'), + ]; + for (const arg of wrongTypes) { + let threw = false; + try { client.GetStringAsync(arg); } catch (_) { threw = true; } + if (!threw) throw new Error(`${String(arg)} was passed where a Uri is expected`); + } + "#, + ); +} + +#[test] +fn struct_buffers_must_cover_the_struct() { + run_js_assert( + "struct_buffers_must_cover_the_struct", + r#" + const PV = Windows.Foundation.PropertyValue; + PV.CreatePoint(new Float32Array([1, 2])); + let threw = false; + try { PV.CreatePoint(new Uint8Array(2)); } catch (_) { threw = true; } + if (!threw) throw new Error('a 2-byte buffer was accepted as an 8-byte Point'); + "#, + ); +} + +#[test] +fn url_accessors_check_their_receiver() { + run_js_assert( + "url_accessors_check_their_receiver", + r#" + const expectTypeError = (what, fn) => { + try { fn(); } catch (e) { + if (e instanceof TypeError) return; + throw new Error(`${what} threw ${e}`); + } + throw new Error(`${what} did not throw`); + }; + const href = Object.getOwnPropertyDescriptor(URL.prototype, 'href').get; + const size = Object.getOwnPropertyDescriptor(URLSearchParams.prototype, 'size').get; + expectTypeError('href on URLSearchParams', () => href.call(new URLSearchParams('a=b'))); + expectTypeError('href on URL.prototype', () => href.call(URL.prototype)); + expectTypeError('size on URL', () => size.call(new URL('https://a.example/'))); + expectTypeError('URL without new', () => URL('https://a.example/')); + expectTypeError('URLSearchParams without new', () => URLSearchParams('a=b')); + if (new URL('https://a.example/p?q=1').href !== 'https://a.example/p?q=1') throw new Error('href'); + if (new URLSearchParams('a=b&c=d').size !== 2) throw new Error('size'); + if (new URL('https://a.example/') instanceof Windows.Foundation.Uri) { + throw new Error('a URL was reported as a Windows.Foundation.Uri'); + } + "#, + ); +} + +#[test] +fn crypto_random_values_and_uuid() { + run_js_assert( + "crypto_random_values_and_uuid", + r#" + const a = crypto.getRandomValues(new Uint8Array(64)); + const b = crypto.getRandomValues(new Uint8Array(64)); + if (a.every((v, i) => v === b[i])) throw new Error('two fills were identical'); + const view = new Uint32Array(new ArrayBuffer(16), 4, 2); + if (crypto.getRandomValues(view) !== view) throw new Error('returns its argument'); + let threw = false; + try { crypto.getRandomValues(new Float32Array(4)); } catch (e) { threw = e instanceof TypeError; } + if (!threw) throw new Error('a float array was accepted'); + threw = false; + try { crypto.getRandomValues(new Uint8Array(65537)); } catch (_) { threw = true; } + if (!threw) throw new Error('more than 65536 bytes were accepted'); + const id = crypto.randomUUID(); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id)) { + throw new Error(`bad UUID ${id}`); + } + if (crypto.randomUUID() === id) throw new Error('UUIDs repeat'); + "#, + ); +} + +#[test] +fn delegate_arguments_must_be_delegates() { + run_js_assert( + "delegate_arguments_must_be_delegates", + r#" + const pool = Windows.System.Threading.ThreadPool; + const handler = NSWinRT.asDelegate('Windows.System.Threading.WorkItemHandler', () => {}); + pool.RunAsync(handler); + pool.RunAsync(() => {}); + let threw = false; + try { pool.RunAsync(new Windows.Foundation.Collections.PropertySet()); } catch (_) { threw = true; } + if (!threw) throw new Error('a PropertySet was passed as a WorkItemHandler'); + "#, + ); +} diff --git a/runtime/src/js/crypto.js b/runtime/src/js/crypto.js new file mode 100644 index 0000000..37a049c --- /dev/null +++ b/runtime/src/js/crypto.js @@ -0,0 +1,48 @@ +// Web Crypto's random source, filled from the OS RNG by `__nsRandomFill`. Hosts that already have +// `crypto` (Node, Deno, Bun) keep their own. +(function () { + var fill = globalThis.__nsRandomFill; + if (typeof fill !== 'function') return; + var crypto = globalThis.crypto || (globalThis.crypto = {}); + + function isIntegerArray(array) { + return array instanceof Int8Array || array instanceof Uint8Array || + array instanceof Uint8ClampedArray || array instanceof Int16Array || + array instanceof Uint16Array || array instanceof Int32Array || + array instanceof Uint32Array || + (typeof BigInt64Array === 'function' && + (array instanceof BigInt64Array || array instanceof BigUint64Array)); + } + + if (typeof crypto.getRandomValues !== 'function') { + crypto.getRandomValues = function getRandomValues(array) { + var prefix = "Failed to execute 'getRandomValues' on 'Crypto': "; + if (!isIntegerArray(array)) { + throw new TypeError(prefix + 'The provided ArrayBufferView is not an integer array.'); + } + if (array.byteLength > 65536) { + var message = prefix + "The ArrayBufferView's byte length (" + array.byteLength + + ') exceeds the number of bytes of entropy available via this API (65536).'; + throw typeof DOMException === 'function' + ? new DOMException(message, 'QuotaExceededError') + : new RangeError(message); + } + fill(array); + return array; + }; + } + + if (typeof crypto.randomUUID !== 'function') { + crypto.randomUUID = function randomUUID() { + var b = crypto.getRandomValues(new Uint8Array(16)); + b[6] = (b[6] & 0x0f) | 0x40; // version 4 + b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant + var hex = ''; + for (var i = 0; i < 16; i++) { + hex += (b[i] + 0x100).toString(16).slice(1); + if (i === 3 || i === 5 || i === 7 || i === 9) hex += '-'; + } + return hex; + }; + } +})(); diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 7b19d8a..1b1673f 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -1399,49 +1399,44 @@ fn symbol_has_instance_callback( let data_ptr = data_ext.value() as *const HasInstanceData; let data = unsafe { &*data_ptr }; - if let Some(dec_field) = obj.get_internal_field(scope, 0) { - if let Ok(dec_ext) = v8::Local::::try_from(dec_field) { - let dec_ptr = dec_ext.value() as *mut DeclarationFFI; - if !dec_ptr.is_null() { - let dec = unsafe { &*dec_ptr }; + if let Some(dec_ptr) = crate::ns_proxy::this_declaration_ffi(scope, obj) { + let dec = unsafe { &*dec_ptr }; - { - let lock = dec.read(); - if lock.full_name() == data.full_name.as_str() { - retval.set_bool(true); - return; - } - } - - let Some(iid) = data.iid else { - // A class: true when the instance's class derives from it - // (a StackPanel is a Panel, a FrameworkElement and a UIElement). - let own_class = dec.read().full_name().to_string(); - retval.set_bool(class_derives_from(&own_class, &data.full_name)); - return; - }; - let Some(instance) = dec.instance.clone() else { - retval.set_bool(false); - return; - }; - - let vtable = instance.vtable(); - let mut qi_ptr: *mut c_void = std::ptr::null_mut(); - let hr = unsafe { - ((*vtable).QueryInterface)( - instance.as_raw(), - &iid, - std::mem::transmute(&mut qi_ptr), - ) - }; - let implements = hr.is_ok() && !qi_ptr.is_null(); - if !qi_ptr.is_null() { - drop(unsafe { IUnknown::from_raw(qi_ptr) }); - } - retval.set_bool(implements); + { + let lock = dec.read(); + if lock.full_name() == data.full_name.as_str() { + retval.set_bool(true); return; } } + + let Some(iid) = data.iid else { + // A class: true when the instance's class derives from it + // (a StackPanel is a Panel, a FrameworkElement and a UIElement). + let own_class = dec.read().full_name().to_string(); + retval.set_bool(class_derives_from(&own_class, &data.full_name)); + return; + }; + let Some(instance) = dec.instance.clone() else { + retval.set_bool(false); + return; + }; + + let vtable = instance.vtable(); + let mut qi_ptr: *mut c_void = std::ptr::null_mut(); + let hr = unsafe { + ((*vtable).QueryInterface)( + instance.as_raw(), + &iid, + std::mem::transmute(&mut qi_ptr), + ) + }; + let implements = hr.is_ok() && !qi_ptr.is_null(); + if !qi_ptr.is_null() { + drop(unsafe { IUnknown::from_raw(qi_ptr) }); + } + retval.set_bool(implements); + return; } if let Some(type_key) = v8::String::new(scope, "__type") { @@ -1581,6 +1576,10 @@ pub fn debug_output(msg: &str) { let wide: Vec = msg.encode_utf16().chain(std::iter::once(0)).collect(); unsafe { OutputDebugStringW(PCWSTR::from_raw(wide.as_ptr())) }; eprint!("{}", msg); + // Release builds keep no on-disk trace: app console output can carry user data. + if !crate::esm_http::is_debug_build() { + return; + } use std::io::Write; LOG_FILE.with(|cell| { let mut slot = cell.borrow_mut(); @@ -1617,9 +1616,10 @@ pub fn debug_output(msg: &str) { let chosen = if writable(&p) { p.to_string_lossy().into_owned() } else { - let base = - std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\fortu".into()); - format!("{}\\console.log", base) + match std::env::var("USERPROFILE") { + Ok(base) => format!("{}\\console.log", base), + Err(_) => return String::new(), + } }; let banner = format!("[NativeScript] log file: {}\n", chosen); let wide_banner: Vec = @@ -7664,6 +7664,24 @@ unsafe impl Send for JsDelegate {} #[cfg(feature = "classic")] unsafe impl Sync for JsDelegate {} +/// A `{ handle }` delegate argument: a JS delegate this runtime made passes as is, since a +/// generic delegate's IID computed from its type name may be spelled differently from the +/// parameter's. Anything else must implement the delegate interface `iid`. +#[cfg(feature = "classic")] +pub(crate) fn js_delegate_arg( + ptr: *mut c_void, + iid: &GUID, +) -> std::result::Result<(crate::value::NativeValue, Option), crate::error::AnyError> { + // Every JS delegate vtable, typed ones included, shares this QueryInterface in slot 0. + let is_js_delegate = !ptr.is_null() + && unsafe { (**(ptr as *const *const JsDelegateVtbl)).query_interface as usize } + == js_delegate_query_interface as usize; + if is_js_delegate { + return Ok((crate::value::NativeValue { pointer: ptr }, None)); + } + crate::value::query_interface_arg(ptr, iid) +} + #[cfg(feature = "classic")] unsafe extern "system" fn js_delegate_query_interface( this: *mut JsDelegate, @@ -8006,7 +8024,10 @@ pub(crate) fn js_delegate_params_from_declaration( lock: &dyn Declaration, kind: DeclarationKind, ) -> Option<(GUID, Vec)> { - let build = |method: &MethodDeclaration, guid: GUID| -> (GUID, Vec) { + let build = |method: &MethodDeclaration, guid: GUID| -> Option<(GUID, Vec)> { + if !delegate_params_supported(method) { + return None; + } let params = method .parameters() .iter() @@ -8016,10 +8037,10 @@ pub(crate) fn js_delegate_params_from_declaration( Some(ffi_type_for_delegate_param(&sig)) }) .collect(); - (guid, params) + Some((guid, params)) }; - Some(match kind { + match kind { DeclarationKind::Delegate => { let d = lock.as_any().downcast_ref::()?; build(d.invoke_method(), d.id()) @@ -8034,7 +8055,16 @@ pub(crate) fn js_delegate_params_from_declaration( .downcast_ref::()?; build(d.invoke_method(), d.id()) } - _ => return None, + _ => None, + } +} + +/// A native caller passes an array argument as two slots, which a JS delegate's single argument +/// would misread, so delegates taking arrays aren't implemented in JS. +fn delegate_params_supported(method: &MethodDeclaration) -> bool { + method.parameters().iter().all(|p| { + p.metadata() + .map_or(true, |m| !Signature::to_string(m, &p.type_()).trim().ends_with("[]")) }) } @@ -8058,6 +8088,9 @@ pub(crate) fn delegate_info_from_type_sig(iid_name: &str) -> Option<(GUID, Vec()?; let invoke = open_delegate.invoke_method(); + if !delegate_params_supported(invoke) { + return None; + } let param_types = invoke .parameters() .iter() @@ -8759,6 +8792,13 @@ impl Runtime { self.run_module(script, filename); return; } + // The remote-module gate reads `security` from the entry's package.json, as for modules. + if let Some(dir) = Path::new(filename).parent() { + let manifest = dir.join("package.json"); + if manifest.is_file() || crate::source_protect::contains(&manifest.to_string_lossy()) { + crate::esm_http::init_security_from_app_dir(dir); + } + } v8::scope!(scope, &mut self.isolate); let context = v8::Local::new(scope, &self.global_context); diff --git a/runtime/src/method_call.rs b/runtime/src/method_call.rs index 8b15109..d6b973d 100644 --- a/runtime/src/method_call.rs +++ b/runtime/src/method_call.rs @@ -259,7 +259,7 @@ pub struct MethodCall { func: *mut c_void, /// Scratch buffer used when a WinRT method returns a value type (e.g. GUID, Rect) /// that is larger than, or cannot be safely aliased through, a single pointer slot. - return_value_buf: [u8; 128], + return_value_buf: [u8; crate::helpers::RETURN_BUFFER_SIZE], /// Argument slots for the call; inline storage covers the common arities, so building a /// call does not allocate. argument_buf: SmallVec<[NativeValue; 8]>, @@ -393,7 +393,7 @@ impl MethodCall { interface: ManuallyDrop::new(unsafe { IUnknown::from_raw(interface_ptr) }), owns_interface: false, func, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), init_error: None, @@ -462,7 +462,7 @@ impl MethodCall { }), owns_interface: false, func: prepared.func, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), init_error: None, @@ -490,7 +490,7 @@ impl MethodCall { interface: ManuallyDrop::new(interface), owns_interface: true, func: std::ptr::null_mut(), - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), init_error: Some(error_msg), @@ -576,7 +576,7 @@ impl MethodCall { interface: ManuallyDrop::new(queried_interface), owns_interface: true, func, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), init_error: None, @@ -708,6 +708,16 @@ impl MethodCall { let mut param_sigs: Vec = Vec::with_capacity(number_of_parameters); parameter_types.push(NativeType::Pointer); + if !crate::helpers::return_layout_supported(&return_type) { + return Self::new_init_error( + interface, + is_initializer, + is_sealed, + iid, + format!("Unsupported array return type: {return_type}"), + ); + } + for parameter in method.parameters().iter() { let type_ = parameter.type_(); let Some(metadata) = parameter.metadata() else { @@ -721,6 +731,15 @@ impl MethodCall { }; let signature = Signature::to_string(metadata, &type_); + if !crate::helpers::parameter_layout_supported(&signature) { + return Self::new_init_error( + interface, + is_initializer, + is_sealed, + iid, + format!("Unsupported array parameter type: {signature}"), + ); + } let parse_native_type = match NativeType::try_from(signature.as_str()) { Ok(value) => value, @@ -877,7 +896,7 @@ impl MethodCall { interface: ManuallyDrop::new(queried_interface), owns_interface: true, func, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), init_error: None, @@ -925,6 +944,7 @@ impl MethodCall { }); self.argument_parse_types.push(None); + let mut buffer_args = Vec::new(); for (i, native_type) in self.si.parse_parameter_types.iter().enumerate() { let parameter = &self.si.parameters[i]; // Use the pre-computed signature string (cached at construction time) to avoid @@ -1076,17 +1096,17 @@ impl MethodCall { { // Other structs: accept ArrayBuffer, struct instances, or plain JS objects. if value.is_array_buffer() || value.is_array_buffer_view() { - ffi_parse_struct_arg(scope, value) + let size = crate::property_call::struct_declaration_size( + &*declaration.read(), + ); + ffi_parse_struct_arg(scope, value, size) } else if value.is_object() { let obj_v = value.to_object(scope).unwrap(); - let has_internal = obj_v - .get_internal_field(scope, 0) - .map(|f| { - !unsafe { f.cast::() } - .value() - .is_null() - }) - .unwrap_or(false); + let has_internal = (obj_v.internal_field_count() == 2) + .then(|| obj_v.get_internal_field(scope, 0)) + .flatten() + .and_then(|f| v8::Local::::try_from(f).ok()) + .is_some_and(|ext| !ext.value().is_null()); if has_internal { ffi_parse_pointer_arg(scope, value) } else { @@ -1102,6 +1122,8 @@ impl MethodCall { { crate::property_call::append_struct_object_bytes(&mut sbuf, scope, obj_v, sd); } + let size = crate::property_call::struct_declaration_size(&*lock); + sbuf.resize(sbuf.len().max(size), 0); } if sbuf.is_empty() { sbuf.push(0); @@ -1126,7 +1148,13 @@ impl MethodCall { }); if let Some(ptr) = handle_ptr { - Ok(NativeValue { pointer: ptr }) + match crate::js_delegate_arg(ptr, guid) { + Ok((pointer, guard)) => { + queried_interfaces.extend(guard); + Ok(pointer) + } + Err(error) => Err(error), + } } else if let Ok(func) = v8::Local::::try_from(value) { @@ -1134,7 +1162,10 @@ impl MethodCall { pointer: crate::new_js_delegate(v8::Global::new(scope, func), *guid, delegate_param_types.clone()), }) } else { - ffi_parse_pointer_arg(scope, value) + ffi_parse_query_interface_arg(scope, value, guid).map(|(pointer, guard)| { + queried_interfaces.extend(guard); + pointer + }) } } } @@ -1145,7 +1176,7 @@ impl MethodCall { Ok(pointer) } Ok((pointer, None)) => Ok(pointer), - Err(_) => ffi_parse_pointer_arg(scope, value), + Err(error) => Err(error), } } PointerPlan::Collection(plan) => { @@ -1155,7 +1186,6 @@ impl MethodCall { Ok(pointer) } Ok((pointer, None)) => Ok(pointer), - Err(_) if !value.is_array() => ffi_parse_pointer_arg(scope, value), Err(error) => Err(error), } } @@ -1167,6 +1197,7 @@ impl MethodCall { Ok(value) => value, Err(_) => return (call_failure(), std::ptr::null_mut(), Vec::new()), }; + buffer_args.push((value, unsafe { buffer_value.pointer }, byte_length)); self.argument_buf.push(NativeValue { u32_value: byte_length, @@ -1177,7 +1208,7 @@ impl MethodCall { continue; } NativeType::Function => ffi_parse_function_arg(scope, value), - NativeType::Struct(_) => ffi_parse_struct_arg(scope, value), + NativeType::Struct(_) => ffi_parse_struct_arg(scope, value, native_type.size()), NativeType::String => ffi_parse_string_arg(scope, value), }; @@ -1231,6 +1262,10 @@ impl MethodCall { } } + if !crate::value::buffers_unchanged(scope, &buffer_args) { + return (call_failure(), std::ptr::null_mut(), Vec::new()); + } + let prep = match crate::ffi::prepare_string_storage( &self.argument_buf, &self.si.parameter_types, @@ -1575,6 +1610,7 @@ impl MethodCall { }); self.argument_parse_types.push(None); + let mut buffer_args = Vec::new(); for (i, native_type) in self.si.parse_parameter_types.iter().enumerate() { let parameter = &self.si.parameters[i]; let param_sig = &self.si.param_sigs[i]; @@ -1706,10 +1742,11 @@ impl MethodCall { { // Other structs: ArrayBuffer bytes, wrapped struct // instances, or plain JS objects serialized field-by-field. - let is_buffer_like = - nv::napi_parse_struct(env, &value).is_ok(); - if is_buffer_like { - nv::napi_parse_struct(env, &value) + if nv::buffer_data(env, &value).is_some() { + let size = crate::property_call::struct_declaration_size( + &*declaration.read(), + ); + nv::napi_parse_struct(env, &value, size) } else if vt == ValueType::Object { let obj: JsObject = unsafe { value.cast() }; let has_wrap = @@ -1726,6 +1763,8 @@ impl MethodCall { { crate::property_call::append_struct_object_bytes_napi(env, &mut sbuf, &obj, sd); } + let size = crate::property_call::struct_declaration_size(&*lock); + sbuf.resize(sbuf.len().max(size), 0); } if sbuf.is_empty() { sbuf.push(0); @@ -1752,7 +1791,13 @@ impl MethodCall { }; if let Some(ptr) = handle_ptr { - Ok(NativeValue { pointer: ptr }) + match crate::napi_engine::delegate::napi_delegate_arg(ptr, guid) { + Ok((pointer, guard)) => { + queried_interfaces.extend(guard); + Ok(pointer) + } + Err(error) => Err(error), + } } else if vt == ValueType::Function { let func: JsFunction = unsafe { value.cast() }; match make_napi_delegate( @@ -1762,10 +1807,16 @@ impl MethodCall { delegate_param_types.clone(), ) { Some(ptr) => Ok(NativeValue { pointer: ptr }), - None => nv::napi_parse_pointer(env, &value), + None => nv::napi_parse_query_interface(env, &value, guid).map(|(pointer, guard)| { + queried_interfaces.extend(guard); + pointer + }), } } else { - nv::napi_parse_pointer(env, &value) + nv::napi_parse_query_interface(env, &value, guid).map(|(pointer, guard)| { + queried_interfaces.extend(guard); + pointer + }) } } } @@ -1776,7 +1827,7 @@ impl MethodCall { Ok(pointer) } Ok((pointer, None)) => Ok(pointer), - Err(_) => nv::napi_parse_pointer(env, &value), + Err(error) => Err(error), } } PointerPlan::Collection(plan) => { @@ -1786,7 +1837,7 @@ impl MethodCall { Ok(pointer) } Ok((pointer, None)) => Ok(pointer), - Err(_) => nv::napi_parse_pointer(env, &value), + Err(error) => Err(error), } } } @@ -1797,6 +1848,7 @@ impl MethodCall { Ok(value) => value, Err(_) => return fail3(), }; + buffer_args.push((unsafe { napi::NapiRaw::raw(&value) }, unsafe { buffer_value.pointer }, byte_length)); self.argument_buf.push(NativeValue { u32_value: byte_length, }); @@ -1806,7 +1858,7 @@ impl MethodCall { continue; } NativeType::Function => nv::napi_parse_function(env, &value), - NativeType::Struct(_) => nv::napi_parse_struct(env, &value), + NativeType::Struct(_) => nv::napi_parse_struct(env, &value, native_type.size()), NativeType::String => nv::napi_parse_string(&value), }; @@ -1852,6 +1904,10 @@ impl MethodCall { } } + if !nv::buffers_unchanged(env, &buffer_args) { + return fail3(); + } + let prep = match crate::ffi::prepare_string_storage( &self.argument_buf, &self.si.parameter_types, diff --git a/runtime/src/napi_engine/delegate.rs b/runtime/src/napi_engine/delegate.rs index 18efa1a..3cea5f8 100644 --- a/runtime/src/napi_engine/delegate.rs +++ b/runtime/src/napi_engine/delegate.rs @@ -190,6 +190,23 @@ pub unsafe fn release_delegate_raw(ptr: *mut c_void) -> u32 { ((*(*d).vtable).release)(d) } +/// A `{ handle }` delegate argument: a JS delegate this runtime made passes as is, since a +/// generic delegate's IID computed from its type name may be spelled differently from the +/// parameter's. Anything else must implement the delegate interface `iid`. +pub(crate) fn napi_delegate_arg( + ptr: *mut c_void, + iid: &GUID, +) -> Result<(crate::value::NativeValue, Option), crate::error::AnyError> { + // Every JS delegate vtable, typed ones included, shares this QueryInterface in slot 0. + let is_js_delegate = !ptr.is_null() + && unsafe { (**(ptr as *const *const NapiDelegateVtbl)).query_interface as usize } + == napi_delegate_query_interface as usize; + if is_js_delegate { + return Ok((crate::value::NativeValue { pointer: ptr }, None)); + } + crate::value::query_interface_arg(ptr, iid) +} + unsafe extern "system" fn napi_delegate_query_interface( this: *mut NapiDelegate, iid: *const GUID, diff --git a/runtime/src/napi_engine/interop.rs b/runtime/src/napi_engine/interop.rs index bf8c312..b91d615 100644 --- a/runtime/src/napi_engine/interop.rs +++ b/runtime/src/napi_engine/interop.rs @@ -328,12 +328,31 @@ pub fn install_interop(env: &Env) -> napi::Result<()> { })?; global.set_named_property("__nsArrayBufferFromBuffer", ab_fn)?; + let random_fn = env.create_function_from_closure("__nsRandomFill", |ctx| { + let value = ctx.get::(0)?; + let Some((data, length)) = buffer_data(&ctx.env, &value) else { + return Err(napi::Error::from_reason( + "__nsRandomFill expects an ArrayBufferView".to_string(), + )); + }; + if length > 0 && !data.is_null() { + let bytes = unsafe { std::slice::from_raw_parts_mut(data as *mut u8, length) }; + getrandom::getrandom(bytes).map_err(|_| { + napi::Error::from_reason("The OS random number generator is unavailable".to_string()) + })?; + } + ctx.env.get_undefined() + })?; + global.set_named_property("__nsRandomFill", random_fn)?; + let func_ctor: JsFunction = global.get_named_property("Function")?; - let body = env.create_string(INTEROP_HELPERS_JS)?; - let installer_obj = func_ctor.new_instance(&[body])?; - let installer: JsFunction = - unsafe { JsFunction::from_raw(env.raw(), installer_obj.raw()) }?; - installer.call_without_args(None)?; + for source in [INTEROP_HELPERS_JS, include_str!("../js/crypto.js")] { + let body = env.create_string(source)?; + let installer_obj = func_ctor.new_instance(&[body])?; + let installer: JsFunction = + unsafe { JsFunction::from_raw(env.raw(), installer_obj.raw()) }?; + installer.call_without_args(None)?; + } Ok(()) } diff --git a/runtime/src/napi_engine/value.rs b/runtime/src/napi_engine/value.rs index 3f356e1..d85b819 100644 --- a/runtime/src/napi_engine/value.rs +++ b/runtime/src/napi_engine/value.rs @@ -339,7 +339,7 @@ pub fn napi_parse_arg( NativeType::Pointer => napi_parse_pointer(env, arg), NativeType::Buffer => napi_parse_buffer(env, arg), NativeType::Function => napi_parse_function(env, arg), - NativeType::Struct(_) => napi_parse_struct(env, arg), + NativeType::Struct(_) => napi_parse_struct(env, arg, native_type.size()), NativeType::Void => Err(type_error("napi_parse_arg: Void is not a parameter type")), } } @@ -595,9 +595,8 @@ fn external_or_null(env: &Env, ptr: *mut c_void) -> Result // -/// Interpret a JS value as a raw native pointer: External, null, BigInt, or Number. -/// Mirrors the acceptance set of the `handle` branches in the rusty_v8 original -/// (undefined and anything else → None, NOT a null pointer). +/// A native pointer carried by a JS value: an External, a BigInt, or null. Numbers and strings +/// are refused because `JSON.parse` output can carry them, and data must never become a pointer. fn js_value_as_ptr(env: &Env, v: &JsUnknown) -> Option<*mut c_void> { match v.get_type().ok()? { ValueType::External => external_ptr_raw(env, unsafe { v.raw() }), @@ -606,21 +605,15 @@ fn js_value_as_ptr(env: &Env, v: &JsUnknown) -> Option<*mut c_void> { let b: JsBigInt = unsafe { v.cast() }; b.get_u64().ok().map(|(u, _)| u as *mut c_void) } - ValueType::Number => { - let n: JsNumber = unsafe { v.cast() }; - // v8 used `integer_value` (ToInteger: trunc toward zero, saturating, NaN→0); - // `f64 as i64` has identical semantics for an actual Number. - n.get_double().ok().map(|d| d as i64 as isize as *mut c_void) - } _ => None, } } /// Extract a native COM/handle pointer from a JS object, trying in order: -/// 1. `handle` property — accessor function result or direct External/null/BigInt/Number. +/// 1. `handle` property: an accessor's result, or a direct External/null/BigInt. /// 2. Wrapped `DeclarationFFI` (rusty_v8 kept it in internal field 0; here ns_proxy wraps /// instance objects via `env.wrap`, read back with `env.unwrap`). -/// 3. `__native_ptr` property — hex/decimal string, BigInt, Number, or External. +/// 3. `__native_ptr` property: a BigInt or External. /// 4. `__handle` managed-bridge id — resolved via `Bridge.GetNativePtrForHandle`. pub(crate) fn try_get_external_handle(env: &Env, obj: &JsObject) -> Option<*mut c_void> { if let Some(handle_raw) = get_named_property_raw(env, unsafe { obj.raw() }, HANDLE_KEY) { @@ -661,23 +654,11 @@ pub(crate) fn try_get_external_handle(env: &Env, obj: &JsObject) -> Option<*mut } if let Ok(val) = obj.get_named_property::("__native_ptr") { - if let Ok(ValueType::String) = val.get_type() { - let s = js_to_rust_string(env, &val); - let s_trim = s.trim_start(); - let parsed = if s_trim.starts_with("0x") || s_trim.starts_with("0X") { - usize::from_str_radix(&s_trim[2..], 16).ok() - } else { - s_trim.parse::().ok() - }; - if let Some(u) = parsed { - return Some(u as *mut c_void); + if !matches!(val.get_type(), Ok(ValueType::Null)) { + if let Some(p) = js_value_as_ptr(env, &val) { + return Some(p); } } - if let Some(p) = js_value_as_ptr(env, &val) { - // BigInt / Number / External forms of __native_ptr (null is not meaningful here, - // but the original accepted numeric zero the same way). - return Some(p); - } } // Managed handle id → ask the bridge for the canonical native pointer. @@ -1049,40 +1030,13 @@ pub fn napi_parse_query_interface( if vt == ValueType::Object || vt == ValueType::Function { let obj: JsObject = unsafe { arg.cast() }; if let Some(pointer) = try_get_external_handle(env, &obj) { - if pointer.is_null() { - return Ok(( - NativeValue { - pointer: std::ptr::null_mut(), - }, - None, - )); - } - - let unknown = ManuallyDrop::new(unsafe { IUnknown::from_raw(pointer) }); - let vtable = unknown.vtable(); - let mut queried: *mut c_void = std::ptr::null_mut(); - - let result = unsafe { - ((*vtable).QueryInterface)( - unknown.as_raw(), - iid, - &mut queried as *mut _ as *mut *mut c_void, - ) - }; - - if result.is_ok() && !queried.is_null() { - let queried = unsafe { IUnknown::from_raw(queried) }; - let pointer = queried.as_raw() as *mut c_void; - return Ok((NativeValue { pointer }, Some(queried))); - } - - return Err(type_error( - "Invalid FFI interface argument for expected WinRT type", - )); + return crate::value::query_interface_arg(pointer, iid); } } - Ok((napi_parse_pointer(env, arg)?, None)) + // Boxed primitives and struct shapes are checked too: an IPropertyValue handed to a callee + // expecting another interface would be called through the wrong vtable. + crate::value::query_interface_arg(unsafe { napi_parse_pointer(env, arg)?.pointer }, iid) } // @@ -1152,6 +1106,17 @@ pub fn napi_parse_buffer(env: &Env, arg: &JsUnknown) -> Result bool { + buffers.iter().all(|&(raw, pointer, length)| { + let value = unsafe { JsUnknown::from_raw_unchecked(env.raw(), raw) }; + matches!(napi_parse_buffer_with_length(env, &value), + Ok((now, now_length)) if unsafe { now.pointer } == pointer && now_length == length) + }) +} + pub fn napi_parse_buffer_with_length( env: &Env, arg: &JsUnknown, @@ -1172,13 +1137,23 @@ pub fn napi_parse_buffer_with_length( )) } -pub fn napi_parse_struct(env: &Env, arg: &JsUnknown) -> Result { - if let Some((pointer, _)) = buffer_data(env, arg) { +pub fn napi_parse_struct( + env: &Env, + arg: &JsUnknown, + struct_size: usize, +) -> Result { + if let Some((pointer, byte_length)) = buffer_data(env, arg) { if pointer.is_null() { return Err(type_error( "Invalid FFI ArrayBuffer, expected data in buffer", )); } + // The callee reads the whole struct, so a shorter buffer would be read past its end. + if byte_length < struct_size { + return Err(type_error(format!( + "Invalid FFI struct buffer: {byte_length} bytes, expected at least {struct_size}" + ))); + } return Ok(NativeValue { pointer }); } Err(type_error( @@ -1438,7 +1413,7 @@ pub fn write_js_value_to_ptr( } NativeType::Struct(_) => { // Copy struct bytes from an ArrayBuffer/ArrayBufferView source. - let nv = napi_parse_struct(env, arg)?; + let nv = napi_parse_struct(env, arg, native_type.size())?; let src = unsafe { nv.pointer as *const u8 }; let size = native_type.size(); unsafe { copy_nonoverlapping(src, dst as *mut u8, size) }; diff --git a/runtime/src/ns_proxy.rs b/runtime/src/ns_proxy.rs index 19aec2c..d694a1c 100644 --- a/runtime/src/ns_proxy.rs +++ b/runtime/src/ns_proxy.rs @@ -241,17 +241,19 @@ pub(crate) fn this_instance( } /// The per-instance `DeclarationFFI` stored on a wrapper object. The pointee -/// lives at least as long as the wrapper. +/// lives at least as long as the wrapper. None for any other object: runtime +/// wrappers have exactly two internal fields, while URL objects keep other data +/// in their one field. #[inline] pub(crate) fn this_declaration_ffi( scope: &mut v8::PinScope<'_, '_>, this: v8::Local, ) -> Option<*mut DeclarationFFI> { - if this.internal_field_count() < 1 { + if this.internal_field_count() != 2 { return None; } let field = this.get_internal_field(scope, 0)?; - let ext = unsafe { field.cast::() }; + let ext = v8::Local::::try_from(field).ok()?; let ptr = ext.value() as *mut DeclarationFFI; if ptr.is_null() { return None; @@ -1704,15 +1706,11 @@ fn v8_value_to_inspectable( if val.is_object() { if let Some(obj) = val.to_object(scope) { - if let Some(dec_field) = obj.get_internal_field(scope, 0) { - let dec_ptr = - unsafe { dec_field.cast::() }.value() as *mut DeclarationFFI; - if !dec_ptr.is_null() { - let wrapper = unsafe { &*dec_ptr }; - if let Some(inst) = &wrapper.instance { - if let Ok(ins) = inst.clone().cast::() { - return Some(ins); - } + if let Some(dec_ptr) = this_declaration_ffi(scope, obj) { + let wrapper = unsafe { &*dec_ptr }; + if let Some(inst) = &wrapper.instance { + if let Ok(ins) = inst.clone().cast::() { + return Some(ins); } } } diff --git a/runtime/src/property_call.rs b/runtime/src/property_call.rs index f6d8132..1c19654 100644 --- a/runtime/src/property_call.rs +++ b/runtime/src/property_call.rs @@ -55,6 +55,14 @@ pub(crate) fn sig_size_align_pub(sig: &str) -> (usize, usize) { sig_size_align(sig) } +/// ABI size in bytes of a struct declaration, or 0 when it isn't one. +pub(crate) fn struct_declaration_size(declaration: &dyn Declaration) -> usize { + declaration + .as_any() + .downcast_ref::() + .map_or(0, |sd| struct_size_align(sd).0) +} + /// (size, alignment) in bytes for a struct field signature. Recurses into nested structs; WinRT enums /// are 4-byte Int32; primitives use their native size. fn sig_size_align(sig: &str) -> (usize, usize) { @@ -72,6 +80,11 @@ fn sig_size_align(sig: &str) -> (usize, usize) { _ => {} } } + // The one reference type a value struct can hold is IReference, a pointer. + if lookup.contains("IReference") { + let size = std::mem::size_of::(); + return (size, size); + } return (4, 4); // unknown dotted ref in a value struct — treat as Int32-sized enum } if let Ok(nt) = NativeType::try_from(sig) { @@ -361,7 +374,7 @@ pub struct PropertyCall { argument_parse_types: SmallVec<[Option; 8]>, /// Scratch buffer used when a WinRT property/method returns a value type /// or scalar that must be written into caller-owned storage. - return_value_buf: [u8; 128], + return_value_buf: [u8; crate::helpers::RETURN_BUFFER_SIZE], } impl PropertyCall { @@ -437,7 +450,7 @@ impl PropertyCall { interface: ManuallyDrop::new(unsafe { IUnknown::from_raw(interface_ptr) }), owns_interface: false, func, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), } @@ -492,7 +505,7 @@ impl PropertyCall { }), owns_interface: false, func: prepared.func, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), } @@ -557,7 +570,7 @@ impl PropertyCall { interface: ManuallyDrop::new(queried_interface), owns_interface: true, func, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), }) @@ -676,6 +689,9 @@ impl PropertyCall { let signature = method.return_type(); let return_type = Signature::to_string(method.metadata()?, &signature); + if !crate::helpers::return_layout_supported(&return_type) { + return None; + } let other_params: usize = if is_initializer { if is_sealed { @@ -702,6 +718,9 @@ impl PropertyCall { let metadata = parameter.metadata()?; let signature = Signature::to_string(metadata, &type_); + if !crate::helpers::parameter_layout_supported(&signature) { + return None; + } let parse_native_type = NativeType::try_from(signature.as_str()).ok()?; parse_parameter_types.push(parse_native_type); @@ -811,7 +830,7 @@ impl PropertyCall { interface: ManuallyDrop::new(interface), owns_interface: true, func, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), }) @@ -880,6 +899,9 @@ impl PropertyCall { let signature = method.return_type(); let raw_return_type = Signature::to_string(method.metadata()?, &signature); let return_type = substitute_type_vars(&raw_return_type, &type_args); + if !crate::helpers::return_layout_supported(&return_type) { + return None; + } let return_kind = crate::classify_return(&return_type, is_void); let other_params: usize = if is_void { 1 } else { 2 }; @@ -895,6 +917,9 @@ impl PropertyCall { let metadata = parameter.metadata()?; let raw_sig = Signature::to_string(metadata, &type_); let signature = substitute_type_vars(&raw_sig, &type_args); + if !crate::helpers::parameter_layout_supported(&signature) { + return None; + } let parse_native_type = NativeType::try_from(signature.as_str()).ok()?; parse_parameter_types.push(parse_native_type); @@ -992,7 +1017,7 @@ impl PropertyCall { interface: ManuallyDrop::new(interface), owns_interface: true, func, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), }) @@ -1049,6 +1074,9 @@ impl PropertyCall { let signature = method.return_type(); let raw_return_type = Signature::to_string(method.metadata()?, &signature); let return_type = substitute_type_vars(&raw_return_type, &type_args); + if !crate::helpers::return_layout_supported(&return_type) { + return None; + } let return_kind = crate::classify_return(&return_type, is_void); let other_params: usize = if is_void { 1 } else { 2 }; @@ -1064,6 +1092,9 @@ impl PropertyCall { let metadata = parameter.metadata()?; let raw_sig = Signature::to_string(metadata, &type_); let sig = substitute_type_vars(&raw_sig, &type_args); + if !crate::helpers::parameter_layout_supported(&sig) { + return None; + } let parse_native_type = NativeType::try_from(sig.as_str()).ok()?; parse_parameter_types.push(parse_native_type); param_sigs.push(sig.clone()); @@ -1160,7 +1191,7 @@ impl PropertyCall { interface: ManuallyDrop::new(interface), owns_interface: true, func, - return_value_buf: [0u8; 128], + return_value_buf: [0u8; crate::helpers::RETURN_BUFFER_SIZE], argument_buf: SmallVec::new(), argument_parse_types: SmallVec::new(), }) @@ -1219,6 +1250,7 @@ impl PropertyCall { }); self.argument_parse_types.push(None); + let mut buffer_args = Vec::new(); for (i, native_type) in self.si.parse_parameter_types.iter().enumerate() { let value = values .get(i) @@ -1313,7 +1345,8 @@ impl PropertyCall { PointerPlan::Struct(declaration) => { // ArrayBuffer / ArrayBufferView → raw bytes pointer if value.is_array_buffer() || value.is_array_buffer_view() { - ffi_parse_struct_arg(scope, value) + let size = struct_declaration_size(&*declaration.read()); + ffi_parse_struct_arg(scope, value, size) } else if value.is_object() { let obj = value.to_object(scope).unwrap(); let mut sbuf: Vec = Vec::new(); @@ -1324,6 +1357,8 @@ impl PropertyCall { { append_struct_object_bytes(&mut sbuf, scope, obj, sd); } + let size = struct_declaration_size(&*lock); + sbuf.resize(sbuf.len().max(size), 0); } if sbuf.is_empty() { sbuf.push(0); @@ -1347,13 +1382,22 @@ impl PropertyCall { }); if let Some(ptr) = handle_ptr { - Ok(NativeValue { pointer: ptr }) + match crate::js_delegate_arg(ptr, guid) { + Ok((pointer, guard)) => { + queried_interfaces.extend(guard); + Ok(pointer) + } + Err(error) => Err(error), + } } else if let Ok(func) = v8::Local::::try_from(value) { Ok(NativeValue { pointer: crate::new_js_delegate(v8::Global::new(scope, func), *guid, delegate_param_types.clone()), }) } else { - ffi_parse_pointer_arg(scope, value) + ffi_parse_query_interface_arg(scope, value, guid).map(|(pointer, guard)| { + queried_interfaces.extend(guard); + pointer + }) } } PointerPlan::Interface(iid) => { @@ -1385,6 +1429,7 @@ impl PropertyCall { Ok(value) => value, Err(_) => return (call_failure(), std::ptr::null_mut(), Vec::new()), }; + buffer_args.push((value, unsafe { buffer_value.pointer }, byte_length)); self.argument_buf.push(NativeValue { u32_value: byte_length, @@ -1419,6 +1464,8 @@ impl PropertyCall { { append_struct_object_bytes(&mut sbuf, scope, obj, sd); } + let size = struct_declaration_size(&*lock); + sbuf.resize(sbuf.len().max(size).max(native_type.size()), 0); } if sbuf.is_empty() { sbuf.push(0); @@ -1431,7 +1478,7 @@ impl PropertyCall { } match handled { Some(nv) => Ok(nv), - None => ffi_parse_struct_arg(scope, value), + None => ffi_parse_struct_arg(scope, value, native_type.size()), } } NativeType::String => ffi_parse_string_arg(scope, value), @@ -1461,6 +1508,10 @@ impl PropertyCall { } } + if !crate::value::buffers_unchanged(scope, &buffer_args) { + return (call_failure(), std::ptr::null_mut(), Vec::new()); + } + let prep = match crate::ffi::prepare_string_storage( &self.argument_buf, &self.si.parameter_types, @@ -1623,6 +1674,7 @@ impl PropertyCall { }); self.argument_parse_types.push(None); + let mut buffer_args = Vec::new(); for (i, native_type) in self.si.parse_parameter_types.iter().enumerate() { let value = match values.get(i) { Some(v) => dup_arg(env, v), @@ -1707,9 +1759,9 @@ impl PropertyCall { PointerPlan::Struct(declaration) => { // ArrayBuffer/view → raw bytes; other objects → serialize // field-by-field (v8 original used is_array_buffer checks). - let is_buffer_like = nv::napi_parse_struct(env, &value).is_ok(); - if is_buffer_like { - nv::napi_parse_struct(env, &value) + if nv::buffer_data(env, &value).is_some() { + let size = struct_declaration_size(&*declaration.read()); + nv::napi_parse_struct(env, &value, size) } else if vt == ValueType::Object { let obj: JsObject = unsafe { value.cast() }; let mut sbuf: Vec = Vec::new(); @@ -1720,6 +1772,8 @@ impl PropertyCall { { append_struct_object_bytes_napi(env, &mut sbuf, &obj, sd); } + let size = struct_declaration_size(&*lock); + sbuf.resize(sbuf.len().max(size), 0); } if sbuf.is_empty() { sbuf.push(0); @@ -1742,7 +1796,13 @@ impl PropertyCall { }; if let Some(ptr) = handle_ptr { - Ok(NativeValue { pointer: ptr }) + match crate::napi_engine::delegate::napi_delegate_arg(ptr, guid) { + Ok((pointer, guard)) => { + queried_interfaces.extend(guard); + Ok(pointer) + } + Err(error) => Err(error), + } } else if vt == ValueType::Function { let func: JsFunction = unsafe { value.cast() }; match make_napi_delegate( @@ -1752,10 +1812,16 @@ impl PropertyCall { delegate_param_types.clone(), ) { Some(ptr) => Ok(NativeValue { pointer: ptr }), - None => nv::napi_parse_pointer(env, &value), + None => nv::napi_parse_query_interface(env, &value, guid).map(|(pointer, guard)| { + queried_interfaces.extend(guard); + pointer + }), } } else { - nv::napi_parse_pointer(env, &value) + nv::napi_parse_query_interface(env, &value, guid).map(|(pointer, guard)| { + queried_interfaces.extend(guard); + pointer + }) } } PointerPlan::Interface(iid) => { @@ -1788,6 +1854,7 @@ impl PropertyCall { Ok(value) => value, Err(_) => return fail3(), }; + buffer_args.push((unsafe { napi::NapiRaw::raw(&value) }, unsafe { buffer_value.pointer }, byte_length)); self.argument_buf.push(NativeValue { u32_value: byte_length, }); @@ -1801,7 +1868,7 @@ impl PropertyCall { // By-value struct: plain JS object → serialized bytes (scratch outlives // the call); ArrayBuffer/view or wrapped instances → struct parse. let mut handled: Option = None; - let is_buffer_like = nv::napi_parse_struct(env, &value).is_ok(); + let is_buffer_like = nv::buffer_data(env, &value).is_some(); if vt == ValueType::Object && !is_buffer_like { let sig: &str = &self.si.param_sigs[i]; let lookup = crate::helpers::strip_generic_suffix(sig); @@ -1817,6 +1884,8 @@ impl PropertyCall { { append_struct_object_bytes_napi(env, &mut sbuf, &obj, sd); } + let size = struct_declaration_size(&*lock); + sbuf.resize(sbuf.len().max(size).max(native_type.size()), 0); } if sbuf.is_empty() { sbuf.push(0); @@ -1829,7 +1898,7 @@ impl PropertyCall { } match handled { Some(nvv) => Ok(nvv), - None => nv::napi_parse_struct(env, &value), + None => nv::napi_parse_struct(env, &value, native_type.size()), } } NativeType::String => nv::napi_parse_string(&value), @@ -1859,6 +1928,10 @@ impl PropertyCall { } } + if !nv::buffers_unchanged(env, &buffer_args) { + return fail3(); + } + let prep = match crate::ffi::prepare_string_storage( &self.argument_buf, &self.si.parameter_types, diff --git a/runtime/src/value.rs b/runtime/src/value.rs index 4146d68..cf1166e 100644 --- a/runtime/src/value.rs +++ b/runtime/src/value.rs @@ -685,6 +685,19 @@ pub fn ffi_parse_f64_arg(arg: v8::Local) -> std::result::Result) -> Option<*mut c_void> { + if let Ok(ext) = v8::Local::::try_from(value) { + return Some(ext.value()); + } + if let Ok(bi) = v8::Local::::try_from(value) { + return Some(bi.u64_value().0 as *mut c_void); + } + value.is_null().then(std::ptr::null_mut) +} + #[cfg(feature = "classic")] #[inline] fn try_get_external_handle( @@ -719,56 +732,24 @@ fn try_get_external_handle( if let Ok(func) = v8::Local::::try_from(handle) { // Call with `this` = the object so typical getters work. if let Some(ret) = func.call(scope, arg.into(), &[]) { - if let Ok(ext) = v8::Local::::try_from(ret) { - return Some(ext.value()); - } - if ret.is_null() { - return Some(std::ptr::null_mut()); - } - if let Ok(bi) = v8::Local::::try_from(ret) { - let u = bi.u64_value().0; - return Some(u as *mut c_void); - } - if let Ok(num) = v8::Local::::try_from(ret) { - if let Some(iv) = num.integer_value(scope) { - return Some(iv as isize as *mut c_void); - } else { - let v = num.value(); - return Some(v as usize as *mut c_void); - } + if let Some(ptr) = native_pointer_value(ret) { + return Some(ptr); } } } } - if let Ok(value) = v8::Local::::try_from(handle) { - return Some(value.value()); - } - - if handle.is_null() { - return Some(std::ptr::null_mut()); - } - // Fallback: some managed bridges may expose the native pointer as a - // numeric value (BigInt or Number). Accept those too. - if let Ok(bi) = v8::Local::::try_from(handle) { - let u = bi.u64_value().0; - let ptr = u as *mut c_void; + if let Some(ptr) = native_pointer_value(handle) { return Some(ptr); } - if let Ok(num) = v8::Local::::try_from(handle) { - if let Some(iv) = num.integer_value(scope) { - let ptr = iv as isize as *mut c_void; - return Some(ptr); - } else { - let v = num.value(); - let ptr = v as usize as *mut c_void; - return Some(ptr); - } - } } } - if let Some(field) = arg.get_internal_field(scope, 0) { - // Checked: a typed array's embedder slots hold Smis, not Externals. + // Only runtime wrappers (two fields) hold a DeclarationFFI: URL objects keep other data in + // their one field, and a typed array's embedder slots hold Smis. + let wrapper_field = (arg.internal_field_count() == 2) + .then(|| arg.get_internal_field(scope, 0)) + .flatten(); + if let Some(field) = wrapper_field { let Ok(ext) = v8::Local::::try_from(field) else { return None; }; @@ -785,42 +766,14 @@ fn try_get_external_handle( return Some(std::ptr::null_mut()); } - // Bridge may provide a canonical native pointer directly on the JS object - // via a `__native_ptr` property (written as a string or numeric value by - // the managed bridge). Accept hex strings, decimal strings, BigInt, and - // Number values here so managed-returned wrappers can expose their - // canonical IUnknown/IInspectable pointer identity. + // Bridge wrappers expose their canonical IUnknown pointer as a `__native_ptr` BigInt. if let Some(native_key) = v8::String::new(scope, "__native_ptr") { if let Some(val) = arg.get(scope, native_key.into()) { - if val.is_string() { - if let Ok(sv) = v8::Local::::try_from(val) { - let s = sv.to_rust_string_lossy(scope); - let s_trim = s.trim_start(); - let parsed = if s_trim.starts_with("0x") || s_trim.starts_with("0X") { - usize::from_str_radix(&s_trim[2..], 16).ok() - } else { - s_trim.parse::().ok() - }; - if let Some(u) = parsed { - return Some(u as *mut c_void); - } - } - } - if let Ok(bi) = v8::Local::::try_from(val) { - let u = bi.u64_value().0; - return Some(u as *mut c_void); - } - if let Ok(num) = v8::Local::::try_from(val) { - if let Some(iv) = num.integer_value(scope) { - return Some(iv as isize as *mut c_void); - } else { - let v = num.value(); - return Some(v as usize as *mut c_void); + if !val.is_null() { + if let Some(ptr) = native_pointer_value(val) { + return Some(ptr); } } - if let Ok(ext) = v8::Local::::try_from(val) { - return Some(ext.value()); - } } } @@ -1276,6 +1229,26 @@ pub fn ffi_parse_pointer_arg( Ok(NativeValue { pointer }) } +/// `pointer` QI'd to `iid` (the guard holds the new reference), or a TypeError for an object +/// that doesn't implement it. Null passes through. +pub(crate) fn query_interface_arg( + pointer: *mut c_void, + iid: &windows::core::GUID, +) -> std::result::Result<(NativeValue, Option), AnyError> { + use windows::core::Interface; + if pointer.is_null() { + return Ok((NativeValue { pointer }, None)); + } + let unknown = ManuallyDrop::new(unsafe { windows::core::IUnknown::from_raw(pointer) }); + let mut queried: *mut c_void = std::ptr::null_mut(); + let result = unsafe { (unknown.vtable().QueryInterface)(unknown.as_raw(), iid, &mut queried) }; + if result.is_ok() && !queried.is_null() { + let queried = unsafe { windows::core::IUnknown::from_raw(queried) }; + return Ok((NativeValue { pointer: queried.as_raw() }, Some(queried))); + } + Err(type_error("Invalid FFI interface argument for expected WinRT type")) +} + #[cfg(feature = "classic")] #[inline] pub fn ffi_parse_query_interface_arg( @@ -1295,40 +1268,27 @@ pub fn ffi_parse_query_interface_arg( if arg.is_object() { let arg = arg.to_object(scope).unwrap(); if let Some(pointer) = try_get_external_handle(scope, arg) { - if pointer.is_null() { - return Ok(( - NativeValue { - pointer: std::ptr::null_mut(), - }, - None, - )); - } - - let unknown = ManuallyDrop::new(unsafe { IUnknown::from_raw(pointer) }); - let vtable = unknown.vtable(); - let mut queried: *mut c_void = std::ptr::null_mut(); - - let result = unsafe { - ((*vtable).QueryInterface)( - unknown.as_raw(), - iid, - &mut queried as *mut _ as *mut *mut c_void, - ) - }; - - if result.is_ok() && !queried.is_null() { - let queried = unsafe { IUnknown::from_raw(queried) }; - let pointer = queried.as_raw() as *mut c_void; - return Ok((NativeValue { pointer }, Some(queried))); - } - - return Err(type_error( - "Invalid FFI interface argument for expected WinRT type", - )); + return query_interface_arg(pointer, iid); } } - Ok((ffi_parse_pointer_arg(scope, arg)?, None)) + // Boxed primitives and struct shapes are checked too: an IPropertyValue handed to a callee + // expecting another interface would be called through the wrong vtable. + query_interface_arg(unsafe { ffi_parse_pointer_arg(scope, arg)?.pointer }, iid) +} + +/// Whether each buffer argument still has the backing it had when parsed. Parsing a later argument +/// can run JS (a getter, `valueOf`) that detaches, transfers or resizes an earlier buffer, and the +/// callee would then write through a stale pointer. +#[cfg(feature = "classic")] +pub(crate) fn buffers_unchanged( + scope: &mut v8::PinScope<'_, '_>, + buffers: &[(v8::Local, *mut c_void, u32)], +) -> bool { + buffers.iter().all(|&(value, pointer, length)| { + matches!(ffi_parse_buffer_arg_with_length(scope, value), + Ok((now, now_length)) if unsafe { now.pointer } == pointer && now_length == length) + }) } #[cfg(feature = "classic")] @@ -1378,11 +1338,26 @@ pub fn ffi_parse_buffer_arg_with_length( pub fn ffi_parse_struct_arg( scope: &mut v8::PinScope<'_, '_>, arg: v8::Local, + struct_size: usize, ) -> std::result::Result { // Order of checking: // 1. ArrayBuffer: Fairly common and not supported by Fast API, optimise this case. // 2. ArrayBufferView: Common and supported by Fast API + let byte_length = if let Ok(value) = v8::Local::::try_from(arg) { + value.byte_length() + } else if let Ok(value) = v8::Local::::try_from(arg) { + value.byte_length() + } else { + 0 + }; + // The callee reads the whole struct, so a shorter buffer would be read past its end. + if byte_length < struct_size { + return Err(type_error(format!( + "Invalid FFI struct buffer: {byte_length} bytes, expected at least {struct_size}" + ))); + } + let pointer = if let Ok(value) = v8::Local::::try_from(arg) { if let Some(non_null) = value.data() { non_null.as_ptr() @@ -1911,7 +1886,7 @@ pub fn write_v8_value_to_ptr( } NativeType::Struct(_) => { // Copy struct bytes from an ArrayBuffer/ArrayBufferView or other struct source - let nv = ffi_parse_struct_arg(scope, arg)?; + let nv = ffi_parse_struct_arg(scope, arg, native_type.size())?; let src = unsafe { nv.pointer as *const u8 }; let size = native_type.size(); unsafe { diff --git a/runtime/src/websocket.rs b/runtime/src/websocket.rs index 136a0dd..19b2581 100644 --- a/runtime/src/websocket.rs +++ b/runtime/src/websocket.rs @@ -123,6 +123,10 @@ pub(crate) fn take_events() -> Vec<(i32, Event)> { CHANNEL.with(|(_, rx)| rx.try_iter().collect()) } +/// Largest message a socket buffers, the `ws` package's default `maxPayload`: a server streaming +/// one endless message must not exhaust memory. +const MAX_MESSAGE_BYTES: usize = 100 * 1024 * 1024; + fn run_socket(id: i32, url: String, protocols: Vec, tx: Sender<(i32, Event)>) { let (ws, connect) = match crate::winhttp::websocket_connect(&url, &protocols) { Ok(pair) => pair, @@ -157,6 +161,13 @@ fn run_socket(id: i32, url: String, protocols: Vec, tx: Sender<(i32, Eve let _ = tx.send((id, Event::Error(reason))); break Event::Close { code: 1006, reason: String::new(), clean: false }; } + if message.len() + read as usize > MAX_MESSAGE_BYTES { + unsafe { + let _ = WinHttpWebSocketShutdown(socket.ws.raw(), 1009, None, 0); + } + let _ = tx.send((id, Event::Error("WebSocket message exceeds 100 MiB".to_string()))); + break Event::Close { code: 1009, reason: String::new(), clean: false }; + } message.extend_from_slice(&buf[..read as usize]); match kind { WINHTTP_WEB_SOCKET_UTF8_FRAGMENT_BUFFER_TYPE | WINHTTP_WEB_SOCKET_BINARY_FRAGMENT_BUFFER_TYPE => {} diff --git a/runtime/src/win32.rs b/runtime/src/win32.rs index 99d64b3..43578ce 100644 --- a/runtime/src/win32.rs +++ b/runtime/src/win32.rs @@ -22,7 +22,9 @@ use std::cell::RefCell; use std::collections::HashMap; use std::ffi::{c_void, CString}; use windows::core::PCWSTR; -use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; +use windows::Win32::System::LibraryLoader::{ + GetProcAddress, LoadLibraryExW, LoadLibraryW, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, +}; static DLL_CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); @@ -104,8 +106,15 @@ fn load_dll(name: &str) -> Result { return Ok(h); } let wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + // A bare name searches only the app directory and System32: the current directory and PATH + // can hold a planted DLL of the same name. A path loads exactly that file. let hmod = unsafe { - LoadLibraryW(PCWSTR(wide.as_ptr())).map_err(|e| format!("LoadLibraryW({name}): {e}"))? + if name.contains(['\\', '/']) { + LoadLibraryW(PCWSTR(wide.as_ptr())) + } else { + LoadLibraryExW(PCWSTR(wide.as_ptr()), None, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS) + } + .map_err(|e| format!("LoadLibrary({name}): {e}"))? }; let handle = hmod.0 as usize; cache.insert(name.to_string(), handle); diff --git a/runtime/src/winhttp.rs b/runtime/src/winhttp.rs index cee8a79..2c6a3c8 100644 --- a/runtime/src/winhttp.rs +++ b/runtime/src/winhttp.rs @@ -8,11 +8,11 @@ use std::sync::OnceLock; use windows::core::{HSTRING, PCWSTR}; use windows::Win32::Networking::WinHttp::{ WinHttpCloseHandle, WinHttpConnect, WinHttpOpen, WinHttpOpenRequest, WinHttpQueryDataAvailable, - WinHttpQueryHeaders, WinHttpReadData, WinHttpReceiveResponse, WinHttpSendRequest, - WinHttpSetOption, WinHttpSetTimeouts, WinHttpWebSocketCompleteUpgrade, + WinHttpQueryHeaders, WinHttpQueryOption, WinHttpReadData, WinHttpReceiveResponse, + WinHttpSendRequest, WinHttpSetOption, WinHttpSetTimeouts, WinHttpWebSocketCompleteUpgrade, WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_DISABLE_COOKIES, WINHTTP_FLAG_REFRESH, WINHTTP_FLAG_SECURE, WINHTTP_OPEN_REQUEST_FLAGS, WINHTTP_OPTION_DISABLE_FEATURE, - WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET, WINHTTP_QUERY_CONTENT_TYPE, WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET, WINHTTP_OPTION_URL, WINHTTP_QUERY_CONTENT_TYPE, WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_QUERY_STATUS_CODE, }; @@ -74,6 +74,8 @@ pub(crate) struct Response { pub status: u32, pub content_type: String, pub body: Vec, + /// Where the response came from, after any redirects WinHTTP followed. + pub final_url: String, } /// Open a GET request (connected, not yet sent) for `url` with `extra_headers` (CRLF-separated). @@ -175,12 +177,38 @@ fn content_type(request: &Handle) -> String { } } +fn final_url(request: &Handle) -> String { + let mut len = 0u32; + unsafe { + let _ = WinHttpQueryOption(request.raw(), WINHTTP_OPTION_URL, None, &mut len); + } + let mut buf = vec![0u16; (len as usize).div_ceil(2)]; + if buf.is_empty() { + return String::new(); + } + let ok = unsafe { + WinHttpQueryOption( + request.raw(), + WINHTTP_OPTION_URL, + Some(buf.as_mut_ptr() as *mut c_void), + &mut len, + ) + .is_ok() + }; + if !ok { + return String::new(); + } + let chars = (len as usize / 2).min(buf.len()); + String::from_utf16_lossy(&buf[..chars]).trim_end_matches('\0').to_string() +} + /// Blocking GET. `headers` are extra request headers, CRLF-separated. pub(crate) fn get(url: &str, headers: &str) -> Result { let (_connect, request) = open_get(url)?; send_and_receive(&request, headers)?; let status = status_code(&request); let content_type = content_type(&request); + let final_url = final_url(&request); let mut body = Vec::new(); loop { let mut available = 0u32; @@ -206,12 +234,22 @@ pub(crate) fn get(url: &str, headers: &str) -> Result { break; } } - Ok(Response { status, content_type, body }) + Ok(Response { status, content_type, body, final_url }) +} + +/// RFC 9110 `token`: one or more visible ASCII characters other than delimiters. +pub(crate) fn is_http_token(s: &str) -> bool { + !s.is_empty() + && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)) } /// Perform the WebSocket upgrade handshake for a `ws://`/`wss://` URL and return the socket /// handle (plus the connection handle, which must outlive it). pub(crate) fn websocket_connect(url: &str, protocols: &[String]) -> Result<(Handle, Handle), String> { + // Subprotocols go into a request header, so anything but an HTTP token could inject headers. + if let Some(bad) = protocols.iter().find(|p| !is_http_token(p)) { + return Err(format!("invalid WebSocket subprotocol {bad:?}")); + } let (connect, request) = open_get(url)?; unsafe { WinHttpSetOption(Some(request.raw()), WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET, None) } .map_err(|e| format!("WebSocket upgrade option failed: {}", e.message()))?; diff --git a/windows-napi/index.d.ts b/windows-napi/index.d.ts index cfd2e6e..0fcb83e 100644 --- a/windows-napi/index.d.ts +++ b/windows-napi/index.d.ts @@ -45,8 +45,8 @@ export declare function pointerValue(value: unknown): number export declare function makeExternal(addr: number): unknown /** Parse `value` as a Buffer arg; returns `[dataPtr, byteLength]` as f64s. */ export declare function bufferInfo(value: unknown): Array -/** Parse `value` as a Struct arg; returns the data pointer as f64. */ -export declare function structPtr(value: unknown): number +/** Parse `value` as a Struct arg of `size` bytes (default 0); returns the data pointer as f64. */ +export declare function structPtr(value: unknown, size?: number | undefined | null): number /** * Write `value` into a native scratch slot as `ty`, then read it back to JS — exercises * `write_js_value_to_ptr` + `read_value_from_ptr` (including HSTRING ownership hand-off). diff --git a/windows-napi/src/value_test.rs b/windows-napi/src/value_test.rs index 12b9c3b..c125f5f 100644 --- a/windows-napi/src/value_test.rs +++ b/windows-napi/src/value_test.rs @@ -72,10 +72,10 @@ pub fn buffer_info(env: Env, value: JsUnknown) -> napi::Result> { Ok(vec![unsafe { nv.pointer } as usize as f64, len as f64]) } -/// Parse `value` as a Struct arg; returns the data pointer as f64. +/// Parse `value` as a Struct arg of `size` bytes (default 0); returns the data pointer as f64. #[napi] -pub fn struct_ptr(env: Env, value: JsUnknown) -> napi::Result { - let nv = napi_parse_struct(&env, &value).map_err(reason)?; +pub fn struct_ptr(env: Env, value: JsUnknown, size: Option) -> napi::Result { + let nv = napi_parse_struct(&env, &value, size.unwrap_or(0) as usize).map_err(reason)?; Ok(unsafe { nv.pointer } as usize as f64) } diff --git a/windows-napi/test/value-test2.js b/windows-napi/test/value-test2.js index 47c8b2c..42b7dea 100644 --- a/windows-napi/test/value-test2.js +++ b/windows-napi/test/value-test2.js @@ -21,18 +21,20 @@ check('ptr(null)', ns.pointerValue(null), 0); check('ptr(undefined)', ns.pointerValue(undefined), 0); // handle property forms check('ptr({handle:null})', ns.pointerValue({ handle: null }), 0); -check('ptr({handle:123})', ns.pointerValue({ handle: 123 }), 123); +// Numbers and strings can come from JSON, so only BigInt/External/null carry pointers. +throws('ptr({handle:123}) throws', () => ns.pointerValue({ handle: 123 })); check('ptr({handle:321n})', ns.pointerValue({ handle: 321n }), 321); -check('ptr({handle:fn->77})', ns.pointerValue({ handle: () => 77 }), 77); +throws('ptr({handle:fn->77}) throws', () => ns.pointerValue({ handle: () => 77 })); +check('ptr({handle:fn->77n})', ns.pointerValue({ handle: () => 77n }), 77); check('ptr({handle:fn->null})', ns.pointerValue({ handle: () => null }), 0); // external round-trip, direct and via handle const ext = ns.makeExternal(4096); check('ptr(external)', ns.pointerValue(ext), 4096); check('ptr({handle:external})', ns.pointerValue({ handle: ext }), 4096); // __native_ptr forms -check('ptr({__native_ptr:"0x10"})', ns.pointerValue({ __native_ptr: '0x10' }), 16); -check('ptr({__native_ptr:"42"})', ns.pointerValue({ __native_ptr: '42' }), 42); -check('ptr({__native_ptr:99})', ns.pointerValue({ __native_ptr: 99 }), 99); +throws('ptr({__native_ptr:"0x10"}) throws', () => ns.pointerValue({ __native_ptr: '0x10' })); +throws('ptr({__native_ptr:"42"}) throws', () => ns.pointerValue({ __native_ptr: '42' })); +throws('ptr({__native_ptr:99}) throws', () => ns.pointerValue({ __native_ptr: 99 })); check('ptr({__native_ptr:7n})', ns.pointerValue({ __native_ptr: 7n }), 7); // primitive boxing to IPropertyValue (nonzero COM pointers) check('ptr("hello") boxed', ns.pointerValue('hello'), p => p > 0); @@ -64,6 +66,8 @@ check('struct(ab).ptr', ns.structPtr(ab), abPtr); check('struct(view+4).ptr', ns.structPtr(u8), abPtr + 4); throws('struct(null) throws', () => ns.structPtr(null)); throws('struct(5) throws', () => ns.structPtr(5)); +check('struct(view+4, 8 bytes).ptr', ns.structPtr(u8, 8), abPtr + 4); +throws('struct shorter than its type throws', () => ns.structPtr(u8, 9)); // write -> read native slot round-trips for (const [ty, v] of [['bool', true], ['u8', 200], ['i8', -5], ['u16', 60000], ['i16', -12345],