Skip to main content

termivar_scanner/wordpress_review/
layout.rs

1//! Strict, bounded operator declarations for WordPress deployment locations.
2//!
3//! A layout binds inert same-origin directory roles to one selected
4//! application. It neither authenticates server configuration nor grants
5//! transport authority; discovery still requires an eligible observed asset.
6
7use 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}