Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .github/workflows/npm_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 16 additions & 8 deletions .github/workflows/windows-napi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ on:
- '.github/workflows/windows-napi.yml'
workflow_dispatch:

permissions:
contents: read

env:
CARGO_TERM_COLOR: always

Expand All @@ -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
Expand All @@ -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
Expand All @@ -63,17 +66,20 @@ 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
- name: Install npm dependencies
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/<triple> sub-packages
Expand All @@ -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"
65 changes: 65 additions & 0 deletions packages/test/security-check.js
Original file line number Diff line number Diff line change
@@ -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'); }
44 changes: 26 additions & 18 deletions packages/windows-quickjs/vendor/napi/quickjs/quickjs-api.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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)) {
Expand Down
86 changes: 84 additions & 2 deletions runtime-devtools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<tungstenite::handshake::server::Response, tungstenite::handshake::server::ErrorResponse>
{
// 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::<std::net::IpAddr>().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)
Expand Down Expand Up @@ -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";
Expand Down
Loading