Compare commits
4
Commits
v1.0.7
...
0902c5e1a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0902c5e1a5 | ||
|
|
1a0a1fa63a | ||
|
|
576922160e | ||
|
|
89afa00678 |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"MD024": false,
|
||||
"MD013": false
|
||||
}
|
||||
@@ -2,6 +2,32 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.0.10] - 2026-02-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Title double-encoding in Twig templates** (`inc/Template/ContextBuilder.php`): WordPress's `get_the_title()` pre-encodes `&` as `&`. When passed to Twig with autoescape enabled, the `&` in `&` was escaped again to `&`, rendering as literal `&` in the browser (e.g. "Bewerbungen & Nachrichten" instead of "Bewerbungen & Nachrichten"). Fixed by wrapping all 6 `get_the_title()` calls with `wp_specialchars_decode()` to decode WordPress entities before Twig. Twig autoescape then properly re-encodes `&` → `&`. This is XSS-safe because Twig still escapes all output.
|
||||
|
||||
## [1.0.9] - 2026-02-19
|
||||
|
||||
### Performance
|
||||
|
||||
- **Color variation CSS transient caching** (`functions.php`): `wp_bootstrap_variation_colors()` now caches the generated inline CSS in a 24-hour WordPress transient keyed by `wp_bootstrap_variation_css_` + an MD5 of the active stylesheet slug. Previously the palette iteration and CSS string building ran on every frontend page load. The transient is immediately invalidated on `switch_theme` and `save_post_wp_global_styles`, so changes made via the Design Editor are reflected instantly.
|
||||
- **Twig template recompilation gated behind `WP_DEBUG`** (`inc/Twig/TwigService.php`): `auto_reload` in the Twig `Environment` constructor was hardcoded to `true`, causing Twig to stat every compiled template file on every request to check for source changes. Changed to `WP_DEBUG` so template recompilation only occurs during development. In production (`WP_DEBUG = false`) compiled Twig templates are served from cache without filesystem mtime checks.
|
||||
|
||||
## [1.0.8] - 2026-02-19
|
||||
|
||||
### Security
|
||||
|
||||
- **Archive XSS hardening**: `ContextBuilder::getArchiveData()` now wraps `get_the_archive_title()` and `get_the_archive_description()` with `wp_kses_post()`. Term descriptions are user-editable by Editors and above; without sanitization an injected `<script>` tag would execute via the `|raw` filter in `archive.html.twig`
|
||||
- **Comment author XSS hardening**: `ContextBuilder::buildCommentTree()` now applies `esc_html()` to `comment_author` and `esc_url()` to `comment_author_url` at the data source, preventing injection via user-supplied comment fields
|
||||
- **Dark mode localStorage whitelist**: `getPreferredTheme()` in `dark-mode.js` now validates the stored theme value against `['dark', 'light']` before use, preventing attribute injection from a tampered localStorage value written by a third-party script
|
||||
- **Twig escaping functions marked safe**: `esc_html()`, `esc_attr()`, and `esc_url()` registered in `TwigService` are now declared with `['is_safe' => ['html']]`, preventing double-encoding if Twig autoescape is ever enabled
|
||||
|
||||
### Changed
|
||||
|
||||
- `views/partials/comment-item.html.twig`: Comment author URL now output via `{{ comment.author_url|raw }}` (escaped in PHP) instead of calling `esc_url()` from the template, keeping escaping logic in one place
|
||||
|
||||
## [1.0.7] - 2026-02-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -34,7 +34,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
||||
|
||||
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
|
||||
|
||||
Current version is **v1.0.5**. See `PLAN.md` for details.
|
||||
Current version is **v1.0.9**. See `PLAN.md` for details.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -98,6 +98,29 @@ Compiled .mo files are built by the Gitea CI/CD pipeline during releases. For lo
|
||||
for po in languages/wp-bootstrap-*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
|
||||
```
|
||||
|
||||
#### Updating Translations
|
||||
|
||||
When new strings are added to PHP sources, use the fast JSON workflow documented in
|
||||
`wp-jobroom-theme/CLAUDE.md → Updating Translations (Fast JSON Workflow)`. That
|
||||
document contains the full step-by-step process including the `patch-po.py` patcher script
|
||||
(located in `wp-jobroom-theme/languages/patch-po.py`) which patches **both** `wp-bootstrap`
|
||||
and `wp-jobroom-theme` `.po` files in a single pass.
|
||||
|
||||
**Quick reference for wp-bootstrap POT regeneration:**
|
||||
|
||||
```bash
|
||||
docker exec jobroom-wordpress wp i18n make-pot \
|
||||
/var/www/html/wp-content/themes/wp-bootstrap \
|
||||
/var/www/html/wp-content/themes/wp-bootstrap/languages/wp-bootstrap.pot \
|
||||
--allow-root
|
||||
|
||||
# Then merge into all .po files:
|
||||
for locale in de_CH de_CH_informal de_DE de_DE_informal en_GB es_ES fr_CH fr_FR it_CH it_IT nl_NL pl_PL pt_PT; do
|
||||
msgmerge --update --backup=none --no-fuzzy-matching \
|
||||
languages/wp-bootstrap-${locale}.po languages/wp-bootstrap.pot
|
||||
done
|
||||
```
|
||||
|
||||
### Create Releases
|
||||
|
||||
**Important Git Notes:**
|
||||
@@ -211,6 +234,74 @@ Build steps (in order):
|
||||
|
||||
## Session History
|
||||
|
||||
### Session 16 — v1.0.10 Title Double-Encoding Fix (2026-02-25)
|
||||
|
||||
**Completed:** Fixed double-encoding of HTML entities in page titles rendered through Twig.
|
||||
|
||||
**Root cause:** WordPress's `get_the_title()` returns titles with HTML entities pre-encoded (e.g. `&` → `&`). `ContextBuilder` passed these pre-encoded strings to Twig as template variables. Twig's autoescape then re-encoded the `&` in `&` to `&#038;`, which browsers rendered as the literal text `&` instead of `&`. Affected all pages with `&` in their title (e.g. help pages "Bewerbungen & Nachrichten", "Konto & Sicherheit", "Abonnements & Abrechnung").
|
||||
|
||||
**Fix:** Wrapped all 6 `get_the_title()` calls in `ContextBuilder.php` with `wp_specialchars_decode()`. This decodes WordPress entities back to raw characters before Twig, allowing Twig autoescape to properly encode them once. XSS-safe because Twig still escapes all output.
|
||||
|
||||
**Files modified:**
|
||||
|
||||
- `inc/Template/ContextBuilder.php` — `wp_specialchars_decode()` on all 6 `get_the_title()` calls
|
||||
- `style.css` — version bump to 1.0.10
|
||||
- `CHANGELOG.md` — v1.0.10 entry
|
||||
|
||||
### Session 15 — v1.0.9 Performance Optimization (2026-02-19)
|
||||
|
||||
**Completed:** Two targeted performance fixes for production environments.
|
||||
|
||||
**Changes made:**
|
||||
|
||||
- **Color variation CSS transient caching** (`functions.php`): `wp_bootstrap_variation_colors()` now caches the generated Bootstrap CSS variable overrides in a 24-hour transient keyed by `wp_bootstrap_variation_css_` + `md5(get_stylesheet())`. Previously the palette loop and CSS string building executed on every frontend request. Transient is invalidated on `switch_theme` and `save_post_wp_global_styles` hooks so Design Editor changes apply immediately.
|
||||
- **Twig `auto_reload` gated behind `WP_DEBUG`** (`inc/Twig/TwigService.php`): Hardcoded `auto_reload => true` caused Twig to `stat()` each compiled template file on every request to detect source file changes. Changed to `auto_reload => WP_DEBUG` so stat checks only occur during development. In production, compiled templates are served from cache unconditionally.
|
||||
|
||||
**Files modified:**
|
||||
|
||||
- `functions.php` — transient caching and invalidation for variation CSS
|
||||
- `inc/Twig/TwigService.php` — `auto_reload => WP_DEBUG`
|
||||
- `style.css` — version bump to 1.0.9
|
||||
- `CHANGELOG.md` — v1.0.9 entry
|
||||
|
||||
### Session 14 — v1.0.8 Security Audit & Hardening (2026-02-19)
|
||||
|
||||
**Completed:** Comprehensive OWASP-aligned security audit. Two parallel background agents reviewed all PHP (functions.php, ContextBuilder, NavWalker, TemplateController, TwigService, all patterns) and JavaScript/Twig templates. Four targeted security fixes applied.
|
||||
|
||||
**Findings and fixes:**
|
||||
|
||||
- **Archive term description XSS (High)**: `get_the_archive_description()` returns raw term content editable by Editor-role users. Templates rendered it with `|raw`, creating a stored XSS path. Fixed: wrapped with `wp_kses_post()` in `ContextBuilder::getArchiveData()`. Same applied to `get_the_archive_title()`.
|
||||
- **Comment author injection (Low, defense-in-depth)**: `comment_author` and `comment_author_url` were passed to Twig as raw database values. Fixed: `esc_html()` applied to author name, `esc_url()` applied to author URL in `ContextBuilder::buildCommentTree()`. Template updated to output pre-escaped URL via `|raw` rather than calling `esc_url()` in Twig.
|
||||
- **Dark mode localStorage whitelist (Medium)**: `getPreferredTheme()` returned any stored value without validation, allowing attribute injection if a malicious script wrote to `localStorage`. Fixed: strict equality check against `['dark', 'light']` before trusting the stored value.
|
||||
- **Twig escaping functions not marked safe (Medium)**: `esc_html()`, `esc_attr()`, `esc_url()` registered in `TwigService` lacked `['is_safe' => ['html']]`, meaning any future autoescape enablement would cause double-encoding. Fixed: all three now carry the `is_safe` declaration.
|
||||
|
||||
**Confirmed secure (no action needed):**
|
||||
|
||||
- All `|raw` filter usages for widget HTML, comment content, comment reply links, and comment forms are by-design (WordPress core output)
|
||||
- Pattern files: no direct-access guards needed (loaded only via `register_block_pattern()`)
|
||||
- No SQL injection vectors (`$wpdb` not used directly; all data via WordPress functions)
|
||||
- `TemplateController` error handling: `\Throwable` caught, logged, and gated behind `WP_DEBUG`
|
||||
- `do_shortcode()` and `wp_kses_post()` Twig functions correctly marked `is_safe`
|
||||
- `wp_head()`, `wp_footer()`, `body_class()` Twig functions correctly use output buffering + `is_safe`
|
||||
|
||||
**Key learnings:**
|
||||
|
||||
- WordPress Twig themes should not enable `autoescape => 'html'` globally: `get_the_title()` applies `wptexturize()` which returns HTML entities (`—`, `“`). Autoescape would double-encode these, corrupting post title rendering.
|
||||
- `esc_url()` does more than HTML-encoding — it validates the URL scheme and strips dangerous protocols (`javascript:`, `data:`). Always use it for user-supplied URLs, even when autoescape is active.
|
||||
- Registering WordPress escaping functions (`esc_url`, `esc_html`, `esc_attr`) as Twig functions without `is_safe => html` silently creates a double-encoding trap: calling `{{ esc_url(url) }}` with autoescape on would produce `&amp;` instead of `&`.
|
||||
- Added `.markdownlint.json` disabling MD024 (duplicate headings, expected in changelogs) and MD013 (line length).
|
||||
|
||||
**Files modified:**
|
||||
|
||||
- `inc/Template/ContextBuilder.php` — archive data sanitization, comment field escaping
|
||||
- `inc/Twig/TwigService.php` — `is_safe => html` on three escaping functions
|
||||
- `views/partials/comment-item.html.twig` — use pre-escaped author URL
|
||||
- `src/js/dark-mode.js` — localStorage whitelist
|
||||
- `assets/js/dark-mode.js` — rebuilt compiled output
|
||||
- `style.css` — version bump to 1.0.8
|
||||
- `CHANGELOG.md` — v1.0.8 entry
|
||||
- `.markdownlint.json` — created
|
||||
|
||||
### Session 13 — v1.0.5 Translation Files (2026-02-11)
|
||||
|
||||
**Completed:** Standardized translation file naming and added 11 new locale translations.
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
*/
|
||||
function getPreferredTheme() {
|
||||
var stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
// Whitelist: only honour known-good values to prevent attribute injection
|
||||
// from a tampered localStorage (e.g. XSS-written value by another script).
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
return stored;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
|
||||
@@ -175,6 +175,17 @@ add_action( 'wp_enqueue_scripts', 'wp_bootstrap_rtl_styles', 20 );
|
||||
*/
|
||||
if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
|
||||
function wp_bootstrap_variation_colors() {
|
||||
$transient_key = 'wp_bootstrap_variation_css_' . md5( get_stylesheet() );
|
||||
$cached_css = get_transient( $transient_key );
|
||||
|
||||
if ( false !== $cached_css ) {
|
||||
// '' means default palette (no inline CSS needed); non-empty string is the computed CSS.
|
||||
if ( '' !== $cached_css ) {
|
||||
wp_add_inline_style( 'wp-bootstrap-style', $cached_css );
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the theme origin palette — this contains the base theme.json
|
||||
// colors merged with the active style variation (if any).
|
||||
$theme_palette = wp_get_global_settings( array( 'color', 'palette', 'theme' ) );
|
||||
@@ -205,10 +216,12 @@ if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
|
||||
|
||||
// No variation active — let Bootstrap's compiled CSS handle both modes.
|
||||
if ( $is_default ) {
|
||||
set_transient( $transient_key, '', DAY_IN_SECONDS );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( empty( $colors['base'] ) || empty( $colors['contrast'] ) ) {
|
||||
set_transient( $transient_key, '', DAY_IN_SECONDS );
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -280,6 +293,9 @@ if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
|
||||
. '[data-bs-theme=light]{' . $light_css . '}'
|
||||
. '[data-bs-theme=dark]{' . $dark_css . '}';
|
||||
|
||||
// Cache for 24 hours; invalidated on theme switch or global-styles save.
|
||||
set_transient( $transient_key, $css, DAY_IN_SECONDS );
|
||||
|
||||
// Attach after the compiled stylesheet so variation values override
|
||||
// Bootstrap's hardcoded dark-mode defaults via source order.
|
||||
wp_add_inline_style( 'wp-bootstrap-style', $css );
|
||||
@@ -287,6 +303,16 @@ if ( ! function_exists( 'wp_bootstrap_variation_colors' ) ) :
|
||||
endif;
|
||||
add_action( 'wp_enqueue_scripts', 'wp_bootstrap_variation_colors', 30 );
|
||||
|
||||
/**
|
||||
* Invalidate the color variation CSS transient when global styles or theme change.
|
||||
*/
|
||||
add_action( 'switch_theme', function () {
|
||||
delete_transient( 'wp_bootstrap_variation_css_' . md5( get_stylesheet() ) );
|
||||
} );
|
||||
add_action( 'save_post_wp_global_styles', function () {
|
||||
delete_transient( 'wp_bootstrap_variation_css_' . md5( get_stylesheet() ) );
|
||||
} );
|
||||
|
||||
/**
|
||||
* Build Bootstrap surface CSS variables for a given background/foreground pair.
|
||||
*
|
||||
|
||||
@@ -153,7 +153,7 @@ class ContextBuilder
|
||||
|
||||
return [
|
||||
'id' => $post->ID,
|
||||
'title' => get_the_title(),
|
||||
'title' => wp_specialchars_decode( get_the_title() ),
|
||||
'url' => get_permalink(),
|
||||
'content' => apply_filters('the_content', get_the_content()),
|
||||
'excerpt' => get_the_excerpt(),
|
||||
@@ -184,7 +184,7 @@ class ContextBuilder
|
||||
$wp_query->the_post();
|
||||
$posts[] = [
|
||||
'id' => get_the_ID(),
|
||||
'title' => get_the_title(),
|
||||
'title' => wp_specialchars_decode( get_the_title() ),
|
||||
'url' => get_permalink(),
|
||||
'excerpt' => get_the_excerpt(),
|
||||
'date' => get_the_date(),
|
||||
@@ -245,8 +245,10 @@ class ContextBuilder
|
||||
private function getArchiveData(): array
|
||||
{
|
||||
return [
|
||||
'title' => get_the_archive_title(),
|
||||
'description' => get_the_archive_description(),
|
||||
// wp_kses_post() allows safe HTML (headings, links, spans) while stripping
|
||||
// script/event-handler attributes that could be injected via term descriptions.
|
||||
'title' => wp_kses_post(get_the_archive_title()),
|
||||
'description' => wp_kses_post(get_the_archive_description()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -291,8 +293,10 @@ class ContextBuilder
|
||||
|
||||
$tree[] = [
|
||||
'id' => (int) $comment->comment_ID,
|
||||
'author' => $comment->comment_author,
|
||||
'author_url' => $comment->comment_author_url,
|
||||
// Escape at source — comment_author is user-supplied, store as safe text.
|
||||
'author' => esc_html($comment->comment_author),
|
||||
// esc_url() strips dangerous schemes (javascript:, data:) and encodes for HTML.
|
||||
'author_url' => esc_url($comment->comment_author_url),
|
||||
'avatar_url' => get_avatar_url($comment, ['size' => 40]),
|
||||
'date' => get_comment_date('', $comment),
|
||||
'date_iso' => get_comment_date('c', $comment),
|
||||
@@ -345,14 +349,14 @@ class ContextBuilder
|
||||
|
||||
if ($prev) {
|
||||
$navigation['previous'] = [
|
||||
'title' => get_the_title($prev),
|
||||
'title' => wp_specialchars_decode( get_the_title($prev) ),
|
||||
'url' => get_permalink($prev),
|
||||
];
|
||||
}
|
||||
|
||||
if ($next) {
|
||||
$navigation['next'] = [
|
||||
'title' => get_the_title($next),
|
||||
'title' => wp_specialchars_decode( get_the_title($next) ),
|
||||
'url' => get_permalink($next),
|
||||
];
|
||||
}
|
||||
@@ -380,7 +384,7 @@ class ContextBuilder
|
||||
$query->the_post();
|
||||
$posts[] = [
|
||||
'id' => get_the_ID(),
|
||||
'title' => get_the_title(),
|
||||
'title' => wp_specialchars_decode( get_the_title() ),
|
||||
'url' => get_permalink(),
|
||||
'date' => get_the_date(),
|
||||
'date_iso' => get_the_date('c'),
|
||||
@@ -434,7 +438,7 @@ class ContextBuilder
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$posts[] = [
|
||||
'title' => get_the_title(),
|
||||
'title' => wp_specialchars_decode( get_the_title() ),
|
||||
'url' => get_permalink(),
|
||||
'date' => get_the_date(),
|
||||
];
|
||||
|
||||
@@ -27,7 +27,7 @@ class TwigService
|
||||
$this->twig = new Environment($loader, [
|
||||
'cache' => WP_DEBUG ? false : $cacheDir,
|
||||
'debug' => WP_DEBUG,
|
||||
'auto_reload' => true,
|
||||
'auto_reload' => WP_DEBUG,
|
||||
]);
|
||||
|
||||
$this->registerWordPressFunctions();
|
||||
@@ -73,10 +73,12 @@ class TwigService
|
||||
return _n($single, $plural, $number, $domain);
|
||||
}));
|
||||
|
||||
// Escaping functions.
|
||||
$this->twig->addFunction(new TwigFunction('esc_html', 'esc_html'));
|
||||
$this->twig->addFunction(new TwigFunction('esc_attr', 'esc_attr'));
|
||||
$this->twig->addFunction(new TwigFunction('esc_url', 'esc_url'));
|
||||
// Escaping functions — marked is_safe so Twig does not double-escape their output.
|
||||
// These functions already return HTML-safe strings; without is_safe, enabling
|
||||
// Twig autoescape would double-encode the result (e.g. & → &amp;).
|
||||
$this->twig->addFunction(new TwigFunction('esc_html', 'esc_html', ['is_safe' => ['html']]));
|
||||
$this->twig->addFunction(new TwigFunction('esc_attr', 'esc_attr', ['is_safe' => ['html']]));
|
||||
$this->twig->addFunction(new TwigFunction('esc_url', 'esc_url', ['is_safe' => ['html']]));
|
||||
|
||||
// WordPress head/footer output (captured via output buffering).
|
||||
$this->twig->addFunction(new TwigFunction('wp_head', function (): string {
|
||||
|
||||
+1816
-792
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1817
-791
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1729
-765
File diff suppressed because it is too large.
Load diff
+1770
-790
File diff suppressed because it is too large.
Load diff
+1822
-790
File diff suppressed because it is too large.
Load diff
+1776
-790
File diff suppressed because it is too large.
Load diff
+1802
-790
File diff suppressed because it is too large.
Load diff
+1802
-790
File diff suppressed because it is too large.
Load diff
+1766
-790
File diff suppressed because it is too large.
Load diff
+1786
-792
File diff suppressed because it is too large.
Load diff
+1772
-790
File diff suppressed because it is too large.
Load diff
+1592
-706
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -20,7 +20,9 @@
|
||||
*/
|
||||
function getPreferredTheme() {
|
||||
var stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
// Whitelist: only honour known-good values to prevent attribute injection
|
||||
// from a tampered localStorage (e.g. XSS-written value by another script).
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
return stored;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
|
||||
@@ -7,7 +7,7 @@ Description: A modern WordPress Block Theme built from scratch with Bootstrap 5.
|
||||
Requires at least: 6.7
|
||||
Tested up to: 6.7
|
||||
Requires PHP: 8.3
|
||||
Version: 1.0.7
|
||||
Version: 1.0.10
|
||||
License: GNU General Public License v2 or later
|
||||
License URI: http://www.gnu.org/licenses/gpl-2.0.html
|
||||
Text Domain: wp-bootstrap
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<strong class="small">
|
||||
{% if comment.author_url %}
|
||||
<a href="{{ esc_url(comment.author_url) }}" class="text-decoration-none text-body" rel="nofollow">
|
||||
{# author_url is pre-escaped with esc_url() in ContextBuilder #}
|
||||
<a href="{{ comment.author_url|raw }}" class="text-decoration-none text-body" rel="nofollow">
|
||||
{{ comment.author }}
|
||||
</a>
|
||||
{% else %}
|
||||
|
||||
Reference in new issue
Block a user