fix(render): apply the SSRF deny-set to the renderer's standalone HTTP loader
The render crate's compatibility loader (a pooled ureq agent behind RenderResourceCache::default) fetched images and fonts with no network policy at all. A page-owned runtime never uses it (it is cache-only and fetches through obscura-net), but every standalone caller did: the paint_file tool, library users rendering a bare DOM, and a JS runtime with no page transport. Markup referencing http://127.0.0.1/, http://169.254.169.254/ or a hostname that resolves to a private address made the renderer connect to it. Move is_forbidden_ip and env_allows_private_network into a new zero-dependency obscura-ssrf crate so obscura-net and obscura-render enforce one deny-set (obscura-net re-exports both, so its API and the wreq transport are unchanged). Install a ureq resolver on the render agent that rejects any resolved address in the deny-set unless OBSCURA_ALLOW_PRIVATE_NETWORK is set, which covers literal addresses, DNS rebinding, and every redirect hop. A resolver refusal surfaces as a DNS transport error; the retry loop now returns immediately on those instead of sleeping through 600ms of backoff for a request it will not make.
This commit is contained in:
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -2080,6 +2080,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"obscura-ssrf",
|
||||
"psl",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
@@ -2107,6 +2108,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"obscura-dom",
|
||||
"obscura-ssrf",
|
||||
"resvg",
|
||||
"swash",
|
||||
"taffy",
|
||||
@@ -2117,6 +2119,10 @@ dependencies = [
|
||||
"wuff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "obscura-ssrf"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/obscura-dom",
|
||||
"crates/obscura-ssrf",
|
||||
"crates/obscura-net",
|
||||
"crates/obscura-browser",
|
||||
"crates/obscura-cdp",
|
||||
@@ -20,6 +21,7 @@ repository = "https://github.com/h4ckf0r0day/obscura"
|
||||
|
||||
[workspace.dependencies]
|
||||
obscura-dom = { path = "crates/obscura-dom" }
|
||||
obscura-ssrf = { path = "crates/obscura-ssrf" }
|
||||
obscura-net = { path = "crates/obscura-net" }
|
||||
obscura-browser = { path = "crates/obscura-browser" }
|
||||
obscura-cdp = { path = "crates/obscura-cdp" }
|
||||
|
||||
@@ -10,6 +10,7 @@ default = []
|
||||
stealth = ["wreq", "wreq-util", "futures-util"]
|
||||
|
||||
[dependencies]
|
||||
obscura-ssrf = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::{Arc, OnceLock};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT};
|
||||
@@ -592,115 +592,7 @@ impl Default for CallbackRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-wide opt-in via env var. Older flow that issue #4 introduced. The
|
||||
/// new `--allow-private-network` CLI flag (issue #33) sets a per-client field
|
||||
/// that is OR'd with this so existing scripts and Docker setups that pin the
|
||||
/// env var keep working unchanged.
|
||||
pub fn env_allows_private_network() -> bool {
|
||||
matches!(
|
||||
std::env::var("OBSCURA_ALLOW_PRIVATE_NETWORK")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref(),
|
||||
Some("1") | Some("true") | Some("yes") | Some("on")
|
||||
)
|
||||
}
|
||||
|
||||
/// True when `ip` must never be the target of an outbound request from the
|
||||
/// engine: loopback, RFC1918 private, link-local (incl. the 169.254.169.254
|
||||
/// cloud-metadata endpoint), broadcast, documentation, the unspecified address
|
||||
/// (0.0.0.0 / ::, which the OS routes to localhost), IPv6 unique-local
|
||||
/// (fc00::/7), and any IPv4-mapped/compatible IPv6 form of the above.
|
||||
/// Centralizes the SSRF deny-set so the literal-host check and the
|
||||
/// DNS-resolution check (`SsrfGuardResolver`) can never disagree.
|
||||
pub fn is_forbidden_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
let o = v4.octets();
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_broadcast()
|
||||
|| v4.is_documentation()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_multicast()
|
||||
|| o[0] == 0
|
||||
// std's is_private() covers only RFC1918, so add the IANA
|
||||
// special-purpose ranges that also host internal services and
|
||||
// are common SSRF targets:
|
||||
// 100.64.0.0/10 CGNAT / RFC6598 — cloud metadata (e.g.
|
||||
// Alibaba 100.100.100.200) lives here.
|
||||
// 198.18.0.0/15 benchmarking / RFC2544.
|
||||
// 192.88.99.0/24 6to4 relay anycast / RFC7526.
|
||||
|| (o[0] == 100 && (64..=127).contains(&o[1]))
|
||||
|| (o[0] == 198 && (o[1] == 18 || o[1] == 19))
|
||||
|| (o[0] == 192 && o[1] == 88 && o[2] == 99)
|
||||
// Most of 192.0.0.0/24 is special-purpose and not globally
|
||||
// reachable. Keep the two globally reachable PCP anycast
|
||||
// assignments usable rather than blocking the entire /24.
|
||||
|| (o[0] == 192
|
||||
&& o[1] == 0
|
||||
&& o[2] == 0
|
||||
&& o[3] != 9
|
||||
&& o[3] != 10)
|
||||
// 240.0.0.0/4 is reserved (255.255.255.255 was already
|
||||
// covered by is_broadcast()).
|
||||
|| o[0] >= 240
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
if v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_unique_local()
|
||||
|| v6.is_unicast_link_local()
|
||||
|| v6.is_multicast()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Unwrap IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d)
|
||||
// forms and re-check the embedded v4 so e.g. [::ffff:127.0.0.1] or
|
||||
// [::ffff:169.254.169.254] cannot slip past the v6 arm.
|
||||
if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
|
||||
return is_forbidden_ip(IpAddr::V4(v4));
|
||||
}
|
||||
|
||||
let s = v6.segments();
|
||||
// IPv4/IPv6 translation prefix (RFC 6052). Only /96 has a fixed
|
||||
// embedded-address position; the local-use /48 is therefore
|
||||
// blocked outright below.
|
||||
if s[0] == 0x64
|
||||
&& s[1] == 0xff9b
|
||||
&& s[2] == 0
|
||||
&& s[3] == 0
|
||||
&& s[4] == 0
|
||||
&& s[5] == 0
|
||||
{
|
||||
return is_forbidden_ip(IpAddr::V4(Ipv4Addr::new(
|
||||
(s[6] >> 8) as u8,
|
||||
s[6] as u8,
|
||||
(s[7] >> 8) as u8,
|
||||
s[7] as u8,
|
||||
)));
|
||||
}
|
||||
// 6to4 carries its IPv4 endpoint in bits 16..48.
|
||||
if s[0] == 0x2002 {
|
||||
return is_forbidden_ip(IpAddr::V4(Ipv4Addr::new(
|
||||
(s[1] >> 8) as u8,
|
||||
s[1] as u8,
|
||||
(s[2] >> 8) as u8,
|
||||
s[2] as u8,
|
||||
)));
|
||||
}
|
||||
|
||||
// Discard-only, local-use NAT64, and documentation prefixes.
|
||||
(s[0] == 0x100 && s[1] == 0 && s[2] == 0 && s[3] == 0)
|
||||
|| (s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1)
|
||||
|| (s[0] == 0x2001 && s[1] == 0x0db8)
|
||||
|| (s[0] == 0x3fff && s[1] & 0xf000 == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use obscura_ssrf::{env_allows_private_network, is_forbidden_ip};
|
||||
|
||||
/// DNS resolver that performs the lookup and then rejects the whole request if
|
||||
/// ANY resolved address is in the SSRF deny-set. This closes the DNS-rebinding
|
||||
|
||||
@@ -11,11 +11,12 @@ default = []
|
||||
# Rasterize the laid-out tree to a pixmap (tiny-skia) with text (cosmic-text
|
||||
# shaping + line breaking, ab_glyph fallback) and images.
|
||||
# Off by default; the layout core (getBoundingClientRect etc.) works without it.
|
||||
paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:swash", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url", "dep:wuff"]
|
||||
paint = ["dep:obscura-ssrf", "dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:swash", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url", "dep:wuff"]
|
||||
|
||||
[dependencies]
|
||||
taffy = { workspace = true }
|
||||
obscura-dom = { workspace = true }
|
||||
obscura-ssrf = { workspace = true, optional = true }
|
||||
tiny-skia = { workspace = true, optional = true }
|
||||
ab_glyph = { workspace = true, optional = true }
|
||||
# Real inline text layout: HarfBuzz-class shaping (rustybuzz) + UAX#14 line
|
||||
|
||||
@@ -7676,6 +7676,12 @@ fn http_get_bytes(url: &str) -> Option<Vec<u8>> {
|
||||
std::thread::sleep(backoff);
|
||||
backoff *= 2;
|
||||
}
|
||||
// A resolution failure is not transient at this timescale, and it
|
||||
// is also how the SSRF guard below refuses a host: never sleep
|
||||
// through the backoff for a request that will not be made.
|
||||
Err(ureq::Error::Transport(err)) if err.kind() == ureq::ErrorKind::Dns => {
|
||||
return None;
|
||||
}
|
||||
Err(ureq::Error::Transport(_)) if attempt < 2 => {
|
||||
std::thread::sleep(backoff);
|
||||
backoff *= 2;
|
||||
@@ -7703,10 +7709,44 @@ fn image_agent() -> &'static ureq::Agent {
|
||||
// cnbc, techcrunch, arstechnica), so the images Chrome loads came
|
||||
// back blank; a real browser UA loads the same bytes Chrome does.
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
.resolver(SsrfGuardResolver)
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
/// The renderer's copy of the SSRF gate every page transport applies. This
|
||||
/// loader only runs for standalone callers (the `paint_file` tool, library
|
||||
/// users rendering a bare DOM, a JS runtime with no page transport); a
|
||||
/// page-owned runtime is cache-only and fetches through `obscura-net`, which
|
||||
/// has its own resolver. Checking at resolution time covers literal
|
||||
/// addresses, names that resolve to a private address (DNS rebinding), and
|
||||
/// every redirect hop, since ureq resolves each connection through here.
|
||||
/// `OBSCURA_ALLOW_PRIVATE_NETWORK` lifts the restriction, as it does for the
|
||||
/// transports; the renderer has no per-context flag to consult.
|
||||
struct SsrfGuardResolver;
|
||||
|
||||
impl ureq::Resolver for SsrfGuardResolver {
|
||||
fn resolve(&self, netloc: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
|
||||
use std::net::ToSocketAddrs;
|
||||
let addrs: Vec<std::net::SocketAddr> = netloc.to_socket_addrs()?.collect();
|
||||
if !obscura_ssrf::env_allows_private_network() {
|
||||
if let Some(bad) = addrs
|
||||
.iter()
|
||||
.find(|addr| obscura_ssrf::is_forbidden_ip(addr.ip()))
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"SSRF blocked: '{netloc}' resolves to forbidden address {}",
|
||||
bad.ip()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(addrs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a percent-escaped data: URI payload (`%23` -> `#`, etc). Bytes that
|
||||
/// are not part of a `%XX` escape pass through unchanged, which is exactly
|
||||
/// right for the inline-SVG case: only the characters that would otherwise be
|
||||
@@ -12088,6 +12128,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Loopback HTTP/1.0 server that counts connections and answers every
|
||||
/// request with `body`. The count is the evidence: a refused fetch must
|
||||
/// never even open the socket.
|
||||
fn loopback_http_server(
|
||||
body: &'static [u8],
|
||||
) -> (u16, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let hits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let observed = hits.clone();
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{Read, Write};
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { break };
|
||||
observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let mut request = [0u8; 1024];
|
||||
let _ = stream.read(&mut request);
|
||||
let _ = write!(
|
||||
stream,
|
||||
"HTTP/1.0 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(body);
|
||||
}
|
||||
});
|
||||
(port, hits)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_loader_refuses_private_network_hosts() {
|
||||
std::env::remove_var("OBSCURA_ALLOW_PRIVATE_NETWORK");
|
||||
let (port, hits) = loopback_http_server(b"png-bytes");
|
||||
let mut cache = RenderResourceCache::default();
|
||||
// A literal loopback address and a name that resolves to one: the
|
||||
// second must be caught at DNS resolution, the same way the page
|
||||
// transports close DNS rebinding.
|
||||
for url in [
|
||||
format!("http://127.0.0.1:{port}/a.png"),
|
||||
format!("http://localhost:{port}/b.png"),
|
||||
] {
|
||||
assert!(
|
||||
fetch_bytes(&url, None, &mut cache).is_none(),
|
||||
"{url} must be refused by the standalone loader"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
hits.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0,
|
||||
"a forbidden host must never be connected to"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_loader_honors_the_private_network_opt_in() {
|
||||
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
|
||||
let (port, hits) = loopback_http_server(b"png-bytes");
|
||||
let mut cache = RenderResourceCache::default();
|
||||
let url = format!("http://127.0.0.1:{port}/a.png");
|
||||
assert_eq!(
|
||||
fetch_bytes(&url, None, &mut cache).as_deref(),
|
||||
Some(b"png-bytes".as_slice())
|
||||
);
|
||||
assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_only_mode_does_not_load_or_negative_cache_unknown_urls() {
|
||||
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
9
crates/obscura-ssrf/Cargo.toml
Normal file
9
crates/obscura-ssrf/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "obscura-ssrf"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Shared SSRF deny-set and private-network opt-in for Obscura's network transports and renderer."
|
||||
|
||||
[dependencies]
|
||||
120
crates/obscura-ssrf/src/lib.rs
Normal file
120
crates/obscura-ssrf/src/lib.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
//! The SSRF policy every Obscura transport shares: which IP addresses an
|
||||
//! outbound request may never reach, and the process-wide env opt-in that
|
||||
//! lifts the restriction for local testing.
|
||||
//!
|
||||
//! This crate has no dependencies on purpose. `obscura-net` (reqwest and wreq
|
||||
//! transports) and `obscura-render` (the synchronous compatibility loader for
|
||||
//! images and fonts) both enforce the same deny-set from here, so the checks
|
||||
//! can never disagree.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
/// Process-wide opt-in via env var. Older flow that issue #4 introduced. The
|
||||
/// new `--allow-private-network` CLI flag (issue #33) sets a per-client field
|
||||
/// that is OR'd with this so existing scripts and Docker setups that pin the
|
||||
/// env var keep working unchanged.
|
||||
pub fn env_allows_private_network() -> bool {
|
||||
matches!(
|
||||
std::env::var("OBSCURA_ALLOW_PRIVATE_NETWORK")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref(),
|
||||
Some("1") | Some("true") | Some("yes") | Some("on")
|
||||
)
|
||||
}
|
||||
|
||||
/// True when `ip` must never be the target of an outbound request from the
|
||||
/// engine: loopback, RFC1918 private, link-local (incl. the 169.254.169.254
|
||||
/// cloud-metadata endpoint), broadcast, documentation, the unspecified address
|
||||
/// (0.0.0.0 / ::, which the OS routes to localhost), IPv6 unique-local
|
||||
/// (fc00::/7), and any IPv4-mapped/compatible IPv6 form of the above.
|
||||
/// Centralizes the SSRF deny-set so the literal-host check and the
|
||||
/// DNS-resolution check (`SsrfGuardResolver`) can never disagree.
|
||||
pub fn is_forbidden_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
let o = v4.octets();
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_broadcast()
|
||||
|| v4.is_documentation()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_multicast()
|
||||
|| o[0] == 0
|
||||
// std's is_private() covers only RFC1918, so add the IANA
|
||||
// special-purpose ranges that also host internal services and
|
||||
// are common SSRF targets:
|
||||
// 100.64.0.0/10 CGNAT / RFC6598 — cloud metadata (e.g.
|
||||
// Alibaba 100.100.100.200) lives here.
|
||||
// 198.18.0.0/15 benchmarking / RFC2544.
|
||||
// 192.88.99.0/24 6to4 relay anycast / RFC7526.
|
||||
|| (o[0] == 100 && (64..=127).contains(&o[1]))
|
||||
|| (o[0] == 198 && (o[1] == 18 || o[1] == 19))
|
||||
|| (o[0] == 192 && o[1] == 88 && o[2] == 99)
|
||||
// Most of 192.0.0.0/24 is special-purpose and not globally
|
||||
// reachable. Keep the two globally reachable PCP anycast
|
||||
// assignments usable rather than blocking the entire /24.
|
||||
|| (o[0] == 192
|
||||
&& o[1] == 0
|
||||
&& o[2] == 0
|
||||
&& o[3] != 9
|
||||
&& o[3] != 10)
|
||||
// 240.0.0.0/4 is reserved (255.255.255.255 was already
|
||||
// covered by is_broadcast()).
|
||||
|| o[0] >= 240
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
if v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_unique_local()
|
||||
|| v6.is_unicast_link_local()
|
||||
|| v6.is_multicast()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Unwrap IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d)
|
||||
// forms and re-check the embedded v4 so e.g. [::ffff:127.0.0.1] or
|
||||
// [::ffff:169.254.169.254] cannot slip past the v6 arm.
|
||||
if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
|
||||
return is_forbidden_ip(IpAddr::V4(v4));
|
||||
}
|
||||
|
||||
let s = v6.segments();
|
||||
// IPv4/IPv6 translation prefix (RFC 6052). Only /96 has a fixed
|
||||
// embedded-address position; the local-use /48 is therefore
|
||||
// blocked outright below.
|
||||
if s[0] == 0x64
|
||||
&& s[1] == 0xff9b
|
||||
&& s[2] == 0
|
||||
&& s[3] == 0
|
||||
&& s[4] == 0
|
||||
&& s[5] == 0
|
||||
{
|
||||
return is_forbidden_ip(IpAddr::V4(Ipv4Addr::new(
|
||||
(s[6] >> 8) as u8,
|
||||
s[6] as u8,
|
||||
(s[7] >> 8) as u8,
|
||||
s[7] as u8,
|
||||
)));
|
||||
}
|
||||
// 6to4 carries its IPv4 endpoint in bits 16..48.
|
||||
if s[0] == 0x2002 {
|
||||
return is_forbidden_ip(IpAddr::V4(Ipv4Addr::new(
|
||||
(s[1] >> 8) as u8,
|
||||
s[1] as u8,
|
||||
(s[2] >> 8) as u8,
|
||||
s[2] as u8,
|
||||
)));
|
||||
}
|
||||
|
||||
// Discard-only, local-use NAT64, and documentation prefixes.
|
||||
(s[0] == 0x100 && s[1] == 0 && s[2] == 0 && s[3] == 0)
|
||||
|| (s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1)
|
||||
|| (s[0] == 0x2001 && s[1] == 0x0db8)
|
||||
|| (s[0] == 0x3fff && s[1] & 0xf000 == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user