1use std::fmt;
8
9use serde::{Deserialize, Deserializer};
10use sha2::{Digest, Sha256};
11use url::Url;
12
13use super::{parse_bounded_json, WordPressReviewError, MAX_WORDPRESS_REFERENCE_BYTES};
14
15pub const WORDPRESS_DISCOVERY_LAYOUT_SCHEMA: &str = "security.wordpress-layout/v1";
16pub const MAX_WORDPRESS_DISCOVERY_LAYOUT_BYTES: usize = 16 * 1024;
17const MAX_LAYOUT_JSON_NODES: usize = 32;
18const MAX_LAYOUT_JSON_MEMBERS: usize = 8;
19
20#[derive(Clone, Eq, PartialEq)]
21pub struct WordPressDiscoveryLayout {
22 application_url: Url,
23 core_base_url: Option<Url>,
24 themes_base_url: Option<Url>,
25 plugins_base_url: Option<Url>,
26 byte_length: usize,
27 sha256: [u8; 32],
28}
29
30impl fmt::Debug for WordPressDiscoveryLayout {
31 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32 formatter
33 .debug_struct("WordPressDiscoveryLayout")
34 .field("application_url", &"<redacted>")
35 .field("has_core_base_url", &self.core_base_url.is_some())
36 .field("has_themes_base_url", &self.themes_base_url.is_some())
37 .field("has_plugins_base_url", &self.plugins_base_url.is_some())
38 .field("byte_length", &self.byte_length)
39 .field("sha256", &"<redacted>")
40 .finish()
41 }
42}
43
44impl WordPressDiscoveryLayout {
45 #[must_use]
46 pub const fn application_url(&self) -> &Url {
47 &self.application_url
48 }
49
50 #[must_use]
51 pub const fn core_base_url(&self) -> Option<&Url> {
52 self.core_base_url.as_ref()
53 }
54
55 #[must_use]
56 pub const fn themes_base_url(&self) -> Option<&Url> {
57 self.themes_base_url.as_ref()
58 }
59
60 #[must_use]
61 pub const fn plugins_base_url(&self) -> Option<&Url> {
62 self.plugins_base_url.as_ref()
63 }
64
65 #[must_use]
66 pub const fn byte_length(&self) -> usize {
67 self.byte_length
68 }
69
70 #[must_use]
71 pub const fn sha256(&self) -> &[u8; 32] {
72 &self.sha256
73 }
74}
75
76pub fn parse_wordpress_discovery_layout(
77 bytes: &[u8],
78) -> Result<WordPressDiscoveryLayout, WordPressReviewError> {
79 let value = parse_bounded_json(
80 bytes,
81 MAX_WORDPRESS_DISCOVERY_LAYOUT_BYTES,
82 MAX_LAYOUT_JSON_NODES,
83 MAX_LAYOUT_JSON_MEMBERS,
84 WordPressReviewError::DiscoveryLayoutTooLarge,
85 )?;
86 if value
87 .as_object()
88 .and_then(|object| object.get("schema"))
89 .and_then(serde_json::Value::as_str)
90 != Some(WORDPRESS_DISCOVERY_LAYOUT_SCHEMA)
91 {
92 return Err(WordPressReviewError::UnsupportedSchema);
93 }
94 let wire: LayoutWire =
95 serde_json::from_value(value).map_err(|_| WordPressReviewError::InvalidDiscoveryLayout)?;
96 let application_url =
97 validate_directory_url(&wire.application_url, DirectoryRole::Application)?;
98 let core_base_url = wire
99 .core_base_url
100 .as_deref()
101 .map(|value| validate_directory_url(value, DirectoryRole::Core))
102 .transpose()?;
103 let themes_base_url = wire
104 .themes_base_url
105 .as_deref()
106 .map(|value| validate_directory_url(value, DirectoryRole::Themes))
107 .transpose()?;
108 let plugins_base_url = wire
109 .plugins_base_url
110 .as_deref()
111 .map(|value| validate_directory_url(value, DirectoryRole::Plugins))
112 .transpose()?;
113 if core_base_url.is_none() && themes_base_url.is_none() && plugins_base_url.is_none() {
114 return Err(WordPressReviewError::InvalidDiscoveryLayout);
115 }
116 for base in [
117 core_base_url.as_ref(),
118 themes_base_url.as_ref(),
119 plugins_base_url.as_ref(),
120 ]
121 .into_iter()
122 .flatten()
123 {
124 if base.origin() != application_url.origin() {
125 return Err(WordPressReviewError::InvalidDiscoveryLayout);
126 }
127 }
128 if core_base_url.as_ref() == Some(&application_url)
129 && themes_base_url.is_none()
130 && plugins_base_url.is_none()
131 {
132 return Err(WordPressReviewError::InvalidDiscoveryLayout);
133 }
134 if themes_base_url
135 .as_ref()
136 .zip(plugins_base_url.as_ref())
137 .is_some_and(|(themes, plugins)| {
138 directory_is_segment_prefix(themes, plugins)
139 || directory_is_segment_prefix(plugins, themes)
140 })
141 {
142 return Err(WordPressReviewError::InvalidDiscoveryLayout);
143 }
144 Ok(WordPressDiscoveryLayout {
145 application_url,
146 core_base_url,
147 themes_base_url,
148 plugins_base_url,
149 byte_length: bytes.len(),
150 sha256: Sha256::digest(bytes).into(),
151 })
152}
153
154#[derive(Clone, Copy)]
155enum DirectoryRole {
156 Application,
157 Core,
158 Themes,
159 Plugins,
160}
161
162fn validate_directory_url(value: &str, role: DirectoryRole) -> Result<Url, WordPressReviewError> {
163 if value.is_empty()
164 || value.len() > MAX_WORDPRESS_REFERENCE_BYTES
165 || value.chars().any(|character| {
166 character == '\\' || character.is_control() || character.is_whitespace()
167 })
168 || value.as_bytes().contains(&b'%')
169 {
170 return Err(WordPressReviewError::InvalidDiscoveryLayout);
171 }
172 let Some(raw_path) = raw_http_directory_path(value) else {
173 return Err(WordPressReviewError::InvalidDiscoveryLayout);
174 };
175 if raw_path.starts_with("//")
176 || raw_path.split('/').enumerate().any(|(index, segment)| {
177 matches!(segment, "." | "..")
178 || (segment.is_empty() && index != 0 && index + 1 != raw_path.split('/').count())
179 || is_device_like_segment(segment)
180 })
181 {
182 return Err(WordPressReviewError::InvalidDiscoveryLayout);
183 }
184 let url = Url::parse(value).map_err(|_| WordPressReviewError::InvalidDiscoveryLayout)?;
185 if url.as_str() != value
186 || !matches!(url.scheme(), "http" | "https")
187 || !url.has_host()
188 || !url.username().is_empty()
189 || url.password().is_some()
190 || url.query().is_some()
191 || url.fragment().is_some()
192 || !url.path().ends_with('/')
193 || (matches!(role, DirectoryRole::Themes | DirectoryRole::Plugins) && url.path() == "/")
194 {
195 return Err(WordPressReviewError::InvalidDiscoveryLayout);
196 }
197 Ok(url)
198}
199
200fn is_device_like_segment(segment: &str) -> bool {
201 if segment.is_empty() {
202 return false;
203 }
204 let normalized = segment.trim_end_matches(['.', ' ']);
205 if normalized.is_empty() || normalized != segment || normalized.contains(':') {
206 return true;
207 }
208 let stem = normalized
209 .split_once('.')
210 .map_or(normalized, |(stem, _)| stem)
211 .to_ascii_lowercase();
212 matches!(stem.as_str(), "con" | "prn" | "aux" | "nul")
213 || stem
214 .strip_prefix("com")
215 .or_else(|| stem.strip_prefix("lpt"))
216 .is_some_and(|number| number.len() == 1 && matches!(number.as_bytes()[0], b'1'..=b'9'))
217 || stem.len() == 2 && stem.as_bytes()[0].is_ascii_alphabetic() && stem.as_bytes()[1] == b'|'
218}
219
220fn raw_http_directory_path(value: &str) -> Option<&str> {
221 let scheme_bytes = if value
222 .get(..7)
223 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
224 {
225 7
226 } else if value
227 .get(..8)
228 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
229 {
230 8
231 } else {
232 return None;
233 };
234 let authority_and_path = value.get(scheme_bytes..)?;
235 let path_offset = authority_and_path.find('/')?;
236 let authority = &authority_and_path[..path_offset];
237 let path = &authority_and_path[path_offset..];
238 if authority.is_empty()
239 || authority
240 .chars()
241 .any(|character| matches!(character, '@' | '?' | '#'))
242 || !path.starts_with('/')
243 || !path.ends_with('/')
244 {
245 return None;
246 }
247 Some(path)
248}
249
250fn directory_is_segment_prefix(left: &Url, right: &Url) -> bool {
251 if left.origin() != right.origin() {
252 return false;
253 }
254 let Some(left) = left.path_segments() else {
255 return false;
256 };
257 let Some(right) = right.path_segments() else {
258 return false;
259 };
260 let left = left
261 .filter(|segment| !segment.is_empty())
262 .collect::<Vec<_>>();
263 let right = right
264 .filter(|segment| !segment.is_empty())
265 .collect::<Vec<_>>();
266 left.len() <= right.len() && left.iter().zip(&right).all(|(left, right)| left == right)
267}
268
269#[derive(Deserialize)]
270#[serde(deny_unknown_fields)]
271struct LayoutWire {
272 #[allow(dead_code)]
273 schema: String,
274 application_url: String,
275 #[serde(default, deserialize_with = "deserialize_present_url")]
276 core_base_url: Option<String>,
277 #[serde(default, deserialize_with = "deserialize_present_url")]
278 themes_base_url: Option<String>,
279 #[serde(default, deserialize_with = "deserialize_present_url")]
280 plugins_base_url: Option<String>,
281}
282
283fn deserialize_present_url<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
284where
285 D: Deserializer<'de>,
286{
287 String::deserialize(deserializer).map(Some)
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn strict_layout_retains_exact_input_identity() {
296 let bytes = br#"{
297 "schema":"security.wordpress-layout/v1",
298 "application_url":"https://example.test/blog/",
299 "core_base_url":"https://example.test/cms/",
300 "themes_base_url":"https://example.test/site-content/themes/",
301 "plugins_base_url":"https://example.test/modules/"
302 }"#;
303 let layout = parse_wordpress_discovery_layout(bytes).unwrap();
304 assert_eq!(layout.application_url().path(), "/blog/");
305 assert_eq!(layout.core_base_url().unwrap().path(), "/cms/");
306 assert_eq!(
307 layout.themes_base_url().unwrap().path(),
308 "/site-content/themes/"
309 );
310 assert_eq!(layout.plugins_base_url().unwrap().path(), "/modules/");
311 assert_eq!(layout.byte_length(), bytes.len());
312 let expected: [u8; 32] = Sha256::digest(bytes).into();
313 assert_eq!(layout.sha256(), &expected);
314 }
315
316 #[test]
317 fn layout_rejects_ambiguous_or_authority_widening_shapes() {
318 for document in [
319 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/"}"#,
320 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","themes_base_url":"https://other.test/themes/"}"#,
321 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","themes_base_url":"https://example.test/"}"#,
322 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","themes_base_url":"https://example.test/content/","plugins_base_url":"https://example.test/content/plugins/"}"#,
323 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","core_base_url":"https://example.test/"}"#,
324 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://user@example.test/","core_base_url":"https://example.test/cms/"}"#,
325 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/blog","core_base_url":"https://example.test/cms/"}"#,
326 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/a/../","core_base_url":"https://example.test/cms/"}"#,
327 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/%252e%252e/","core_base_url":"https://example.test/cms/"}"#,
328 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/%2e%2e/","core_base_url":"https://example.test/cms/"}"#,
329 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/%00/","core_base_url":"https://example.test/cms/"}"#,
330 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test//blog/","core_base_url":"https://example.test/cms/"}"#,
331 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/blog//child/","core_base_url":"https://example.test/cms/"}"#,
332 r#"{"schema":"security.wordpress-layout/v1","application_url":"file:///C:/blog/","core_base_url":"file:///C:/cms/"}"#,
333 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/C:/blog/","core_base_url":"https://example.test/cms/"}"#,
334 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/C%3a/blog/","core_base_url":"https://example.test/cms/"}"#,
335 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/C|/blog/","core_base_url":"https://example.test/cms/"}"#,
336 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/blog/","core_base_url":"https://example.test/CON/"}"#,
337 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/blog/","core_base_url":"https://example.test/lpt1.data/"}"#,
338 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/blog/","core_base_url":"https://example.test/content./"}"#,
339 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/blog/","themes_base_url":"https://example.test/content/themes/","plugins_base_url":"https://example.test/content/%74hemes/"}"#,
340 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/blog/?mode=1","core_base_url":"https://example.test/cms/"}"#,
341 r#"{"schema":"security.wordpress-layout/v1","application_url":"https:example.test/blog/","core_base_url":"https://example.test/cms/"}"#,
342 r#"{"schema":"security.wordpress-layout/v1","application_url":"https:/example.test/blog/","core_base_url":"https://example.test/cms/"}"#,
343 r#"{"schema":"security.wordpress-layout/v1","application_url":"https:///example.test/blog/","core_base_url":"https://example.test/cms/"}"#,
344 r#"{"schema":"security.wordpress-layout/v1","application_url":" https://example.test/blog/","core_base_url":"https://example.test/cms/"}"#,
345 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://@example.test/blog/","core_base_url":"https://example.test/cms/"}"#,
346 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://%65xample.test/blog/","core_base_url":"https://example.test/cms/"}"#,
347 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://EXAMPLE.test/blog/","core_base_url":"https://example.test/cms/"}"#,
348 r#"{"schema":"security.wordpress-layout/v1","application_url":"https://bücher.example/blog/","core_base_url":"https://example.test/cms/"}"#,
349 r#"{"schema":"security.wordpress-layout/v1","application_url":"http://2130706433/blog/","core_base_url":"http://127.0.0.1/cms/"}"#,
350 r#"{"schema":"security.wordpress-layout/v1","application_url":"http://127.1/blog/","core_base_url":"http://127.0.0.1/cms/"}"#,
351 r#"{"schema":"security.wordpress-layout/v1","application_url":"http://example.test:80/blog/","core_base_url":"http://example.test/cms/"}"#,
352 r##"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/blog/#fragment","core_base_url":"https://example.test/cms/"}"##,
353 ] {
354 assert!(
355 parse_wordpress_discovery_layout(document.as_bytes()).is_err(),
356 "{document}"
357 );
358 }
359 let unicode_control = "{\"schema\":\"security.wordpress-layout/v1\",\"application_url\":\"https://example.test/blog/\",\"core_base_url\":\"https://example.test/control\u{85}/\"}";
360 assert_eq!(
361 parse_wordpress_discovery_layout(unicode_control.as_bytes()).unwrap_err(),
362 WordPressReviewError::InvalidDiscoveryLayout
363 );
364 }
365
366 #[test]
367 fn layout_requires_canonical_ports_and_one_exact_effective_origin() {
368 let accepted = br#"{
369 "schema":"security.wordpress-layout/v1",
370 "application_url":"https://example.test:8443/blog/",
371 "core_base_url":"https://example.test:8443/cms/"
372 }"#;
373 assert!(parse_wordpress_discovery_layout(accepted).is_ok());
374
375 let default_port = br#"{
376 "schema":"security.wordpress-layout/v1",
377 "application_url":"https://example.test/blog/",
378 "core_base_url":"https://example.test:443/cms/"
379 }"#;
380 assert_eq!(
381 parse_wordpress_discovery_layout(default_port).unwrap_err(),
382 WordPressReviewError::InvalidDiscoveryLayout
383 );
384
385 let alternate_port = br#"{
386 "schema":"security.wordpress-layout/v1",
387 "application_url":"https://example.test:8443/blog/",
388 "core_base_url":"https://example.test/cms/"
389 }"#;
390 assert_eq!(
391 parse_wordpress_discovery_layout(alternate_port).unwrap_err(),
392 WordPressReviewError::InvalidDiscoveryLayout
393 );
394 }
395
396 #[test]
397 fn layout_rejects_duplicates_unknowns_and_limit_plus_one() {
398 let duplicate = br#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","application_url":"https://example.test/blog/","core_base_url":"https://example.test/cms/"}"#;
399 assert_eq!(
400 parse_wordpress_discovery_layout(duplicate).unwrap_err(),
401 WordPressReviewError::DuplicateKey
402 );
403 let unknown = br#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","core_base_url":"https://example.test/cms/","paths":[]}"#;
404 assert_eq!(
405 parse_wordpress_discovery_layout(unknown).unwrap_err(),
406 WordPressReviewError::InvalidDiscoveryLayout
407 );
408 for explicit_null in [
409 br#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","core_base_url":null}"#.as_slice(),
410 br#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","themes_base_url":null}"#.as_slice(),
411 br#"{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","plugins_base_url":null}"#.as_slice(),
412 ] {
413 assert_eq!(
414 parse_wordpress_discovery_layout(explicit_null).unwrap_err(),
415 WordPressReviewError::InvalidDiscoveryLayout
416 );
417 }
418 let oversized_reference = format!(
419 r#"{{"schema":"security.wordpress-layout/v1","application_url":"https://example.test/","core_base_url":"https://example.test/{}/"}}"#,
420 "a".repeat(MAX_WORDPRESS_REFERENCE_BYTES)
421 );
422 assert_eq!(
423 parse_wordpress_discovery_layout(oversized_reference.as_bytes()).unwrap_err(),
424 WordPressReviewError::JsonLimitExceeded
425 );
426 assert_eq!(
427 parse_wordpress_discovery_layout(&vec![b' '; MAX_WORDPRESS_DISCOVERY_LAYOUT_BYTES + 1])
428 .unwrap_err(),
429 WordPressReviewError::DiscoveryLayoutTooLarge
430 );
431 }
432
433 #[test]
434 fn layout_debug_redacts_operator_urls() {
435 let sentinel = "layout-private-host.invalid/operator-secret-path";
436 let document = format!(
437 r#"{{"schema":"security.wordpress-layout/v1","application_url":"https://{sentinel}/","core_base_url":"https://{sentinel}/private-core/"}}"#
438 );
439 let debug = format!(
440 "{:?}",
441 parse_wordpress_discovery_layout(document.as_bytes()).unwrap()
442 );
443 assert!(!debug.contains("layout-private-host"), "{debug}");
444 assert!(!debug.contains("operator-secret-path"), "{debug}");
445 assert!(!debug.contains("private-core"), "{debug}");
446 assert!(debug.contains("<redacted>"), "{debug}");
447 assert!(debug.contains("has_core_base_url: true"), "{debug}");
448 }
449}