1 Commits
Author SHA1 Message Date
clawbot 98a3bf16cd Refuse a PRIVMSG or NOTICE whose body or meta has the wrong type (closes #245)
check / check (push) Canceled after 0s
Over the HTTP API, a PRIVMSG or NOTICE body was checked only by
decoding it into a list of strings. JSON null decodes into an empty
string there, and meta was not checked at all, so a null line, or a
meta that is not an object, reached every recipient.

handlePrivmsg now answers 400 invalid request body, before anything
else, when body is present and is not an array of strings, or meta is
present and is not an object. The check returns the lines it accepted,
and the empty-body check counts those, so body is decoded once. A
missing or empty body still gets ERR_NOTEXTTOSEND. README.md says so.

Model: opus-5-5
2026-11-02 06:07:04 +00:00
2 changed files with 36 additions and 26 deletions

No files matched your search

+22 -19
View File
@@ -994,7 +994,7 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
command, target, body, meta, bodyLines, command, target, body, meta,
) )
case irc.CmdJoin: case irc.CmdJoin:
hdlr.handleJoin( hdlr.handleJoin(
@@ -1139,9 +1139,9 @@ func (hdlr *Handlers) handlePrivmsg(
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
bodyLines func() []string,
) { ) {
if !validBodyAndMeta(body, meta) { lines, ok := checkBodyAndMeta(body, meta)
if !ok {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"invalid request body", "invalid request body",
@@ -1165,7 +1165,6 @@ func (hdlr *Handlers) handlePrivmsg(
return return
} }
lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1199,25 +1198,29 @@ func (hdlr *Handlers) handlePrivmsg(
) )
} }
// validBodyAndMeta reports whether body, when present, is an // checkBodyAndMeta returns the lines in body. It returns false
// array of strings and meta, when present, is an object. // when body is present and is not an array of strings, or meta
// Decoding into []string would turn a null line into "", so // is present and is not an object. Each line is decoded as any
// each line is decoded as any and checked. A null body or // because decoding into []string turns a null line into "". A
// meta decodes without an error into nil. // null body or meta decodes without an error into nil.
func validBodyAndMeta(body, meta json.RawMessage) bool { func checkBodyAndMeta(body, meta json.RawMessage) ([]string, bool) {
var lines []string
if body != nil { if body != nil {
var lines []any var values []any
err := json.Unmarshal(body, &lines) err := json.Unmarshal(body, &values)
if err != nil || lines == nil { if err != nil || values == nil {
return false return nil, false
} }
for _, line := range lines { for _, value := range values {
_, isString := line.(string) line, isString := value.(string)
if !isString { if !isString {
return false return nil, false
} }
lines = append(lines, line)
} }
} }
@@ -1226,11 +1229,11 @@ func validBodyAndMeta(body, meta json.RawMessage) bool {
err := json.Unmarshal(meta, &fields) err := json.Unmarshal(meta, &fields)
if err != nil || fields == nil { if err != nil || fields == nil {
return false return nil, false
} }
} }
return true return lines, true
} }
// respondIRCError enqueues a numeric error reply, notifies // respondIRCError enqueues a numeric error reply, notifies
+14 -7
View File
@@ -1001,6 +1001,8 @@ func TestChannelMessage(t *testing.T) {
} }
} }
// TestMessageMissingBody checks that a PRIVMSG with no body,
// or an empty one, gets ERR_NOTEXTTOSEND.
func TestMessageMissingBody(t *testing.T) { func TestMessageMissingBody(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1015,21 +1017,26 @@ func TestMessageMissingBody(t *testing.T) {
_, lastID := tserver.pollMessages(token, 0) _, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ for _, request := range []map[string]any{
commandKey: privmsgCmd, toKey: channel, {commandKey: privmsgCmd, toKey: channel},
}) {commandKey: privmsgCmd, toKey: channel, bodyKey: []string{}},
} {
status, _ := tserver.sendCommand(token, request)
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status) t.Fatalf("%v: expected 200, got %d", request, status)
} }
msgs, _ := tserver.pollMessages(token, lastID) var msgs []map[string]any
msgs, lastID = tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "412") { if !findNumeric(msgs, "412") {
t.Fatalf( t.Fatalf(
"expected ERR_NOTEXTTOSEND (412), got %v", "%v: expected ERR_NOTEXTTOSEND (412), got %v",
msgs, request, msgs,
) )
} }
}
} }
func TestMessageMissingTo(t *testing.T) { func TestMessageMissingTo(t *testing.T) {