Skip to main content

max / makenotwork

mt: land ultra-fuzz Run #4 remediation (S1, H2, P1/P2, M2, NOTE-1, UX1, security LOWs, P3) Run #4 findings, all gated on tests + clippy: - S1 (SERIOUS): search post_matches joins author on p.author_id (matched reply), not t.author_id (OP); regression test added. - H2: get_thread_stats excludes removed_at/deleted_at; count_posts_in_thread left tombstone-inclusive (pagination correctness, verified false positive). - P1/P2: list_pending_flags + list_community_bans take a limit; moderation page fetches CAP+1 and shows a truncation notice instead of unbounded reads. - M2 (latent): migration 031 redefines the post_count trigger to treat a post active iff removed_at AND deleted_at are null, firing on either column. - NOTE-1: add_footnote_handler 404s on community/slug mismatch (matches flag/endorse/remove); regression test added. - UX1: CSRF degrades without JS via hidden csrf_token input + urlencoded form-field fallback in the middleware (header path untouched). - Security LOWs: link_preview rejects non-80/443 ports; validate_image rejects >50 MP pixel bombs from format headers; nonce eviction time-bucketed. - P3: community_members paginated via the now-revived count_community_members. - Security headers moved outermost so /static and /internal also receive them. Left by decision: M3 (sqlx offline migration), UX2/UX3 (low-value churn), NOTE-3 (mod_remove_post is a live test helper, not dead).
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 23:04 UTC
Signed with PGP, not checked
Commit: 4d7b285212b02350af64c3d7670c097b7f634b4b
Parent: 64c640f
27 files changed, +570 insertions, -59 deletions
@@ -43,13 +43,65 @@
43 43 == 0
44 44 }
45 45
46 - /// Middleware: validate X-CSRF-Token header on POST/PUT/PATCH/DELETE.
46 + /// Extract a single field value from an `application/x-www-form-urlencoded`
47 + /// body, decoding `+` and `%XX` escapes. Returns the first match. Kept
48 + /// dependency-free; only used on the no-JS CSRF fallback path.
49 + fn extract_form_field(bytes: &[u8], key: &str) -> Option<String> {
50 + for pair in bytes.split(|&b| b == b'&') {
51 + let mut it = pair.splitn(2, |&b| b == b'=');
52 + let k = it.next().unwrap_or(b"");
53 + if k == key.as_bytes() {
54 + return Some(percent_decode_form(it.next().unwrap_or(b"")));
55 + }
56 + }
57 + None
58 + }
59 +
60 + /// Decode a urlencoded form value: `+` → space, `%XX` → byte, lossy UTF-8.
61 + fn percent_decode_form(input: &[u8]) -> String {
62 + let mut out = Vec::with_capacity(input.len());
63 + let mut i = 0;
64 + while i < input.len() {
65 + match input[i] {
66 + b'+' => out.push(b' '),
67 + b'%' if i + 2 < input.len() => {
68 + let hi = (input[i + 1] as char).to_digit(16);
69 + let lo = (input[i + 2] as char).to_digit(16);
70 + if let (Some(hi), Some(lo)) = (hi, lo) {
71 + out.push((hi * 16 + lo) as u8);
72 + i += 3;
73 + continue;
74 + }
75 + out.push(b'%');
76 + }
77 + b => out.push(b),
78 + }
79 + i += 1;
80 + }
81 + String::from_utf8_lossy(&out).into_owned()
82 + }
83 +
84 + /// Max body size we'll buffer to recover a CSRF token from a form field on the
85 + /// no-JS fallback path. Forms are tiny; anything larger isn't a urlencoded form
86 + /// we'd be parsing a token out of.
87 + const MAX_FORM_FALLBACK_BYTES: usize = 64 * 1024;
88 +
89 + /// Middleware: validate the CSRF token on POST/PUT/PATCH/DELETE.
90 + ///
91 + /// Two delivery paths, in order:
92 + /// 1. `X-CSRF-Token` header (set by mt.js for every fetch/HTMX request). This
93 + /// is the fast path — the request body is never touched.
94 + /// 2. A hidden `csrf_token` form field, for graceful degradation when mt.js
95 + /// didn't run (JS disabled, asset failure). Only urlencoded bodies are
96 + /// inspected, and only when the header is absent; multipart uploads remain
97 + /// header-only. The body is buffered, validated, and re-attached so the
98 + /// downstream handler still sees it.
47 99 ///
48 100 /// `/auth/` is deliberately NOT exempt: its only mutating routes (`logout`,
49 - /// `refresh`) are same-origin forms that carry the token via mt.js, so they
50 - /// get CSRF protection like everything else. `login`/`callback` are GET and so
51 - /// never reach this check. `/_test/` is only ever mounted by the integration
52 - /// harness (never in production); `/api/health` is GET-only.
101 + /// `refresh`) are same-origin forms that carry the token, so they get CSRF
102 + /// protection like everything else. `login`/`callback` are GET and so never
103 + /// reach this check. `/_test/` is only ever mounted by the integration harness
104 + /// (never in production); `/api/health` is GET-only.
53 105 pub async fn csrf_middleware(request: Request, next: Next) -> Response {
54 106 let method = request.method().clone();
55 107
@@ -72,35 +124,63 @@
72 124 }
73 125 };
74 126
75 - let provided_token = request
127 + let session_token: Option<String> = session.get(CSRF_SESSION_KEY).await.ok().flatten();
128 +
129 + // Fast path: token in the header (mt.js). Body untouched.
130 + if let Some(header_token) = request
76 131 .headers()
77 132 .get("X-CSRF-Token")
78 133 .and_then(|v| v.to_str().ok())
79 - .map(|s| s.to_string());
134 + .map(|s| s.to_string())
135 + {
136 + return match session_token {
137 + Some(ref expected) if constant_time_compare(expected, &header_token) => {
138 + next.run(request).await
139 + }
140 + _ => {
141 + tracing::warn!(path = %path, "CSRF token mismatch");
142 + (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()
143 + }
144 + };
145 + }
80 146
81 - let token = match provided_token {
82 - Some(t) => t,
83 - None => {
84 - tracing::warn!(path = %path, "CSRF token missing");
147 + // Fallback path: no header. Accept a hidden `csrf_token` form field from a
148 + // urlencoded body so the site degrades gracefully without JS.
149 + let is_form = request
150 + .headers()
151 + .get(axum::http::header::CONTENT_TYPE)
152 + .and_then(|v| v.to_str().ok())
153 + .map(|ct| ct.starts_with("application/x-www-form-urlencoded"))
154 + .unwrap_or(false);
155 +
156 + if !is_form {
157 + tracing::warn!(path = %path, "CSRF token missing");
158 + return (StatusCode::FORBIDDEN, "CSRF token required").into_response();
159 + }
160 +
161 + let (parts, body) = request.into_parts();
162 + let bytes = match axum::body::to_bytes(body, MAX_FORM_FALLBACK_BYTES).await {
163 + Ok(b) => b,
164 + Err(_) => {
165 + tracing::warn!(path = %path, "CSRF fallback: body too large or unreadable");
85 166 return (StatusCode::FORBIDDEN, "CSRF token required").into_response();
86 167 }
87 168 };
88 169
89 - let session_token: Option<String> = session
90 - .get(CSRF_SESSION_KEY)
91 - .await
92 - .ok()
93 - .flatten();
170 + let form_token = extract_form_field(&bytes, "csrf_token");
94 171
95 - match session_token {
96 - Some(ref expected) if constant_time_compare(expected, &token) => {
97 - next.run(request).await
98 - }
99 - _ => {
100 - tracing::warn!(path = %path, "CSRF token mismatch");
101 - (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()
102 - }
172 + let valid = matches!(
173 + (&session_token, &form_token),
174 + (Some(expected), Some(provided)) if constant_time_compare(expected, provided)
175 + );
176 +
177 + if !valid {
178 + tracing::warn!(path = %path, "CSRF token mismatch (form fallback)");
179 + return (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response();
103 180 }
181 +
182 + next.run(Request::from_parts(parts, axum::body::Body::from(bytes)))
183 + .await
104 184 }
105 185
106 186 #[cfg(test)]
@@ -129,4 +209,26 @@
129 209 assert!(!constant_time_compare("", "a"));
130 210 assert!(constant_time_compare("", ""));
131 211 }
212 +
213 + #[test]
214 + fn extract_form_field_finds_token() {
215 + let body = b"username=alice&csrf_token=deadbeef&duration=1h";
216 + assert_eq!(extract_form_field(body, "csrf_token").as_deref(), Some("deadbeef"));
217 + assert_eq!(extract_form_field(body, "username").as_deref(), Some("alice"));
218 + assert_eq!(extract_form_field(body, "missing"), None);
219 + }
220 +
221 + #[test]
222 + fn extract_form_field_decodes_escapes() {
223 + let body = b"reason=a+b%2Fc&csrf_token=abc123";
224 + assert_eq!(extract_form_field(body, "reason").as_deref(), Some("a b/c"));
225 + assert_eq!(extract_form_field(body, "csrf_token").as_deref(), Some("abc123"));
226 + }
227 +
228 + #[test]
229 + fn extract_form_field_handles_empty_and_valueless() {
230 + assert_eq!(extract_form_field(b"", "csrf_token"), None);
231 + assert_eq!(extract_form_field(b"csrf_token=", "csrf_token").as_deref(), Some(""));
232 + assert_eq!(extract_form_field(b"csrf_token", "csrf_token").as_deref(), Some(""));
233 + }
132 234 }
@@ -44,18 +44,39 @@
44 44 /// by (request rate × window), and the internal rate limiter caps that. Nonces
45 45 /// are inserted only AFTER the signature verifies, so unauthenticated traffic
46 46 /// can't poison or grow the cache.
47 - static NONCE_CACHE: LazyLock<Mutex<HashMap<String, i64>>> =
48 - LazyLock::new(|| Mutex::new(HashMap::new()));
47 + struct NonceCache {
48 + seen: HashMap<String, i64>,
49 + /// Unix time of the last full sweep; the O(n) `retain` runs at most once per
50 + /// window rather than on every insert.
51 + last_sweep: i64,
52 + }
53 +
54 + static NONCE_CACHE: LazyLock<Mutex<NonceCache>> = LazyLock::new(|| {
55 + Mutex::new(NonceCache {
56 + seen: HashMap::new(),
57 + last_sweep: 0,
58 + })
59 + });
49 60
50 61 /// Record a nonce as seen. Returns `false` if it was already present within the
51 - /// window (a replay). Sweeps aged entries opportunistically.
62 + /// window (a replay).
63 + ///
64 + /// Eviction is time-bucketed: the O(n) sweep of aged entries runs at most once
65 + /// per freshness window, not on every call, so the hot internal path stays
66 + /// effectively O(1) under the lock. Keeping an aged entry slightly longer is
67 + /// harmless — a request old enough to evict is already rejected by the timestamp
68 + /// freshness check before it ever reaches here, so it can't be the nonce we'd
69 + /// have swept. Worst-case memory is ~2× the window's traffic instead of 1×.
52 70 fn record_nonce(nonce: &str, now_unix: i64) -> bool {
53 71 let mut cache = NONCE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
54 - cache.retain(|_, &mut ts| now_unix - ts <= MAX_TIMESTAMP_AGE_SECS);
55 - if cache.contains_key(nonce) {
72 + if now_unix - cache.last_sweep >= MAX_TIMESTAMP_AGE_SECS {
73 + cache.seen.retain(|_, &mut ts| now_unix - ts <= MAX_TIMESTAMP_AGE_SECS);
74 + cache.last_sweep = now_unix;
75 + }
76 + if cache.seen.contains_key(nonce) {
56 77 return false;
57 78 }
58 - cache.insert(nonce.to_string(), now_unix);
79 + cache.seen.insert(nonce.to_string(), now_unix);
59 80 true
60 81 }
61 82
@@ -45,15 +45,30 @@
45 45 .or_else(|| lower.strip_prefix("https://"))
46 46 .unwrap_or("");
47 47 let host_and_port = host_part.split('/').next().unwrap_or("");
48 - let host = if host_and_port.starts_with('[') {
49 - host_and_port
50 - .split(']')
51 - .next()
52 - .map(|s| format!("{}]", s))
53 - .unwrap_or_default()
48 + // Split host and optional port (`[ipv6]:port` or `host:port`).
49 + let (host, port) = if host_and_port.starts_with('[') {
50 + match host_and_port.split_once(']') {
51 + Some((h, rest)) => (
52 + format!("{}]", h),
53 + rest.strip_prefix(':').filter(|p| !p.is_empty()),
54 + ),
55 + None => (host_and_port.to_string(), None),
56 + }
54 57 } else {
55 - host_and_port.split(':').next().unwrap_or("").to_string()
58 + match host_and_port.split_once(':') {
59 + Some((h, p)) => (h.to_string(), Some(p).filter(|p| !p.is_empty())),
60 + None => (host_and_port.to_string(), None),
61 + }
56 62 };
63 + // Only allow the standard web ports. A public host that legitimately serves
64 + // OG metadata does so on 80/443; an explicit non-web port (e.g. :6379) means
65 + // the link is trying to use us as a request-forwarder to some other service.
66 + if let Some(p) = port
67 + && p != "80"
68 + && p != "443"
69 + {
70 + return false;
71 + }
57 72 let host = host.as_str();
58 73
59 74 // Quick string-based check for common private patterns
@@ -115,9 +115,20 @@
115 115 ))
116 116 .with_secure(state.config.cookie_secure);
117 117
118 - let app = multithreaded::routes::forum_routes(state.clone())
118 + // CSRF + session are scoped to the forum routes only; the internal API uses
119 + // HMAC auth and must not run them.
120 + let forum = multithreaded::routes::forum_routes(state.clone())
119 121 .layer(axum::middleware::from_fn(csrf::csrf_middleware))
120 - .layer(session_layer)
122 + .layer(session_layer);
123 +
124 + // Security headers (CSP, nosniff, X-Frame, cache-control) wrap the WHOLE app,
125 + // applied outermost so `/static` assets and the internal API get them too —
126 + // not just forum routes (the layers used to sit inside `forum_routes`, before
127 + // the merge/nest, so static responses shipped without nosniff/X-Frame/CSP).
128 + let app = forum
129 + // Internal API routes — HMAC auth only, no CSRF/session middleware
130 + .merge(multithreaded::routes::internal::internal_routes(state))
131 + .nest_service("/static", ServeDir::new("static"))
121 132 .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
122 133 axum::http::header::CONTENT_SECURITY_POLICY,
123 134 axum::http::HeaderValue::from_static(
@@ -135,10 +146,7 @@
135 146 .layer(tower_http::set_header::SetResponseHeaderLayer::if_not_present(
136 147 axum::http::header::CACHE_CONTROL,
137 148 axum::http::HeaderValue::from_static("private, no-cache"),
138 - ))
139 - // Internal API routes — HMAC auth only, no CSRF/session middleware
140 - .merge(multithreaded::routes::internal::internal_routes(state))
141 - .nest_service("/static", ServeDir::new("static"));
149 + ));
142 150
143 151 // Default to loopback. Rate limiting uses TrustedProxyKeyExtractor, which
144 152 // honors X-Forwarded-For only from a configured trusted proxy (TRUSTED_PROXIES,
@@ -14,6 +14,14 @@
14 14 /// Maximum image size: 5 MB.
15 15 pub const MAX_IMAGE_SIZE: usize = 5 * 1024 * 1024;
16 16
17 + /// Maximum decoded image dimensions (pixels). The 5 MB byte cap does NOT bound
18 + /// the decoded pixel count — a lossless PNG/WebP of a solid color compresses a
19 + /// gigapixel canvas into a few KB (a decompression / "pixel bomb"). We never
20 + /// decode server-side, but a viewer's browser would, so we reject absurd
21 + /// declared dimensions up front. 50 MP comfortably clears any real photo
22 + /// (an 8000x6000 shot is 48 MP).
23 + pub const MAX_IMAGE_PIXELS: u64 = 50_000_000;
24 +
17 25 /// Allowed image content types.
18 26 const ALLOWED_CONTENT_TYPES: &[&str] = &[
19 27 "image/png",
@@ -102,6 +110,92 @@
102 110 None
103 111 }
104 112
113 + /// Read the declared pixel dimensions `(width, height)` straight from the format
114 + /// header, without decoding the image. Returns `None` if the header is too short
115 + /// or the dimensions can't be located — callers treat that as "can't tell",
116 + /// which is safe because the format was already authenticated by
117 + /// [`sniff_image_format`] and a real pixel bomb carries parseable dimensions.
118 + fn image_dimensions(format: &str, data: &[u8]) -> Option<(u32, u32)> {
119 + let be16 = |i: usize| -> Option<u32> {
120 + Some(u32::from(u16::from_be_bytes([*data.get(i)?, *data.get(i + 1)?])))
121 + };
122 + let le16 = |i: usize| -> Option<u32> {
123 + Some(u32::from(u16::from_le_bytes([*data.get(i)?, *data.get(i + 1)?])))
124 + };
125 + match format {
126 + // PNG: IHDR is the first chunk; width/height are big-endian u32 at 16/20.
127 + "png" => {
128 + let w = u32::from_be_bytes([
129 + *data.get(16)?, *data.get(17)?, *data.get(18)?, *data.get(19)?,
130 + ]);
131 + let h = u32::from_be_bytes([
132 + *data.get(20)?, *data.get(21)?, *data.get(22)?, *data.get(23)?,
133 + ]);
134 + Some((w, h))
135 + }
136 + // GIF: logical-screen width/height are little-endian u16 at 6/8.
137 + "gif" => Some((le16(6)?, le16(8)?)),
138 + // JPEG: scan segments for a Start-Of-Frame marker (C0-CF except the
139 + // non-SOF C4/C8/CC); height/width are big-endian u16 right after the
140 + // 3-byte (length + precision) preamble.
141 + "jpg" => {
142 + let mut i = 2; // skip SOI (FF D8)
143 + while i + 9 < data.len() {
144 + if data[i] != 0xFF {
145 + i += 1;
146 + continue;
147 + }
148 + let marker = data[i + 1];
149 + if (0xC0..=0xCF).contains(&marker)
150 + && marker != 0xC4 && marker != 0xC8 && marker != 0xCC
151 + {
152 + return Some((be16(i + 7)?, be16(i + 5)?));
153 + }
154 + // Standalone markers (RSTn, SOI, EOI, TEM) carry no length.
155 + if marker == 0xD8 || marker == 0xD9 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
156 + i += 2;
157 + continue;
158 + }
159 + let seg_len = be16(i + 2)? as usize;
160 + if seg_len < 2 {
161 + return None;
162 + }
163 + i += 2 + seg_len;
164 + }
165 + None
166 + }
167 + // WebP: three sub-chunk layouts after the "WEBP" tag at byte 12.
168 + "webp" => match data.get(12..16)? {
169 + b"VP8X" => {
170 + // Canvas width/height minus one, 24-bit little-endian at 24/27.
171 + let w = 1 + (u32::from(*data.get(24)?)
172 + | u32::from(*data.get(25)?) << 8
173 + | u32::from(*data.get(26)?) << 16);
174 + let h = 1 + (u32::from(*data.get(27)?)
175 + | u32::from(*data.get(28)?) << 8
176 + | u32::from(*data.get(29)?) << 16);
177 + Some((w, h))
178 + }
179 + b"VP8L" => {
180 + // 14-bit width/height minus one, packed after the 0x2F signature.
181 + let b = data.get(21..25)?;
182 + let bits = u32::from(b[0])
183 + | u32::from(b[1]) << 8
184 + | u32::from(b[2]) << 16
185 + | u32::from(b[3]) << 24;
186 + Some((1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF)))
187 + }
188 + b"VP8 " => {
189 + // Lossy: 14-bit width/height little-endian at 26/28 (after the
190 + // 3-byte start code 9D 01 2A).
191 + Some((le16(26)? & 0x3FFF, le16(28)? & 0x3FFF))
192 + }
193 + _ => None,
194 + },
195 + _ => None,
196 + }
197 + }
198 +
105 199 /// Validate an uploaded file. Returns the sanitized extension and content type.
106 200 ///
107 201 /// Defence in depth: the filename extension and the multipart Content-Type must
@@ -158,7 +252,17 @@
158 252
159 253 // Authoritative check: the bytes themselves must be the declared format.
160 254 match sniff_image_format(data) {
161 - Some(sniffed) if sniffed == ext_str => Ok((ext_str, ct)),
255 + Some(sniffed) if sniffed == ext_str => {
256 + // Reject decompression / pixel bombs: a tiny file can still declare a
257 + // gigapixel canvas that detonates in a viewer's browser. If we can
258 + // read the dimensions and they exceed the cap, refuse.
259 + if let Some((w, h)) = image_dimensions(ext_str, data)
260 + && u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS
261 + {
262 + return Err("Image dimensions are too large.");
263 + }
264 + Ok((ext_str, ct))
265 + }
162 266 Some(_) => Err("File contents do not match the declared image type."),
163 267 None => Err("File is not a valid PNG, JPEG, GIF, or WebP image."),
164 268 }
@@ -283,6 +387,26 @@
283 387 assert!(err.contains("does not match"));
284 388 }
285 389
390 + #[test]
391 + fn validate_rejects_png_pixel_bomb() {
392 + // A tiny, valid-looking PNG that declares a 30000x30000 (900 MP) canvas.
393 + let mut bomb = png_bytes();
394 + let w = 30_000u32.to_be_bytes();
395 + let h = 30_000u32.to_be_bytes();
396 + bomb[16..20].copy_from_slice(&w);
397 + bomb[20..24].copy_from_slice(&h);
398 + let err = validate_image("bomb.png", "image/png", &bomb).unwrap_err();
399 + assert!(err.contains("dimensions are too large"));
400 + }
401 +
402 + #[test]
403 + fn validate_accepts_reasonable_png_dimensions() {
404 + let mut img = png_bytes();
405 + img[16..20].copy_from_slice(&1920u32.to_be_bytes());
406 + img[20..24].copy_from_slice(&1080u32.to_be_bytes());
407 + assert!(validate_image("ok.png", "image/png", &img).is_ok());
408 + }
409 +
286 410 #[test]
287 411 fn validate_rejects_empty() {
288 412 let err = validate_image("photo.png", "image/png", &[]).unwrap_err();
@@ -178,12 +178,19 @@
178 178 tracing::error!(error = %e, "failed to clean up expired bans");
179 179 }
180 180
181 - let db_bans = mt_db::queries::list_community_bans(&state.db, community.id)
181 + // Cap both moderation reads so a large ban/flag backlog can't make one page
182 + // load materialize an unbounded result set. Fetch CAP+1 to detect whether
183 + // more exist than we show, then surface that rather than silently truncating.
184 + const MOD_LIST_CAP: usize = 200;
185 +
186 + let mut db_bans = mt_db::queries::list_community_bans(&state.db, community.id, MOD_LIST_CAP as i64 + 1)
182 187 .await
183 188 .map_err(|e| {
184 189 tracing::error!(error = ?e, "db error listing bans");
185 190 StatusCode::INTERNAL_SERVER_ERROR.into_response()
186 191 })?;
192 + let bans_truncated = db_bans.len() > MOD_LIST_CAP;
193 + db_bans.truncate(MOD_LIST_CAP);
187 194
188 195 let bans = db_bans
189 196 .into_iter()
@@ -198,12 +205,14 @@
198 205 })
199 206 .collect();
200 207
201 - let db_flags = mt_db::queries::list_pending_flags(&state.db, community.id)
208 + let mut db_flags = mt_db::queries::list_pending_flags(&state.db, community.id, MOD_LIST_CAP as i64 + 1)
202 209 .await
203 210 .map_err(|e| {
204 211 tracing::error!(error = ?e, "db error listing flags");
205 212 StatusCode::INTERNAL_SERVER_ERROR.into_response()
206 213 })?;
214 + let flags_truncated = db_flags.len() > MOD_LIST_CAP;
215 + db_flags.truncate(MOD_LIST_CAP);
207 216
208 217 let pending_flags = db_flags
209 218 .into_iter()
@@ -227,7 +236,9 @@
227 236 community_name: community.name,
228 237 community_slug: slug,
229 238 bans,
239 + bans_truncated,
230 240 pending_flags,
241 + flags_truncated,
231 242 is_owner: is_owner(&role),
232 243 })
233 244 }