enforce channel prefixes

3c243c4 · gmorell · 2026-06-11 04:11

12 files +119 -15

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