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
|
|
@ -99,7 +99,13 @@ pub fn cloak_host(key: &str, host: &str) -> String {
|
|||
format!("{alpha}.{beta}.{gamma}{IP_SUFFIX}")
|
||||
} else {
|
||||
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(".");
|
||||
format!("{}.{suffix}", label(key, host, 8))
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -386,6 +386,18 @@ pub fn on_register(s: &mut Server, uid: Uid) -> AuthOutcome {
|
|||
else {
|
||||
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
|
||||
if class.ssl_trusted && !has_cert {
|
||||
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(","))
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
vec![Box::new(ExtJwt)]
|
||||
}
|
||||
|
|
@ -106,15 +123,19 @@ impl Command for ExtJwt {
|
|||
v
|
||||
})
|
||||
.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!(
|
||||
"{{\"exp\":{},\"iss\":\"{}\",\"sub\":\"{}\",\"account\":\"{}\",\"umodes\":{}{}}}",
|
||||
now() + duration,
|
||||
s.name,
|
||||
nick,
|
||||
account,
|
||||
json_esc(&s.name),
|
||||
json_esc(&nick),
|
||||
json_esc(&account),
|
||||
umodes,
|
||||
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).
|
||||
fn escape_tag(v: &str) -> String {
|
||||
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 meta = format!(
|
||||
"{{\"url\":\"{}\",\"filename\":\"{}\",\"type\":\"{}\"}}",
|
||||
url,
|
||||
filename,
|
||||
json_esc(url),
|
||||
json_esc(filename),
|
||||
file_type(filename)
|
||||
);
|
||||
// fold the metadata onto this message's relayed tag block (message-tags
|
||||
|
|
|
|||
|
|
@ -144,10 +144,22 @@ impl Command for MetadataCmd {
|
|||
return CmdResult::Fail;
|
||||
};
|
||||
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);
|
||||
match &value {
|
||||
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())
|
||||
.or_default()
|
||||
.insert(mkey.clone(), v.clone());
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ impl Command for RelayMsg {
|
|||
);
|
||||
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");
|
||||
}
|
||||
if nick.chars().any(|c| FORBIDDEN.contains(c)) {
|
||||
|
|
|
|||
|
|
@ -147,8 +147,9 @@ impl Module for RestrictCommands {
|
|||
return ModResult::Passthru;
|
||||
}
|
||||
}
|
||||
// connect-delay: allowed once connected long enough
|
||||
if r.connectdelay > 0 && now().saturating_sub(signon) >= r.connectdelay {
|
||||
// connect-delay: allowed once connected long enough (0 = no delay, so the
|
||||
// command is allowed immediately)
|
||||
if now().saturating_sub(signon) >= r.connectdelay {
|
||||
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 oper = json::get_str(params, "oper").or_else(|| json::get_str(params, "type"));
|
||||
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
|
||||
}
|
||||
Ok(obj(&[("result", "true".into())]))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue