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" 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/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)) { 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"; 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/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/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/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/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 @@ - 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 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],