Skip to content

Commit 7e9d781

Browse files
authored
policyserver: validate provided PDU (#37)
1 parent 929a721 commit 7e9d781

File tree

9 files changed

+134
-21
lines changed

9 files changed

+134
-21
lines changed

.github/workflows/go.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,9 @@ jobs:
3232
go install honnef.co/go/tools/cmd/staticcheck@latest
3333
export PATH="$HOME/go/bin:$PATH"
3434
35+
- name: Enable jsonv2
36+
run: export GOEXPERIMENT=jsonv2
37+
if: matrix.go-version == '1.25'
38+
3539
- name: Run pre-commit
3640
uses: pre-commit/action@v3.0.1

.gitlab-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ include:
44

55
variables:
66
BINARY_NAME: meowlnir
7+
GOEXPERIMENT: jsonv2

cmd/meowlnir/policyserver.go

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,92 @@
1+
//go:build goexperiment.jsonv2
2+
13
package main
24

35
import (
4-
"encoding/json"
6+
"encoding/json/v2"
7+
"io"
58
"net/http"
9+
"regexp"
610

711
"github.com/rs/zerolog/hlog"
812
"go.mau.fi/util/exhttp"
913
"maunium.net/go/mautrix"
10-
"maunium.net/go/mautrix/event"
1114
"maunium.net/go/mautrix/federation"
15+
"maunium.net/go/mautrix/federation/pdu"
1216
"maunium.net/go/mautrix/id"
1317

1418
"go.mau.fi/meowlnir/policyeval"
1519
)
1620

21+
var eventIDRegex = regexp.MustCompile(`^\$[A-Za-z0-9_-]{43}$`)
22+
23+
const pduSizeLimit = 64 * 1024 // 64 KiB
24+
const bodySizeLimit = pduSizeLimit * 1.5 // Leave a bit of room for whitespace
25+
1726
func (m *Meowlnir) PostMSC4284EventCheck(w http.ResponseWriter, r *http.Request) {
1827
eventID := id.EventID(r.PathValue("event_id"))
19-
var req event.Event
20-
err := json.NewDecoder(r.Body).Decode(&req)
28+
// Only room v4+ is supported
29+
if !eventIDRegex.MatchString(string(eventID)) {
30+
mautrix.MInvalidParam.WithMessage("Invalid event ID format").Write(w)
31+
return
32+
} else if r.ContentLength > bodySizeLimit {
33+
mautrix.MTooLarge.WithMessage("PDUs must be less than 64 KiB").Write(w)
34+
return
35+
}
36+
if r.ContentLength >= 0 && r.ContentLength <= 2 {
37+
resp := m.PolicyServer.HandleCachedCheck(eventID)
38+
if resp == nil {
39+
mautrix.MNotFound.WithMessage("Event not found in cache, please provide it in the request").Write(w)
40+
} else if resp.Recommendation == "spam" && m.Config.Meowlnir.DryRun {
41+
exhttp.WriteJSONResponse(w, http.StatusOK, &policyeval.PolicyServerResponse{Recommendation: "ok"})
42+
} else {
43+
exhttp.WriteJSONResponse(w, http.StatusOK, resp)
44+
}
45+
return
46+
}
47+
var parsedPDU *pdu.PDU
48+
err := json.UnmarshalRead(io.LimitReader(r.Body, bodySizeLimit), &parsedPDU)
2149
if err != nil {
22-
hlog.FromRequest(r).Err(err).Msg("Failed to parse request body")
2350
mautrix.MNotJSON.WithMessage("Request body is not valid JSON").Write(w)
2451
return
2552
}
26-
53+
var ok bool
2754
m.MapLock.RLock()
28-
eval, ok := m.EvaluatorByProtectedRoom[req.RoomID]
55+
eval, ok := m.EvaluatorByProtectedRoom[parsedPDU.RoomID]
2956
m.MapLock.RUnlock()
3057
if !ok {
3158
mautrix.MNotFound.WithMessage("Policy server error: room is not protected").Write(w)
3259
return
3360
}
61+
createEvt := eval.GetProtectedRoomCreateEvent(parsedPDU.RoomID)
62+
if createEvt == nil {
63+
mautrix.MNotFound.WithMessage("Policy server error: room create event not found").Write(w)
64+
return
65+
}
66+
expectedEventID, err := parsedPDU.CalculateEventID(createEvt.RoomVersion)
67+
if err != nil {
68+
hlog.FromRequest(r).Err(err).Msg("Failed to calculate event ID from PDU")
69+
mautrix.MUnknown.WithMessage("Failed to calculate event ID from PDU").Write(w)
70+
return
71+
} else if expectedEventID != eventID {
72+
mautrix.MInvalidParam.WithMessage("Event ID does not match hash of request body").Write(w)
73+
return
74+
}
75+
clientEvent, err := parsedPDU.ToClientEvent(createEvt.RoomVersion)
76+
if err != nil {
77+
hlog.FromRequest(r).Err(err).Msg("Failed to convert PDU to client event")
78+
mautrix.MUnknown.WithMessage("Failed to convert PDU to client event").Write(w)
79+
return
80+
}
81+
3482
resp, err := m.PolicyServer.HandleCheck(
3583
r.Context(),
3684
eventID,
37-
&req,
85+
clientEvent,
3886
eval,
3987
m.Config.PolicyServer.AlwaysRedact && !m.Config.Meowlnir.DryRun,
40-
federation.OriginServerNameFromRequest(r))
88+
federation.OriginServerNameFromRequest(r),
89+
)
4190
if err != nil {
4291
hlog.FromRequest(r).Err(err).Msg("Failed to handle check")
4392
mautrix.MUnknown.WithMessage("Policy server error: internal server error").Write(w)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//go:build !goexperiment.jsonv2
2+
3+
package main
4+
5+
import (
6+
"net/http"
7+
8+
"maunium.net/go/mautrix"
9+
)
10+
11+
func (m *Meowlnir) PostMSC4284EventCheck(w http.ResponseWriter, r *http.Request) {
12+
mautrix.MUnrecognized.WithMessage("This Meowlnir wasn't compiled with jsonv2 for policy server support").Write(w)
13+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ require (
1313
golang.org/x/sync v0.16.0
1414
gopkg.in/yaml.v3 v3.0.1
1515
maunium.net/go/mauflag v1.0.0
16-
maunium.net/go/mautrix v0.25.0
16+
maunium.net/go/mautrix v0.25.1-0.20250817104534-cc80be150059
1717
)
1818

1919
require (

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
9898
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
9999
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
100100
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
101-
maunium.net/go/mautrix v0.25.0 h1:dhYoXIXSxI9A+kEPwBceuRP0wcpho15dVLucUF8k2eE=
102-
maunium.net/go/mautrix v0.25.0/go.mod h1:pDd6Ppg+1PbWrw/rg4ZQQfVYZICRGzH+DcliZ/BODvU=
101+
maunium.net/go/mautrix v0.25.1-0.20250817104534-cc80be150059 h1:ldFZJ2MKcRLje69uIs3RmxKZSW2TQVRjj1aL4Di0saY=
102+
maunium.net/go/mautrix v0.25.1-0.20250817104534-cc80be150059/go.mod h1:pDd6Ppg+1PbWrw/rg4ZQQfVYZICRGzH+DcliZ/BODvU=

policyeval/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
type protectedRoomMeta struct {
2727
Name string
2828
ACL *event.ServerACLEventContent
29+
Create *event.CreateEventContent
2930
ApplyACL bool
3031
}
3132

policyeval/policyserver.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ func (ps *PolicyServer) getCache(evtID id.EventID, pdu *event.Event) *psCacheEnt
6161
defer ps.cacheLock.Unlock()
6262
entry, ok := ps.eventCache[evtID]
6363
if !ok {
64+
if pdu == nil {
65+
return nil
66+
}
6467
ps.unlockedClearCacheIfNeeded()
6568
entry = &psCacheEntry{LastAccessed: time.Now(), PDU: pdu}
6669
ps.eventCache[evtID] = entry
@@ -110,6 +113,20 @@ func (ps *PolicyServer) getRecommendation(pdu *event.Event, evaluator *PolicyEva
110113
return PSRecommendationOk, nil
111114
}
112115

116+
func (ps *PolicyServer) HandleCachedCheck(evtID id.EventID) *PolicyServerResponse {
117+
r := ps.getCache(evtID, nil)
118+
if r == nil {
119+
return nil
120+
}
121+
r.Lock.Lock()
122+
defer r.Lock.Unlock()
123+
if r.Recommendation == "" {
124+
return nil
125+
}
126+
r.LastAccessed = time.Now()
127+
return &PolicyServerResponse{Recommendation: r.Recommendation}
128+
}
129+
113130
func (ps *PolicyServer) HandleCheck(
114131
ctx context.Context,
115132
evtID id.EventID,
@@ -121,13 +138,12 @@ func (ps *PolicyServer) HandleCheck(
121138
log := zerolog.Ctx(ctx).With().
122139
Stringer("room_id", pdu.RoomID).
123140
Stringer("event_id", evtID).
124-
Str("caller", caller).
125141
Logger()
126142
r := ps.getCache(evtID, pdu)
127143
finalRec := r.Recommendation
128144
r.Lock.Lock()
129145
defer func() {
130-
defer r.Lock.Unlock()
146+
r.Lock.Unlock()
131147
if caller != pdu.Sender.Homeserver() && finalRec == PSRecommendationSpam && redact {
132148
go func() {
133149
if _, err = evaluator.Bot.RedactEvent(context.WithoutCancel(ctx), pdu.RoomID, evtID); err != nil {

policyeval/protectedrooms.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,27 @@ func (pe *PolicyEvaluator) IsProtectedRoom(roomID id.RoomID) bool {
3232
return protected
3333
}
3434

35+
func (pe *PolicyEvaluator) GetProtectedRoomCreateEvent(roomID id.RoomID) *event.CreateEventContent {
36+
pe.protectedRoomsLock.RLock()
37+
meta, ok := pe.protectedRooms[roomID]
38+
pe.protectedRoomsLock.RUnlock()
39+
if !ok {
40+
return nil
41+
}
42+
return meta.Create
43+
}
44+
3545
func (pe *PolicyEvaluator) HandleProtectedRoomMeta(ctx context.Context, evt *event.Event) {
3646
switch evt.Type {
3747
case event.StatePowerLevels:
3848
pe.handleProtectedRoomPowerLevels(ctx, evt)
49+
case event.StateCreate:
50+
pe.protectedRoomsLock.Lock()
51+
meta, ok := pe.protectedRooms[evt.RoomID]
52+
if ok {
53+
meta.Create = evt.Content.AsCreate()
54+
}
55+
pe.protectedRoomsLock.Unlock()
3956
case event.StateRoomName:
4057
pe.protectedRoomsLock.Lock()
4158
meta, ok := pe.protectedRooms[evt.RoomID]
@@ -142,15 +159,16 @@ func (pe *PolicyEvaluator) tryProtectingRoom(ctx context.Context, joinedRooms *m
142159
return nil, fmt.Sprintf("* Bot is not in protected room [%s](%s) and joining failed: %v", roomID, roomID.URI().MatrixToURL(), err)
143160
}
144161
}
162+
createEvt, err := pe.Bot.FullStateEvent(ctx, roomID, event.StateCreate, "")
163+
if err != nil {
164+
return nil, fmt.Sprintf("* Failed to get create event for [%s](%s): %v", roomID, roomID.URI().MatrixToURL(), err)
165+
}
145166
var powerLevels event.PowerLevelsEventContent
146167
err = pe.Bot.StateEvent(ctx, roomID, event.StatePowerLevels, "", &powerLevels)
147168
if err != nil {
148169
return nil, fmt.Sprintf("* Failed to get power levels for [%s](%s): %v", roomID, roomID.URI().MatrixToURL(), err)
149170
}
150-
powerLevels.CreateEvent, err = pe.Bot.FullStateEvent(ctx, roomID, event.StateCreate, "")
151-
if err != nil {
152-
return nil, fmt.Sprintf("* Failed to get creation content for [%s](%s): %v", roomID, roomID.URI().MatrixToURL(), err)
153-
}
171+
powerLevels.CreateEvent = createEvt
154172
ownLevel := powerLevels.GetUserLevel(pe.Bot.UserID)
155173
minLevel := max(powerLevels.Ban(), powerLevels.Redact())
156174
if ownLevel < minLevel && !pe.DryRun {
@@ -171,7 +189,7 @@ func (pe *PolicyEvaluator) tryProtectingRoom(ctx context.Context, joinedRooms *m
171189
zerolog.Ctx(ctx).Warn().Err(err).Stringer("room_id", roomID).Msg("Failed to get server ACL")
172190
}
173191
slices.Sort(acl.Deny)
174-
pe.markAsProtectedRoom(roomID, name.Name, &acl, members.Chunk)
192+
pe.markAsProtectedRoom(roomID, name.Name, &acl, createEvt.Content.AsCreate(), members.Chunk)
175193
if doReeval {
176194
memberIDs := make([]id.UserID, len(members.Chunk))
177195
for i, member := range members.Chunk {
@@ -247,10 +265,21 @@ func (pe *PolicyEvaluator) markAsWantToProtect(roomID id.RoomID) {
247265
pe.wantToProtect[roomID] = struct{}{}
248266
}
249267

250-
func (pe *PolicyEvaluator) markAsProtectedRoom(roomID id.RoomID, name string, acl *event.ServerACLEventContent, evts []*event.Event) {
268+
func (pe *PolicyEvaluator) markAsProtectedRoom(
269+
roomID id.RoomID,
270+
name string,
271+
acl *event.ServerACLEventContent,
272+
create *event.CreateEventContent,
273+
evts []*event.Event,
274+
) {
251275
pe.protectedRoomsLock.Lock()
252276
defer pe.protectedRoomsLock.Unlock()
253-
pe.protectedRooms[roomID] = &protectedRoomMeta{Name: name, ACL: acl, ApplyACL: !slices.Contains(pe.skipACLForRooms, roomID)}
277+
pe.protectedRooms[roomID] = &protectedRoomMeta{
278+
Name: name,
279+
ACL: acl,
280+
Create: create,
281+
ApplyACL: !slices.Contains(pe.skipACLForRooms, roomID),
282+
}
254283
delete(pe.wantToProtect, roomID)
255284
for _, evt := range evts {
256285
pe.unlockedUpdateUser(id.UserID(evt.GetStateKey()), evt.RoomID, evt.Content.AsMember().Membership)

0 commit comments

Comments
 (0)