neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
enforce channel prefixes
3c243c4 · gmorell · 2026-06-11 04:11
Files changed
modified
README.md
+3
−0
@@ -30,6 +30,9 @@ services, IRC-over-TLS, and ACME cert provisioning baked in.
| 30 | 30 | KICK / INVITE / AWAY / USERHOST / ISON`. |
| 31 | 31 | * Channel access prefixes `~ & @ % +` (founder / admin / op / |
| 32 | 32 | halfop / voice) with proper rank-based permission checks. |
| 33 | + * Channel names must carry a channel-type prefix (`#` or `&`) — no | |
| 34 | + prefix-less names — be 2–50 chars, and contain no spaces, commas, | |
| 35 | + or `:`. A `JOIN` of anything else is refused with `403`. | |
| 33 | 36 | * Channel modes: `+b/+e/+I` (ban / except / invite-exception masks |
| 34 | 37 | with glob matching), `+i` (invite-only), `+m` (moderated), |
| 35 | 38 | `+n` (no external), `+k <key>`, `+l <limit>`. Mode views via |
modified
lib/oh_my_ex_oh_ircd/irc/channel.ex
+14
−0
@@ -62,6 +62,20 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 62 | 62 | @doc "Numeric rank for an access level — higher means more privileged." |
| 63 | 63 | def rank(level), do: Map.get(@access_rank, level, 0) |
| 64 | 64 | |
| 65 | + @doc """ | |
| 66 | + Whether `name` is an acceptable channel name. We require one of the | |
| 67 | + RFC channel-type prefixes this server routes (`#` or `&`) — i.e. no | |
| 68 | + prefix-less names — a length of 2–50 including the prefix, and no | |
| 69 | + spaces, commas, control chars, or `:` in the body. This is the single | |
| 70 | + source of truth gating channel creation on JOIN (IRC + web). | |
| 71 | + """ | |
| 72 | + @spec valid_name?(term()) :: boolean() | |
| 73 | + def valid_name?(<<prefix, rest::binary>> = name) | |
| 74 | + when prefix in [?#, ?&] and byte_size(name) <= 50 and rest != "", | |
| 75 | + do: not String.contains?(rest, [" ", ",", ":", "\0", "\a", "\r", "\n", "\t"]) | |
| 76 | + | |
| 77 | + def valid_name?(_), do: false | |
| 78 | + | |
| 65 | 79 | ## API |
| 66 | 80 | |
| 67 | 81 | def ensure_started(name) do |
modified
lib/oh_my_ex_oh_ircd/irc/connection.ex
+4
−0
@@ -1343,6 +1343,10 @@ defmodule OhMyExOhIrcd.IRC.Connection do
| 1343 | 1343 | send_numeric(socket, state, "451", ["*", "You have not registered"]) |
| 1344 | 1344 | state |
| 1345 | 1345 | |
| 1346 | + not Channel.valid_name?(channel) -> | |
| 1347 | + send_numeric(socket, state, "403", [state.nick || "*", channel, "Invalid channel name"]) | |
| 1348 | + state | |
| 1349 | + | |
| 1346 | 1350 | true -> |
| 1347 | 1351 | {:ok, _pid} = Channel.ensure_started(channel) |
| 1348 | 1352 | user_id = state.account && state.account.id |
modified
lib/oh_my_ex_oh_ircd/irc/web_session.ex
+22
−15
@@ -77,26 +77,33 @@ defmodule OhMyExOhIrcd.IRC.WebSession do
| 77 | 77 | user_id = state.user && state.user.id |
| 78 | 78 | prefix = "#{state.nick}!#{state.nick}@web" |
| 79 | 79 | |
| 80 | − case Channel.ensure_started(channel) do | |
| 81 | − {:ok, _} -> | |
| 82 | − Phoenix.PubSub.subscribe(@pubsub, MessageLog.channel_topic(channel)) | |
| 80 | + cond do | |
| 81 | + not Channel.valid_name?(channel) -> | |
| 82 | + notify(state, {:join_refused, channel, :bad_channel_name}) | |
| 83 | + {:reply, {:error, :bad_channel_name}, state} | |
| 84 | + | |
| 85 | + true -> | |
| 86 | + case Channel.ensure_started(channel) do | |
| 87 | + {:ok, _} -> | |
| 88 | + Phoenix.PubSub.subscribe(@pubsub, MessageLog.channel_topic(channel)) | |
| 89 | + | |
| 90 | + case Channel.join(channel, state.nick, self(), user_id, prefix, nil) do | |
| 91 | + :ok -> | |
| 92 | + backlog = MessageLog.entries(channel) | |
| 93 | + notify(state, {:joined, channel, backlog, Channel.members(channel)}) | |
| 83 | 94 | |
| 84 | − case Channel.join(channel, state.nick, self(), user_id, prefix, nil) do | |
| 85 | − :ok -> | |
| 86 | − backlog = MessageLog.entries(channel) | |
| 87 | − notify(state, {:joined, channel, backlog, Channel.members(channel)}) | |
| 95 | + {:reply, :ok, | |
| 96 | + %{state | channels: MapSet.put(state.channels, String.downcase(channel))}} | |
| 88 | 97 | |
| 89 | − {:reply, :ok, | |
| 90 | − %{state | channels: MapSet.put(state.channels, String.downcase(channel))}} | |
| 98 | + {:error, reason} = err -> | |
| 99 | + Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel)) | |
| 100 | + notify(state, {:join_refused, channel, reason}) | |
| 101 | + {:reply, err, state} | |
| 102 | + end | |
| 91 | 103 | |
| 92 | − {:error, reason} = err -> | |
| 93 | − Phoenix.PubSub.unsubscribe(@pubsub, MessageLog.channel_topic(channel)) | |
| 94 | − notify(state, {:join_refused, channel, reason}) | |
| 104 | + err -> | |
| 95 | 105 | {:reply, err, state} |
| 96 | 106 | end |
| 97 | − | |
| 98 | − err -> | |
| 99 | − {:reply, err, state} | |
| 100 | 107 | end |
| 101 | 108 | end |
| 102 | 109 |
added
test/oh_my_ex_oh_ircd/irc/channel_valid_name_test.exs
+36
−0
@@ -0,0 +1,36 @@
| 1 | +defmodule OhMyExOhIrcd.IRC.ChannelValidNameTest do | |
| 2 | + use ExUnit.Case, async: true | |
| 3 | + | |
| 4 | + alias OhMyExOhIrcd.IRC.Channel | |
| 5 | + | |
| 6 | + test "accepts well-formed # and & channels" do | |
| 7 | + assert Channel.valid_name?("#foo") | |
| 8 | + assert Channel.valid_name?("#foo-bar_baz.123") | |
| 9 | + assert Channel.valid_name?("&local") | |
| 10 | + assert Channel.valid_name?("#" <> String.duplicate("a", 49)) | |
| 11 | + end | |
| 12 | + | |
| 13 | + test "rejects prefix-less names and unsupported prefixes" do | |
| 14 | + refute Channel.valid_name?("foo") | |
| 15 | + refute Channel.valid_name?("+modeless") | |
| 16 | + refute Channel.valid_name?("!12345") | |
| 17 | + end | |
| 18 | + | |
| 19 | + test "rejects a bare prefix and over-long names" do | |
| 20 | + refute Channel.valid_name?("#") | |
| 21 | + refute Channel.valid_name?("&") | |
| 22 | + refute Channel.valid_name?("#" <> String.duplicate("a", 50)) | |
| 23 | + end | |
| 24 | + | |
| 25 | + test "rejects names with spaces, commas, colons, or control chars" do | |
| 26 | + refute Channel.valid_name?("#has space") | |
| 27 | + refute Channel.valid_name?("#has,comma") | |
| 28 | + refute Channel.valid_name?("#has:colon") | |
| 29 | + refute Channel.valid_name?("#has\ttab") | |
| 30 | + end | |
| 31 | + | |
| 32 | + test "rejects non-binaries" do | |
| 33 | + refute Channel.valid_name?(nil) | |
| 34 | + refute Channel.valid_name?(:"#foo") | |
| 35 | + end | |
| 36 | +end |
modified
test/oh_my_ex_oh_ircd/irc/connection_integration_test.exs
+40
−0
@@ -156,6 +156,46 @@ defmodule OhMyExOhIrcd.IRC.ConnectionIntegrationTest do
| 156 | 156 | end |
| 157 | 157 | end |
| 158 | 158 | |
| 159 | + describe "JOIN name validation" do | |
| 160 | + test "JOIN of a name without a # prefix is rejected with 403, no JOIN echo" do | |
| 161 | + port = start_listener() | |
| 162 | + nick = unique_nick() | |
| 163 | + sock = register(port, nick) | |
| 164 | + | |
| 165 | + send_line(sock, "JOIN notachannel") | |
| 166 | + lines = drain(sock, 300) | |
| 167 | + | |
| 168 | + assert Enum.any?(lines, &String.contains?(&1, " 403 ")) | |
| 169 | + refute Enum.any?(lines, &(&1 =~ ~r/JOIN notachannel/)) | |
| 170 | + end | |
| 171 | + | |
| 172 | + test "JOIN of an & (local) RFC channel is allowed" do | |
| 173 | + port = start_listener() | |
| 174 | + nick = unique_nick() | |
| 175 | + chan = "&local#{System.unique_integer([:positive])}" | |
| 176 | + sock = register(port, nick) | |
| 177 | + | |
| 178 | + send_line(sock, "JOIN #{chan}") | |
| 179 | + lines = drain(sock, 300) | |
| 180 | + | |
| 181 | + assert Enum.any?(lines, &(&1 =~ ~r/JOIN #{Regex.escape(chan)}/)) | |
| 182 | + refute Enum.any?(lines, &String.contains?(&1, " 403 ")) | |
| 183 | + end | |
| 184 | + | |
| 185 | + test "a normal #channel JOIN still works" do | |
| 186 | + port = start_listener() | |
| 187 | + nick = unique_nick() | |
| 188 | + chan = unique_channel() | |
| 189 | + sock = register(port, nick) | |
| 190 | + | |
| 191 | + send_line(sock, "JOIN #{chan}") | |
| 192 | + lines = drain(sock, 300) | |
| 193 | + | |
| 194 | + assert Enum.any?(lines, &(&1 =~ ~r/JOIN #{Regex.escape(chan)}/)) | |
| 195 | + refute Enum.any?(lines, &String.contains?(&1, " 403 ")) | |
| 196 | + end | |
| 197 | + end | |
| 198 | + | |
| 159 | 199 | describe "TOPIC" do |
| 160 | 200 | test "set + read back with 332 and 333 (setter + timestamp)" do |
| 161 | 201 | port = start_listener() |
Parents: 007f0a2