Merge remote-tracking branch 'origin/main' into maintainer/pr-517-tests
This commit is contained in:
commit
16b6a09546
9 files changed
+679
-18
No files matched your search
Generated
+72
@@ -697,6 +697,12 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -1608,6 +1614,12 @@ dependencies = [
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@@ -1764,12 +1776,14 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"encoding_rs",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"url",
|
||||
"wreq",
|
||||
@@ -1850,6 +1864,16 @@ dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1961,6 +1985,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -2147,6 +2177,19 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -2275,6 +2318,7 @@ version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -2737,6 +2781,25 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -3682,6 +3745,15 @@ dependencies = [
|
||||
"tap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||
dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/h4ckf0r0day/obscura/main/assets/icon.png" alt="Obscura" width="80" />
|
||||
</p>
|
||||
|
||||
<h2 align="center">Obscura</h2>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/25837?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-25837" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/25837/daily" alt="h4ckf0r0day%2Fobscura | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://docs.obscura.sh"><img src="https://img.shields.io/badge/Docs-1a1a1a?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation" /></a>
|
||||
<a href="https://obscura.sh"><img src="https://img.shields.io/badge/Website-1a1a1a?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjE5MCAxNzAgNDIwIDQyMCI+PHBhdGggZmlsbD0iI0ZGRkZGRiIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNNDA2LDE3NS4wMDA0MTIgQzQ0MC40OTg5MzIsMTc1LjAwMDM1MSA0NzQuNDk4MTA4LDE3NS4wODA5OTQgNTA4LjQ5NjczNSwxNzQuOTc1NjYyIEM1MzcuNzMzNjQzLDE3NC44ODUwODYgNTYxLjI3MDY5MSwxODcuMjIwNTUxIDU3OS45Mzk2MzYsMjA4Ljc4NjYyMSBDNTkwLjg0MTA2NCwyMjEuMzc5Nzc2IDU5Ny44ODM1NDUsMjM2LjM1MjY2MSA1OTkuOTgxODczLDI1My4yMjAzNjcgQzYwMC40NDU1NTcsMjU2Ljk0NzU0MCA2MDAuOTUxMTExLDI2MC43MDQ0MzcgNjAwLjk1NjYwNCwyNjQuNDQ4MzY0IEM2MDEuMDIyNjQ0LDMwOS42MTM1MjUgNjAxLjA3NTgwNiwzNTQuNzc5MDIyIDYwMC45NTE3ODIsMzk5Ljk0MzkzOSBDNjAwLjkxMzE0Nyw0MTQuMDMyMzQ5IDYwMC42NjMyNjksNDI4LjE0MDEwNiA1OTkuNzgyMjg4LDQ0Mi4xOTMzNTkgQzU5OS41NDEwMTYsNDQ2LjA0MDg5NCA1OTcuNTYwNzkxLDQ1MC42MTU2MDEgNTk0Ljg1MzIxMCw0NTMuMzQxMzA5IEM1NzEuMTc3NTUxLDQ3Ny4xNzUzMjMgNTQ3LjE4MDcyNSw1MDAuNjkwNzk2IDUyMy4yMjc5NjYsNTI0LjI0ODc3OSBDNTA2Ljc5OTgzNSw1NDAuNDA2MTI4IDQ5MC4yMDk5OTEsNTU2LjM5OTkwMiA0NzMuODY0NTYzLDU3Mi42NDAxMzcgQzQ2OC44MDA4NDIsNTc3LjY3MTIwNCA0NjMuMDgyODg2LDU4MC4wNTQxOTkgNDU1Ljk3MDYxMiw1ODAuMDQzODIzIEM0MDAuOTcyNDQzLDU3OS45NjM1MDEgMzQ1Ljk3MzY2Myw1ODAuMTMyMDE5IDI5MC45NzYwNDQsNTc5LjkzNjc2OCBDMjY3LjU4MDQxNCw1NzkuODUzNjk5IDI0Ni41NTEyMDgsNTcyLjA0MzU3OSAyMjguOTM5NzQzLDU1Ni44Mjc1MTUgQzIxMi44NDk2MjUsNTQyLjkyNTg0MiAyMDIuNzUxOTY4LDUyNC45MDIxMDAgMTk4LjE1NjE1OCw1MDQuMDM5NjczIEMxOTcuMjgyMTk2LDUwMC4wNzIyOTYgMTk3LjA1MjQxNCw0OTUuODk1Mzg2IDE5Ny4wNDczMzMsNDkxLjgxNDQ4NCBDMTk2Ljk3ODE0OSw0MzYuMTQ5NjU4IDE5Ny4wNTA3MjAsMzgwLjQ4NDYxOSAxOTYuOTMyNjE3LDMyNC44MTk5NDYgQzE5Ni45MTkxNDQsMzE4LjQ3MDkxNyAxOTkuMTg0ODQ1LDMxMy40MTYxOTkgMjAzLjQ5NTU3NSwzMDkuMTAwNTI1IEMyNDAuNTg3OTk3LDI3MS45NjU0MjQgMjc3LjY4MjQ5NSwyMzQuODMyMzA2IDMxNC44MzQ1NjQsMTk3Ljc1Njk1OCBDMzIwLjk1NjQyMSwxOTEuNjQ3Nzk3IDMyNy4yNjQ0MzUsMTg1LjcxNTI1NiAzMzMuNjczOTIwLDE3OS45MDgwODEgQzMzNy4zNzYwMzgsMTc2LjU1MzgzMyAzNDEuNzIxNDY2LDE3NC44NTMzMTcgMzQ3LjAwNTc5OCwxNzQuOTEyODcyIEMzNjYuNTAxNzA5LDE3NS4xMzI2MTQgMzg2LjAwMTYxNywxNzUuMDAwMzIwIDQwNiwxNzUuMDAwNDEyIFogTTUwMy4zNDQ2NjYsMjczLjg0MDE0OSBDNTA0LjEwMjcyMiwyNzYuMTY3NTcyIDUwNC45NDA5NDgsMjc4LjIxMTA5MCA1MDQuOTQzMjY4LDI4MC4yNTU1MjQgQzUwNS4wMTI4NDgsMzQxLjc0MjI0OSA1MDQuOTY5MTQ3LDQwMy4yMjkwNjUgNTA1LjAzMDc5Miw0NjQuNzE1NzkwIEM1MDUuMDQwODAyLDQ3NC42ODY0OTMgNDk2LjExNzQ2Miw0ODMuOTUzMTg2IDQ4NS43Nzc5MjQsNDgzLjk2NTI3MSBDNDI0LjYyNDQ1MSw0ODQuMDM2ODk2IDM2My40NzA5MTcsNDg0LjAwMzIzNSAzMDIuMzE3MzgzLDQ4My45OTY3MzUgQzI5NS41ODc3NjksNDgzLjk5NjAzMyAyOTMuMDAxMjUxLDQ4MS4zMDE2MDUgMjkzLjAwMDkxNiw0NzQuMzM4NTYyIEMyOTIuOTk3ODY0LDQxMy4zNTE2NTQgMjkzLjIwOTYyNSwzNTIuMzYzMzEyIDI5Mi43ODA3MDEsMjkxLjM3OTM5NSBDMjkyLjcxMzUzMSwyODEuODI4MDk0IDMwMy4yMTMwNDMsMjcwLjgyNDE1OCAzMTMuNDc4MjcxLDI3MC44OTQzMTggQzM1MS45NjgyMDEsMjcxLjE1NzQ0MCAzOTAuNDYwOTk5LDI3MC45OTk3ODYgNDI4Ljk1MjcyOCwyNzAuOTk5Nzg2IEM0NTEuMTE0NjI0LDI3MC45OTk3ODYgNDczLjI3NzI4MywyNzAuOTE3NDgwIDQ5NS40Mzc0NjksMjcxLjExMjE1MiBDNDk3Ljk3NzAyMCwyNzEuMTM0NDkxIDUwMC41MDI5MzAsMjcyLjcwNDM3NiA1MDMuMzQ0NjY2LDI3My44NDAxNDkgWiI+PC9wYXRoPjwvc3ZnPgo=&logoColor=white" alt="Website" /></a>
|
||||
<a href="https://cal.com/obscura/quick-chat"><img src="https://img.shields.io/badge/Book_a_Demo-1a1a1a?style=for-the-badge&logo=googlecalendar&logoColor=white" alt="Book a demo" /></a>
|
||||
<a href="https://github.com/h4ckf0r0day/obscura/releases"><img src="https://img.shields.io/badge/Releases-1a1a1a?style=for-the-badge&logo=github&logoColor=white" alt="Releases" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>The open-source headless browser for AI agents and web scraping.</strong><br>
|
||||
Lightweight, stealthy, and built in Rust.
|
||||
@@ -132,6 +132,26 @@ Want to sponsor? Email [[email protected]](mailto:[email protected]).
|
||||
🎁 Use discount code <b>RAPID10</b> to get <b>10% off</b>.<br><br></b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="200" align="center" valign="middle">
|
||||
<a href="https://www.thordata.com/?ls=dob&lk=dob" target="_blank">
|
||||
<img alt="Thordata" src="/assets/sponsors/thordatalogo.png" width="180"/>
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
🚀 <b>Obscura × Thordata</b><br>
|
||||
Need more stable proxies for automation, public web scraping, SEO, or ad verification? Power your workflow with <a href="https://www.thordata.com/?ls=dob&lk=dob"><b>Thordata</b></a>.<br><br>
|
||||
<b>🌍 Residential Proxies: 100M+ real residential IPs in 190+ countries<br>
|
||||
♾️ Unlimited Proxies: for heavy traffic and high concurrency<br>
|
||||
✨ Recently upgraded residential IP pool<br>
|
||||
✨ Unlimited Concurrent Sessions<br>
|
||||
🔁 Rotating & Sticky Sessions<br>
|
||||
📍 Flexible GEO targeting<br>
|
||||
⚡ Stable HTTP(S) connections<br><br>
|
||||
🎁 Use code <b>obscura</b> for <b>10% off</b>.<br><br></b>
|
||||
Get started with a 3-day free trial and test Thordata with your own workflow.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Install
|
||||
@@ -153,6 +173,9 @@ tar xzf obscura-aarch64-linux.tar.gz
|
||||
# Arch Linux (AUR)
|
||||
yay -S obscura-browser
|
||||
|
||||
# NixOS
|
||||
nix-env -iA nixpkgs.obscura
|
||||
|
||||
# macOS Apple Silicon
|
||||
curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-aarch64-macos.tar.gz
|
||||
tar xzf obscura-aarch64-macos.tar.gz
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -117,3 +117,134 @@ async fn dynamic_external_scripts_execute_and_fire_load() {
|
||||
"dynamic scripts must execute and fire load before navigation settles"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn dynamic_data_scripts_execute_before_chained_load_handlers() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session_id = "session-1";
|
||||
ctx.sessions.insert(session_id.to_string(), page_id);
|
||||
|
||||
cdp(
|
||||
&mut ctx,
|
||||
1,
|
||||
"Page.navigate",
|
||||
json!({
|
||||
"url": "data:text/html,<html><head></head><body></body></html>",
|
||||
"waitUntil": "load"
|
||||
}),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
cdp(
|
||||
&mut ctx,
|
||||
2,
|
||||
"Runtime.evaluate",
|
||||
json!({
|
||||
"expression": r#"(function () {
|
||||
var state = {
|
||||
aExec: false,
|
||||
aLoad: false,
|
||||
bExec: false,
|
||||
bLoad: false,
|
||||
cExec: false,
|
||||
cLoad: false
|
||||
};
|
||||
window.__dataScriptState = state;
|
||||
|
||||
var a = document.createElement('script');
|
||||
a.src = 'data:,window.__dataScriptState.aExec=true';
|
||||
a.onload = function () {
|
||||
state.aLoad = true;
|
||||
var b = document.createElement('script');
|
||||
b.src = 'data:text/html,' + encodeURIComponent('window.__dataScriptState.bExec=true');
|
||||
b.onload = function () {
|
||||
state.bLoad = true;
|
||||
var c = document.createElement('script');
|
||||
c.src = 'data:text/javascript;base64,' +
|
||||
btoa('window.__dataScriptState.cExec=true').replace(/=+$/, '') +
|
||||
'#ignored-fragment';
|
||||
c.onload = function () { state.cLoad = true; };
|
||||
document.head.appendChild(c);
|
||||
};
|
||||
document.head.appendChild(b);
|
||||
};
|
||||
document.head.appendChild(a);
|
||||
return 'kicked';
|
||||
})()"#,
|
||||
"returnByValue": true,
|
||||
}),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
ctx.pages[0].settle(500).await;
|
||||
|
||||
let result = cdp(
|
||||
&mut ctx,
|
||||
3,
|
||||
"Runtime.evaluate",
|
||||
json!({
|
||||
"expression": "JSON.stringify(window.__dataScriptState)",
|
||||
"returnByValue": true,
|
||||
}),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
result["result"]["value"],
|
||||
r#"{"aExec":true,"aLoad":true,"bExec":true,"bLoad":true,"cExec":true,"cLoad":true}"#,
|
||||
"data URL script bodies must execute before each chained load handler"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn invalid_dynamic_data_script_fires_error_not_load() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session_id = "session-1";
|
||||
ctx.sessions.insert(session_id.to_string(), page_id);
|
||||
|
||||
cdp(
|
||||
&mut ctx,
|
||||
1,
|
||||
"Page.navigate",
|
||||
json!({"url": "data:text/html,<html><head></head><body></body></html>", "waitUntil": "load"}),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
cdp(
|
||||
&mut ctx,
|
||||
2,
|
||||
"Runtime.callFunctionOn",
|
||||
json!({
|
||||
"functionDeclaration": r#"function () {
|
||||
window.__invalidDataScript = { error: false, load: false };
|
||||
var script = document.createElement('script');
|
||||
script.src = 'data:text/javascript;base64,!';
|
||||
script.onerror = function () { window.__invalidDataScript.error = true; };
|
||||
script.onload = function () { window.__invalidDataScript.load = true; };
|
||||
document.head.appendChild(script);
|
||||
}"#,
|
||||
"awaitPromise": true,
|
||||
}),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
let result = cdp(
|
||||
&mut ctx,
|
||||
3,
|
||||
"Runtime.evaluate",
|
||||
json!({
|
||||
"expression": "JSON.stringify(window.__invalidDataScript)",
|
||||
"returnByValue": true,
|
||||
}),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result["result"]["value"], r#"{"error":true,"load":false}"#);
|
||||
}
|
||||
Vendored
+97
-5
@@ -18,7 +18,7 @@
|
||||
'__obscura_hw', '__obscura_mem',
|
||||
'__documentReadyState__', '__currentUrl',
|
||||
// internal helpers (var-declared throughout the file)
|
||||
'__processDynScriptQueue', '_markNative', '_fpRand', '_fpNoise',
|
||||
'__processDynScriptQueue', '_decodeDataScriptUrl', '_markNative', '_fpRand', '_fpNoise',
|
||||
'_fpCache', '_getFp', '_fp', '_splitAsciiWhitespace',
|
||||
'_getElementsByClassName', '_docEncoding', '_docIsUtf8',
|
||||
'_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase',
|
||||
@@ -163,6 +163,51 @@ let _fpSeed = 0;
|
||||
// when SPAs dynamically insert multiple <script module> tags at once.
|
||||
let __dynScriptQueue = [];
|
||||
let __dynScriptBusy = false;
|
||||
function _decodeDataScriptUrl(url) {
|
||||
const comma = url.indexOf(',');
|
||||
if (!url.startsWith('data:') || comma < 5) {
|
||||
throw new TypeError('Invalid dynamic script data URL');
|
||||
}
|
||||
|
||||
const meta = url.slice(5, comma);
|
||||
const fragment = url.indexOf('#', comma + 1);
|
||||
const payload = url.slice(comma + 1, fragment < 0 ? url.length : fragment);
|
||||
if (meta.split(';').some(part => part.toLowerCase() === 'base64')) {
|
||||
let encoded = payload.replace(/[\r\n\t\f ]/g, '');
|
||||
const remainder = encoded.length % 4;
|
||||
if (remainder === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || /=/.test(encoded.slice(0, -2))) {
|
||||
throw new TypeError('Invalid dynamic script data URL base64');
|
||||
}
|
||||
if (remainder > 0) encoded += '='.repeat(4 - remainder);
|
||||
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) {
|
||||
throw new TypeError('Invalid dynamic script data URL base64');
|
||||
}
|
||||
return new TextDecoder().decode(_base64ToUint8Array(encoded));
|
||||
}
|
||||
|
||||
const bytes = [];
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
const code = payload.charCodeAt(i);
|
||||
if (code === 0x25 && i + 2 < payload.length) {
|
||||
const hi = _hexv(payload.charCodeAt(i + 1));
|
||||
const lo = _hexv(payload.charCodeAt(i + 2));
|
||||
if (hi >= 0 && lo >= 0) {
|
||||
bytes.push(hi * 16 + lo);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (code < 0x80) {
|
||||
bytes.push(code);
|
||||
} else {
|
||||
const character = String.fromCodePoint(payload.codePointAt(i));
|
||||
if (character.length === 2) i++;
|
||||
const encoded = new TextEncoder().encode(character);
|
||||
for (let j = 0; j < encoded.length; j++) bytes.push(encoded[j]);
|
||||
}
|
||||
}
|
||||
return new TextDecoder().decode(new Uint8Array(bytes));
|
||||
}
|
||||
async function __processDynScriptQueue() {
|
||||
if (__dynScriptBusy) return;
|
||||
__dynScriptBusy = true;
|
||||
@@ -175,12 +220,17 @@ async function __processDynScriptQueue() {
|
||||
try {
|
||||
if (task.isModule) {
|
||||
await import(task.url);
|
||||
} else {
|
||||
let body;
|
||||
if (task.url.startsWith('data:')) {
|
||||
body = _decodeDataScriptUrl(task.url);
|
||||
} else {
|
||||
const raw = await Deno.core.ops.op_fetch_url(task.url, "GET", "{}", "", task.pageOrigin, "no-cors");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.body) {
|
||||
body = JSON.parse(raw).body;
|
||||
}
|
||||
if (body) {
|
||||
globalThis.__currentScriptNid = task.nid;
|
||||
try { (0, eval)(parsed.body); }
|
||||
try { (0, eval)(body); }
|
||||
catch(e) { console.error('Dynamic script error (' + task.url + '):', e.message); }
|
||||
finally { globalThis.__currentScriptNid = task.prevNid || 0; }
|
||||
}
|
||||
@@ -2626,6 +2676,13 @@ class Document extends Node {
|
||||
// returned a generic Event for every type, which broke libraries that call
|
||||
// createEvent('CustomEvent').initCustomEvent(...) — see issue #41.
|
||||
createEvent(type) {
|
||||
const normalized = String(type || '').toLowerCase();
|
||||
if (normalized === 'promiserejectionevent') {
|
||||
throw new DOMException(
|
||||
"The provided event type ('PromiseRejectionEvent') is invalid",
|
||||
'NotSupportedError'
|
||||
);
|
||||
}
|
||||
const map = {
|
||||
'customevent': CustomEvent, 'customevents': CustomEvent,
|
||||
'mouseevent': MouseEvent, 'mouseevents': MouseEvent,
|
||||
@@ -2640,8 +2697,9 @@ class Document extends Node {
|
||||
'popstateevent': PopStateEvent,
|
||||
'animationevent': AnimationEvent,
|
||||
'transitionevent': TransitionEvent,
|
||||
'storageevent': StorageEvent,
|
||||
};
|
||||
const Cls = map[String(type || '').toLowerCase()] || Event;
|
||||
const Cls = map[normalized] || Event;
|
||||
return new Cls('');
|
||||
}
|
||||
createRange() { return new Range(); }
|
||||
@@ -5020,6 +5078,40 @@ globalThis.ToggleEvent = class ToggleEvent extends Event {
|
||||
};
|
||||
_markNative(globalThis.ToggleEvent);
|
||||
|
||||
globalThis.PromiseRejectionEvent = class PromiseRejectionEvent extends Event {
|
||||
constructor(type, init) {
|
||||
if (arguments.length < 2 || init == null || !('promise' in Object(init))) {
|
||||
throw new TypeError(
|
||||
"Failed to construct 'PromiseRejectionEvent': required member promise is undefined."
|
||||
);
|
||||
}
|
||||
super(type, init);
|
||||
this.promise = init.promise;
|
||||
this.reason = init.reason;
|
||||
}
|
||||
};
|
||||
_markNative(globalThis.PromiseRejectionEvent);
|
||||
|
||||
globalThis.StorageEvent = class StorageEvent extends Event {
|
||||
constructor(type, init = {}) {
|
||||
super(type, init);
|
||||
this.key = init.key !== undefined ? init.key : null;
|
||||
this.oldValue = init.oldValue !== undefined ? init.oldValue : null;
|
||||
this.newValue = init.newValue !== undefined ? init.newValue : null;
|
||||
this.url = init.url || "";
|
||||
this.storageArea = init.storageArea || null;
|
||||
}
|
||||
initStorageEvent(type, bubbles, cancelable, key, oldValue, newValue, url, storageArea) {
|
||||
this.initEvent(type, bubbles, cancelable);
|
||||
this.key = key !== undefined ? key : null;
|
||||
this.oldValue = oldValue !== undefined ? oldValue : null;
|
||||
this.newValue = newValue !== undefined ? newValue : null;
|
||||
this.url = url || "";
|
||||
this.storageArea = storageArea || null;
|
||||
}
|
||||
};
|
||||
_markNative(globalThis.StorageEvent);
|
||||
|
||||
// AbortController / AbortSignal. AbortSignal is a real constructor with a
|
||||
// prototype, so feature-detection and `AbortSignal.prototype` access work. It
|
||||
// carries aborted/reason, supports throwIfAborted(), and fires "abort" to
|
||||
|
||||
@@ -3114,6 +3114,101 @@ mod tests {
|
||||
assert_eq!(kind, serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_promise_rejection_event_requires_promise() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"(() => {
|
||||
const promise = Promise.resolve(1);
|
||||
const event = new PromiseRejectionEvent('unhandledrejection', {
|
||||
promise,
|
||||
reason: 'failed'
|
||||
});
|
||||
let missingPromiseThrows = false;
|
||||
try {
|
||||
new PromiseRejectionEvent('unhandledrejection');
|
||||
} catch (error) {
|
||||
missingPromiseThrows = error instanceof TypeError;
|
||||
}
|
||||
return [
|
||||
event instanceof Event,
|
||||
event.promise === promise,
|
||||
event.reason === 'failed',
|
||||
missingPromiseThrows
|
||||
];
|
||||
})()"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, serde_json::json!([true, true, true, true]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_event_rejects_promise_rejection_event() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"(() => {
|
||||
try {
|
||||
document.createEvent('PromiseRejectionEvent');
|
||||
return null;
|
||||
} catch (error) {
|
||||
return [error.name, error instanceof DOMException];
|
||||
}
|
||||
})()"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!(["NotSupportedError", true])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_event_constructor_and_legacy_factory() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"(() => {
|
||||
const event = new StorageEvent('storage', {
|
||||
key: 'theme',
|
||||
oldValue: 'light',
|
||||
newValue: 'dark',
|
||||
url: 'https://example.test/'
|
||||
});
|
||||
const legacy = document.createEvent('StorageEvent');
|
||||
legacy.initStorageEvent(
|
||||
'storage', false, false, 'count', '1', '2',
|
||||
'https://example.test/', null
|
||||
);
|
||||
return [
|
||||
event instanceof Event,
|
||||
event.key,
|
||||
event.oldValue,
|
||||
event.newValue,
|
||||
event.url,
|
||||
legacy instanceof StorageEvent,
|
||||
legacy.key,
|
||||
legacy.newValue
|
||||
];
|
||||
})()"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([
|
||||
true,
|
||||
"theme",
|
||||
"light",
|
||||
"dark",
|
||||
"https://example.test/",
|
||||
true,
|
||||
"count",
|
||||
"2"
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_markdown_headings() {
|
||||
let mut rt = setup_runtime("<html><body><h1>Title</h1><h2>Sub</h2><p>Body</p></body></html>");
|
||||
|
||||
@@ -770,9 +770,9 @@ fn tool_fill(args: &Value, state: &mut BrowserState) -> Result<String, String> {
|
||||
r#"(function(){{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return "error:element not found";
|
||||
el.value = {val};
|
||||
el.dispatchEvent(new Event("input", {{bubbles:true}}));
|
||||
el.dispatchEvent(new Event("change", {{bubbles:true}}));
|
||||
globalThis.__obscura_setFieldValue(el, "value", {val});
|
||||
el.dispatchEvent(globalThis.__obscura_markTrusted(new Event("input", {{bubbles:true}})));
|
||||
el.dispatchEvent(globalThis.__obscura_markTrusted(new Event("change", {{bubbles:true}})));
|
||||
return "ok";
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(&selector).unwrap(),
|
||||
@@ -796,8 +796,8 @@ fn tool_type(args: &Value, state: &mut BrowserState) -> Result<String, String> {
|
||||
r#"(function(){{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return "error:element not found";
|
||||
el.value = (el.value || "") + {txt};
|
||||
el.dispatchEvent(new Event("input", {{bubbles:true}}));
|
||||
globalThis.__obscura_setFieldValue(el, "value", (el.value || "") + {txt});
|
||||
el.dispatchEvent(globalThis.__obscura_markTrusted(new Event("input", {{bubbles:true}})));
|
||||
return "ok";
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(&selector).unwrap(),
|
||||
@@ -1329,9 +1329,9 @@ fn tool_fill_form(args: &Value, state: &mut BrowserState) -> Result<String, Stri
|
||||
_ => format!(r#"(function(){{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return "error:not found";
|
||||
el.value = {val};
|
||||
el.dispatchEvent(new Event('input', {{bubbles:true}}));
|
||||
el.dispatchEvent(new Event('change', {{bubbles:true}}));
|
||||
globalThis.__obscura_setFieldValue(el, 'value', {val});
|
||||
el.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {{bubbles:true}})));
|
||||
el.dispatchEvent(globalThis.__obscura_markTrusted(new Event('change', {{bubbles:true}})));
|
||||
return "ok";
|
||||
}})()"#, sel = serde_json::to_string(&selector).unwrap(), val = serde_json::to_string(value).unwrap()),
|
||||
};
|
||||
@@ -1769,3 +1769,81 @@ fn tool_set_storage_state(args: &Value, state: &mut BrowserState) -> Result<Stri
|
||||
}
|
||||
Ok(format!("Restored {applied} state entries."))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn fill_tools_notify_controlled_input_tracker() {
|
||||
let mut state = BrowserState::new(None, None, false);
|
||||
state
|
||||
.page_mut()
|
||||
.navigate("data:text/html,<div id=root><input id=field></div>")
|
||||
.await
|
||||
.expect("test page should navigate");
|
||||
|
||||
state.page_mut().evaluate(
|
||||
r#"(function () {
|
||||
var root = document.getElementById('root');
|
||||
var input = document.getElementById('field');
|
||||
var descriptor = Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value');
|
||||
var tracked = String(input.value);
|
||||
Object.defineProperty(input, 'value', {
|
||||
configurable: true,
|
||||
get: function () { return descriptor.get.call(this); },
|
||||
set: function (value) {
|
||||
tracked = String(value);
|
||||
descriptor.set.call(this, value);
|
||||
}
|
||||
});
|
||||
window.__controlledState = '';
|
||||
window.__controlledUpdates = 0;
|
||||
window.__lastInputTarget = '';
|
||||
window.__lastInputTrusted = false;
|
||||
root.addEventListener('input', function (event) {
|
||||
window.__lastInputTarget = event.target.id;
|
||||
window.__lastInputTrusted = event.isTrusted;
|
||||
var next = String(event.target.value);
|
||||
if (next !== tracked) {
|
||||
tracked = next;
|
||||
window.__controlledState = next;
|
||||
window.__controlledUpdates++;
|
||||
}
|
||||
});
|
||||
})()"#,
|
||||
);
|
||||
|
||||
tool_fill(
|
||||
&json!({ "selector": "#field", "value": "filled" }),
|
||||
&mut state,
|
||||
)
|
||||
.expect("browser_fill should succeed");
|
||||
tool_type(
|
||||
&json!({ "selector": "#field", "text": "-typed" }),
|
||||
&mut state,
|
||||
)
|
||||
.expect("browser_type should succeed");
|
||||
tool_fill_form(
|
||||
&json!({
|
||||
"fields": [{ "selector": "#field", "value": "form-filled" }]
|
||||
}),
|
||||
&mut state,
|
||||
)
|
||||
.expect("browser_fill_form should succeed");
|
||||
|
||||
let actual = state.page_mut().evaluate(
|
||||
r#"JSON.stringify({
|
||||
domValue: document.getElementById('field').value,
|
||||
controlledState: window.__controlledState,
|
||||
controlledUpdates: window.__controlledUpdates,
|
||||
lastInputTarget: window.__lastInputTarget,
|
||||
lastInputTrusted: window.__lastInputTrusted
|
||||
})"#,
|
||||
);
|
||||
assert_eq!(
|
||||
actual,
|
||||
json!(r#"{"domValue":"form-filled","controlledState":"form-filled","controlledUpdates":3,"lastInputTarget":"field","lastInputTrusted":true}"#),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,15 @@ tempfile = "3"
|
||||
# Pin exact versions so the build always resolves to the API wreq_client.rs targets.
|
||||
wreq-util = { version = "=3.0.0-rc.12", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# TLS fixtures for the configured-roots tests: rcgen mints a throwaway CA + leaf,
|
||||
# tokio-rustls serves one HTTPS response with them. Pinned to the ring
|
||||
# provider so the test binary has a single rustls CryptoProvider (reqwest's
|
||||
# rustls also uses ring; adding aws-lc-rs would make provider auto-selection
|
||||
# panic).
|
||||
rcgen = "0.13"
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||
|
||||
# `prefix-symbols` renames BoringSSL exports to avoid clashes with system OpenSSL,
|
||||
# but the renaming is only correct on Linux and Android (per wreq's README).
|
||||
# Enabling it on other platforms produces unresolved `build_script_main_*`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
@@ -14,6 +14,52 @@ use url::Url;
|
||||
use crate::cookies::CookieJar;
|
||||
use crate::interceptor::{InterceptAction, RequestInterceptor};
|
||||
|
||||
fn configured_root_paths() -> Vec<std::path::PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
if let Some(path) = std::env::var_os("SSL_CERT_FILE").filter(|path| !path.is_empty()) {
|
||||
paths.push(path.into());
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("SSL_CERT_DIR").filter(|path| !path.is_empty()) {
|
||||
match std::fs::read_dir(directory) {
|
||||
Ok(entries) => {
|
||||
paths.extend(entries.filter_map(|entry| entry.ok().map(|entry| entry.path())));
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "failed to read SSL_CERT_DIR");
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
fn configured_root_certificates() -> &'static [reqwest::Certificate] {
|
||||
static ROOTS: OnceLock<Vec<reqwest::Certificate>> = OnceLock::new();
|
||||
|
||||
ROOTS.get_or_init(|| {
|
||||
let mut certificates = Vec::new();
|
||||
for path in configured_root_paths() {
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, path = %path.display(), "failed to read CA certificate file");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match reqwest::Certificate::from_pem_bundle(&bytes) {
|
||||
Ok(mut bundle) if !bundle.is_empty() => certificates.append(&mut bundle),
|
||||
_ => match reqwest::Certificate::from_der(&bytes) {
|
||||
Ok(certificate) => certificates.push(certificate),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, path = %path.display(), "failed to parse CA certificate file");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
certificates
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Response {
|
||||
pub url: Url,
|
||||
@@ -456,6 +502,14 @@ impl ObscuraHttpClient {
|
||||
.dns_resolver(Arc::new(SsrfGuardResolver::new(self.allow_private_network)))
|
||||
;
|
||||
|
||||
if std::env::var_os("SSL_CERT_FILE").is_some()
|
||||
|| std::env::var_os("SSL_CERT_DIR").is_some()
|
||||
{
|
||||
for certificate in configured_root_certificates() {
|
||||
builder = builder.add_root_certificate(certificate.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref proxy) = self.proxy_url {
|
||||
if let Ok(p) = reqwest::Proxy::all(proxy.as_str()) {
|
||||
builder = builder.proxy(p);
|
||||
@@ -764,10 +818,12 @@ pub enum ObscuraNetError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod ssrf_tests {
|
||||
use super::{is_forbidden_ip, validate_url, SsrfGuardResolver};
|
||||
use super::{is_forbidden_ip, validate_url, ObscuraHttpClient, SsrfGuardResolver};
|
||||
use crate::cookies::CookieJar;
|
||||
use reqwest::dns::{Name, Resolve};
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
@@ -852,4 +908,109 @@ mod ssrf_tests {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a throwaway CA plus a 127.0.0.1 leaf it signed, and serve one
|
||||
/// canned HTTPS response with the leaf on an ephemeral port. Returns the
|
||||
/// port and the CA certificate as PEM.
|
||||
async fn https_fixture_with_private_ca() -> (u16, String) {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let ca_key = rcgen::KeyPair::generate().unwrap();
|
||||
let mut ca_params = rcgen::CertificateParams::new(Vec::new()).unwrap();
|
||||
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
|
||||
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
|
||||
|
||||
let leaf_key = rcgen::KeyPair::generate().unwrap();
|
||||
let leaf_params =
|
||||
rcgen::CertificateParams::new(vec!["127.0.0.1".to_string()]).unwrap();
|
||||
let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap();
|
||||
|
||||
let certs = vec![tokio_rustls::rustls::pki_types::CertificateDer::from(
|
||||
leaf_cert.der().to_vec(),
|
||||
)];
|
||||
let key = tokio_rustls::rustls::pki_types::PrivateKeyDer::Pkcs8(
|
||||
tokio_rustls::rustls::pki_types::PrivatePkcs8KeyDer::from(
|
||||
leaf_key.serialize_der(),
|
||||
),
|
||||
);
|
||||
let config = tokio_rustls::rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)
|
||||
.unwrap();
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
let acceptor = acceptor.clone();
|
||||
tokio::spawn(async move {
|
||||
let Ok(mut tls) = acceptor.accept(stream).await else {
|
||||
return; // Handshake rejection is the point of one test.
|
||||
};
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = tls.read(&mut buf).await;
|
||||
let body = "private ca ok";
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = tls.write_all(resp.as_bytes()).await;
|
||||
let _ = tls.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
(port, ca_cert.pem())
|
||||
}
|
||||
|
||||
// The two configured-roots tests set/rely on SSL_CERT_FILE, which is
|
||||
// cached once per process at client build. They are only correct under
|
||||
// `cargo nextest` (one process per test), the same constraint the whole
|
||||
// workspace already has.
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_roots_trust_a_private_ca_via_ssl_cert_file() {
|
||||
let (port, ca_pem) = https_fixture_with_private_ca().await;
|
||||
let ca_file = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(ca_file.path(), ca_pem).unwrap();
|
||||
std::env::set_var("SSL_CERT_FILE", ca_file.path());
|
||||
|
||||
let client =
|
||||
ObscuraHttpClient::with_full_options(Arc::new(CookieJar::new()), None, true);
|
||||
let url = Url::parse(&format!("https://127.0.0.1:{port}/")).unwrap();
|
||||
let resp = client.fetch(&url).await.expect("private CA in SSL_CERT_FILE must be trusted");
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(resp.text(), "private ca ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_roots_trust_a_private_ca_via_ssl_cert_dir() {
|
||||
let (port, ca_pem) = https_fixture_with_private_ca().await;
|
||||
let ca_dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(ca_dir.path().join("private-ca.pem"), ca_pem).unwrap();
|
||||
std::env::set_var("SSL_CERT_DIR", ca_dir.path());
|
||||
|
||||
let client =
|
||||
ObscuraHttpClient::with_full_options(Arc::new(CookieJar::new()), None, true);
|
||||
let url = Url::parse(&format!("https://127.0.0.1:{port}/")).unwrap();
|
||||
let resp = client
|
||||
.fetch(&url)
|
||||
.await
|
||||
.expect("private CA in SSL_CERT_DIR must be trusted");
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(resp.text(), "private ca ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_ca_is_still_rejected_without_ssl_cert_file() {
|
||||
// The same fixture that the SSL_CERT_FILE test trusts must fail here. The
|
||||
// listener is reachable (same setup), so an Err can only be TLS.
|
||||
let (port, _ca_pem) = https_fixture_with_private_ca().await;
|
||||
let client =
|
||||
ObscuraHttpClient::with_full_options(Arc::new(CookieJar::new()), None, true);
|
||||
let url = Url::parse(&format!("https://127.0.0.1:{port}/")).unwrap();
|
||||
assert!(client.fetch(&url).await.is_err(), "unknown CA must be rejected");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user