harden: connclass clone-cap at register, ws control-frame limits, uuid recycle-skip, json-escape extjwt/filehost claims, metadata value/key caps, cloak numeric-dotted leak, relaymsg remote-nick, rpc set_oper block validation, isupport 13-token split, multi-hop privmsg routing, connectdelay=0
This commit is contained in:
parent
e935ee7002
commit
35af95fd0f
11 changed files with 129 additions and 27 deletions
32
src/link.rs
32
src/link.rs
|
|
@ -79,18 +79,25 @@ impl Server {
|
||||||
/// Mint the next network-wide UID for a local user: our SID + 6 base-26 chars
|
/// Mint the next network-wide UID for a local user: our SID + 6 base-26 chars
|
||||||
/// (e.g. `0AAAAAAAB`).
|
/// (e.g. `0AAAAAAAB`).
|
||||||
pub fn next_uuid(&mut self) -> String {
|
pub fn next_uuid(&mut self) -> String {
|
||||||
let mut x = self.uuid_counter;
|
loop {
|
||||||
self.uuid_counter += 1;
|
let mut x = self.uuid_counter;
|
||||||
let mut suffix = [b'A'; 6];
|
self.uuid_counter += 1;
|
||||||
for c in suffix.iter_mut().rev() {
|
let mut suffix = [b'A'; 6];
|
||||||
*c = b'A' + (x % 26) as u8;
|
for c in suffix.iter_mut().rev() {
|
||||||
x /= 26;
|
*c = b'A' + (x % 26) as u8;
|
||||||
|
x /= 26;
|
||||||
|
}
|
||||||
|
let uuid = format!(
|
||||||
|
"{}{}",
|
||||||
|
self.sid,
|
||||||
|
std::str::from_utf8(&suffix).unwrap_or("AAAAAA")
|
||||||
|
);
|
||||||
|
// after 26^6 mints the counter wraps and could re-issue a still-live id;
|
||||||
|
// skip any that's in use so uuids stay unique
|
||||||
|
if !self.uuid_local.contains_key(&uuid) && !self.remote_users.contains_key(&uuid) {
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
format!(
|
|
||||||
"{}{}",
|
|
||||||
self.sid,
|
|
||||||
std::str::from_utf8(&suffix).unwrap_or("AAAAAA")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a new server-link connection. An **outbound** link introduces
|
/// Register a new server-link connection. An **outbound** link introduces
|
||||||
|
|
@ -966,6 +973,9 @@ impl Server {
|
||||||
.map(|u| u.nick.clone())
|
.map(|u| u.nick.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.send(dst, format!(":{prefix} {cmd} {nick} :{text}"));
|
self.send(dst, format!(":{prefix} {cmd} {nick} :{text}"));
|
||||||
|
} else {
|
||||||
|
// a remote target reached via another link (multi-hop) — forward onward
|
||||||
|
self.forward_to_target(&target, msg, via);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,13 @@ pub fn cloak_host(key: &str, host: &str) -> String {
|
||||||
format!("{alpha}.{beta}.{gamma}{IP_SUFFIX}")
|
format!("{alpha}.{beta}.{gamma}{IP_SUFFIX}")
|
||||||
} else {
|
} else {
|
||||||
let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect();
|
let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect();
|
||||||
if parts.len() >= 3 {
|
// reveal the registered domain suffix only for a real hostname (its TLD has a
|
||||||
|
// letter); a numeric dotted string that slipped past the IP parsers is fully
|
||||||
|
// cloaked so no octets leak in cleartext
|
||||||
|
let real_host = parts
|
||||||
|
.last()
|
||||||
|
.is_some_and(|t| t.bytes().any(|b| b.is_ascii_alphabetic()));
|
||||||
|
if parts.len() >= 3 && real_host {
|
||||||
let suffix = parts[parts.len() - 2..].join(".");
|
let suffix = parts[parts.len() - 2..].join(".");
|
||||||
format!("{}.{suffix}", label(key, host, 8))
|
format!("{}.{suffix}", label(key, host, 8))
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,18 @@ pub fn on_register(s: &mut Server, uid: Uid) -> AuthOutcome {
|
||||||
else {
|
else {
|
||||||
return AuthOutcome::Proceed;
|
return AuthOutcome::Proceed;
|
||||||
};
|
};
|
||||||
|
// enforce per-IP clone caps here too: a class matched only by a host mask isn't
|
||||||
|
// picked at connect, so `assign` never got to check them
|
||||||
|
if let Some(max) = class.localmax {
|
||||||
|
if local_clones(s, &ip, &class.name, uid) >= max {
|
||||||
|
return AuthOutcome::Reject("Too many connections from your address".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(max) = class.globalmax {
|
||||||
|
if global_clones(s, &ip, uid) >= max {
|
||||||
|
return AuthOutcome::Reject("Too many connections from your address".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
// cheap cert check before the (possibly slow) password verify
|
// cheap cert check before the (possibly slow) password verify
|
||||||
if class.ssl_trusted && !has_cert {
|
if class.ssl_trusted && !has_cert {
|
||||||
return AuthOutcome::Reject("Your connection class requires a client certificate".into());
|
return AuthOutcome::Reject("Your connection class requires a client certificate".into());
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,23 @@ fn json_arr(chars: impl IntoIterator<Item = char>) -> String {
|
||||||
format!("[{}]", items.join(","))
|
format!("[{}]", items.join(","))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Escape a string for embedding in a JSON string literal.
|
||||||
|
fn json_esc(s: &str) -> String {
|
||||||
|
let mut o = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => o.push_str("\\\""),
|
||||||
|
'\\' => o.push_str("\\\\"),
|
||||||
|
'\n' => o.push_str("\\n"),
|
||||||
|
'\r' => o.push_str("\\r"),
|
||||||
|
'\t' => o.push_str("\\t"),
|
||||||
|
c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
|
||||||
|
c => o.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
vec![Box::new(ExtJwt)]
|
vec![Box::new(ExtJwt)]
|
||||||
}
|
}
|
||||||
|
|
@ -106,15 +123,19 @@ impl Command for ExtJwt {
|
||||||
v
|
v
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
chan_claims = format!(",\"channel\":\"{target}\",\"cmodes\":{}", json_arr(cmodes));
|
chan_claims = format!(
|
||||||
|
",\"channel\":\"{}\",\"cmodes\":{}",
|
||||||
|
json_esc(&target),
|
||||||
|
json_arr(cmodes)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let claims = format!(
|
let claims = format!(
|
||||||
"{{\"exp\":{},\"iss\":\"{}\",\"sub\":\"{}\",\"account\":\"{}\",\"umodes\":{}{}}}",
|
"{{\"exp\":{},\"iss\":\"{}\",\"sub\":\"{}\",\"account\":\"{}\",\"umodes\":{}{}}}",
|
||||||
now() + duration,
|
now() + duration,
|
||||||
s.name,
|
json_esc(&s.name),
|
||||||
nick,
|
json_esc(&nick),
|
||||||
account,
|
json_esc(&account),
|
||||||
umodes,
|
umodes,
|
||||||
chan_claims
|
chan_claims
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,24 @@ fn file_type(filename: &str) -> &'static str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Escape a string for embedding in a JSON string literal (the tag carries JSON,
|
||||||
|
/// so a `"`/`\` in the url or filename would otherwise break it).
|
||||||
|
fn json_esc(v: &str) -> String {
|
||||||
|
let mut o = String::with_capacity(v.len());
|
||||||
|
for c in v.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => o.push_str("\\\""),
|
||||||
|
'\\' => o.push_str("\\\\"),
|
||||||
|
'\n' => o.push_str("\\n"),
|
||||||
|
'\r' => o.push_str("\\r"),
|
||||||
|
'\t' => o.push_str("\\t"),
|
||||||
|
c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
|
||||||
|
c => o.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
/// IRCv3 message-tag value escape (JSON is full of spaces, which would split the line).
|
/// IRCv3 message-tag value escape (JSON is full of spaces, which would split the line).
|
||||||
fn escape_tag(v: &str) -> String {
|
fn escape_tag(v: &str) -> String {
|
||||||
let mut out = String::with_capacity(v.len());
|
let mut out = String::with_capacity(v.len());
|
||||||
|
|
@ -114,8 +132,8 @@ impl Module for FileHost {
|
||||||
let filename = &url[files_prefix.len().min(url.len())..];
|
let filename = &url[files_prefix.len().min(url.len())..];
|
||||||
let meta = format!(
|
let meta = format!(
|
||||||
"{{\"url\":\"{}\",\"filename\":\"{}\",\"type\":\"{}\"}}",
|
"{{\"url\":\"{}\",\"filename\":\"{}\",\"type\":\"{}\"}}",
|
||||||
url,
|
json_esc(url),
|
||||||
filename,
|
json_esc(filename),
|
||||||
file_type(filename)
|
file_type(filename)
|
||||||
);
|
);
|
||||||
// fold the metadata onto this message's relayed tag block (message-tags
|
// fold the metadata onto this message's relayed tag block (message-tags
|
||||||
|
|
|
||||||
|
|
@ -144,10 +144,22 @@ impl Command for MetadataCmd {
|
||||||
return CmdResult::Fail;
|
return CmdResult::Fail;
|
||||||
};
|
};
|
||||||
let value = params.get(3).cloned(); // no value => delete the key
|
let value = params.get(3).cloned(); // no value => delete the key
|
||||||
|
let maxval = s.conf_num("metadata_maxvalue", 512usize);
|
||||||
|
let maxkeys = s.conf_num("metadata_maxkeys", 32usize);
|
||||||
{
|
{
|
||||||
let st = s.ext.get_or_insert_with::<MetaStore>(MetaStore::default);
|
let st = s.ext.get_or_insert_with::<MetaStore>(MetaStore::default);
|
||||||
match &value {
|
match &value {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
|
// bound value length and per-target key count so a client
|
||||||
|
// can't grow the store without limit
|
||||||
|
let cur = st.0.get(&key);
|
||||||
|
let overlong = v.len() > maxval;
|
||||||
|
let too_many = cur.map(|m| m.len() >= maxkeys && !m.contains_key(&mkey))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if overlong || too_many {
|
||||||
|
s.fail(uid, "METADATA", "KEY_INVALID", "value too long or too many keys");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
st.0.entry(key.clone())
|
st.0.entry(key.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
.insert(mkey.clone(), v.clone());
|
.insert(mkey.clone(), v.clone());
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ impl Command for RelayMsg {
|
||||||
);
|
);
|
||||||
return CmdResult::Fail;
|
return CmdResult::Fail;
|
||||||
}
|
}
|
||||||
if s.find_nick(nick).is_some() {
|
if s.find_nick(nick).is_some() || s.remote_nick.contains_key(&nick.to_ascii_lowercase()) {
|
||||||
return bad(s, "RELAYMSG spoofed nick is already in use");
|
return bad(s, "RELAYMSG spoofed nick is already in use");
|
||||||
}
|
}
|
||||||
if nick.chars().any(|c| FORBIDDEN.contains(c)) {
|
if nick.chars().any(|c| FORBIDDEN.contains(c)) {
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,9 @@ impl Module for RestrictCommands {
|
||||||
return ModResult::Passthru;
|
return ModResult::Passthru;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// connect-delay: allowed once connected long enough
|
// connect-delay: allowed once connected long enough (0 = no delay, so the
|
||||||
if r.connectdelay > 0 && now().saturating_sub(signon) >= r.connectdelay {
|
// command is allowed immediately)
|
||||||
|
if now().saturating_sub(signon) >= r.connectdelay {
|
||||||
return ModResult::Passthru;
|
return ModResult::Passthru;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,17 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result<String, RpcE
|
||||||
let uid = resolve(s, params).ok_or_else(|| RpcError::not_found("no such user"))?;
|
let uid = resolve(s, params).ok_or_else(|| RpcError::not_found("no such user"))?;
|
||||||
let oper = json::get_str(params, "oper").or_else(|| json::get_str(params, "type"));
|
let oper = json::get_str(params, "oper").or_else(|| json::get_str(params, "type"));
|
||||||
match oper {
|
match oper {
|
||||||
Some(name) if !name.is_empty() => s.oper_up(uid),
|
Some(name) if !name.is_empty() => {
|
||||||
|
// apply the named oper block's level; reject an unknown name rather
|
||||||
|
// than silently opering with defaults
|
||||||
|
match s.opers.iter().find(|o| o.0 == name).map(|o| o.2) {
|
||||||
|
Some(level) => {
|
||||||
|
s.oper_up(uid);
|
||||||
|
crate::modules::operlevels::set(s, uid, level);
|
||||||
|
}
|
||||||
|
None => return Err(RpcError::invalid_params("no such oper block")),
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => svs_set_user_modes(s, uid, "-o"), // de-oper
|
_ => svs_set_user_modes(s, uid, "-o"), // de-oper
|
||||||
}
|
}
|
||||||
Ok(obj(&[("result", "true".into())]))
|
Ok(obj(&[("result", "true".into())]))
|
||||||
|
|
|
||||||
|
|
@ -716,17 +716,22 @@ impl Server {
|
||||||
// config-overridable (see modules::customprefix)
|
// config-overridable (see modules::customprefix)
|
||||||
let include_oper = self.conf_bool("operprefix", false) || self.conf_bool("ojoin", false);
|
let include_oper = self.conf_bool("operprefix", false) || self.conf_bool("ojoin", false);
|
||||||
let prefix = crate::modules::customprefix::isupport(include_oper);
|
let prefix = crate::modules::customprefix::isupport(include_oper);
|
||||||
let mut lines = vec![format!(
|
let mut tokens: Vec<String> = format!(
|
||||||
"CHANTYPES=# PREFIX={prefix} CHANMODES=beIgXw,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnprstuz EXTBAN=,Gbcgjmnrsy WATCH={maxwatch} MONITOR={maxmon} SILENCE={maxsil} CALLERID=g WHOX CHATHISTORY={chathist} MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN={maxnick} CHANNELLEN={maxchan} NETWORK={}",
|
"CHANTYPES=# PREFIX={prefix} CHANMODES=beIgXw,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnprstuz EXTBAN=,Gbcgjmnrsy WATCH={maxwatch} MONITOR={maxmon} SILENCE={maxsil} CALLERID=g WHOX CHATHISTORY={chathist} MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN={maxnick} CHANNELLEN={maxchan} NETWORK={}",
|
||||||
self.network
|
self.network
|
||||||
)];
|
)
|
||||||
|
.split(' ')
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
if let Some(tok) = crate::modules::network_icon::isupport(self) {
|
if let Some(tok) = crate::modules::network_icon::isupport(self) {
|
||||||
lines.push(tok);
|
tokens.push(tok);
|
||||||
}
|
}
|
||||||
if let Some(tok) = crate::modules::filehost::isupport(self) {
|
if let Some(tok) = crate::modules::filehost::isupport(self) {
|
||||||
lines.push(tok);
|
tokens.push(tok);
|
||||||
}
|
}
|
||||||
lines
|
// at most 13 tokens per 005 line (the RFC-suggested cap) so strict clients
|
||||||
|
// don't truncate trailing tokens
|
||||||
|
tokens.chunks(13).map(|c| c.join(" ")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit the ISUPPORT numerics to `uid`. When `batched` (the client negotiated
|
/// Emit the ISUPPORT numerics to `uid`. When `batched` (the client negotiated
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,13 @@ fn io_loop<S: WsStream>(
|
||||||
Ok(Some((frame, consumed))) => {
|
Ok(Some((frame, consumed))) => {
|
||||||
acc.drain(..consumed);
|
acc.drain(..consumed);
|
||||||
match frame.opcode {
|
match frame.opcode {
|
||||||
|
// RFC 6455 §5.5: control frames must be ≤125 bytes and
|
||||||
|
// never fragmented — drop the connection otherwise
|
||||||
|
OP_CLOSE | OP_PING | OP_PONG
|
||||||
|
if !frame.fin || frame.payload.len() > 125 =>
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
OP_CLOSE => return,
|
OP_CLOSE => return,
|
||||||
OP_PING => {
|
OP_PING => {
|
||||||
let _ = stream.write_all(&encode(OP_PONG, &frame.payload));
|
let _ = stream.write_all(&encode(OP_PONG, &frame.payload));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue