Compare commits

..

11 Commits

Author SHA1 Message Date
Dan Brown
446b4a7d3d Updated version and assets for release v25.12.9 2026-03-12 11:01:04 +00:00
Dan Brown
d335b49be0 Merge branch 'v25-12' into release 2026-03-12 10:59:00 +00:00
Dan Brown
5f5fea7c83 Deps: Bumped PHP packages before release 2026-03-12 10:52:12 +00:00
Dan Brown
6e7cc169d1 Preferences: Updated return redirect with better origin checks
As suggested by Alex Dan in their security report.
2026-03-10 18:31:51 +00:00
Dan Brown
6216c89f82 Packages: Updated PHP package versions 2026-03-10 17:48:12 +00:00
Dan Brown
404e67afbc Page Revisions: Added testing coverage to basic diffing 2026-03-10 17:47:07 +00:00
Dan Brown
6d64262a61 Revision Diffs: Added filtering post-diff render 2026-03-10 15:03:43 +00:00
Dan Brown
d9b9303a42 Updated version and assets for release v25.12.8 2026-02-27 10:29:04 +00:00
Dan Brown
50a7183b32 Merge branch 'v25-12' into release 2026-02-27 10:28:13 +00:00
Dan Brown
25ed242f61 Deps: Updated PHP package versions 2026-02-27 10:09:41 +00:00
Dan Brown
7aef0a48b3 Content: Updated filters to allow some required attributes
- Allows target attribute on links.
- Allows custom mention attribute on links.

Adds test case to cover these.
For #6034
2026-02-23 08:08:44 +00:00
9 changed files with 349 additions and 231 deletions

View File

@@ -12,6 +12,8 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
use Ssddanbrown\HtmlDiff\Diff;
@@ -101,12 +103,15 @@ class PageRevisionController extends Controller
$prev = $revision->getPreviousRevision();
$prevContent = $prev->html ?? '';
$diff = Diff::excecute($prevContent, $revision->html);
// TODO - Refactor PageContent so we can de-dupe these steps
$rawDiff = Diff::excecute($prevContent, $revision->html);
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
$filter = new HtmlContentFilter($filterConfig);
$diff = $filter->filterString($rawDiff);
$page->fill($revision->toArray());
// TODO - Refactor PageContent so we don't need to juggle this
$page->html = $revision->html;
$page->html = (new PageContent($page))->render();
$page->html = '';
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
return view('pages.revision', [

View File

@@ -167,14 +167,26 @@ abstract class Controller extends BaseController
/**
* Redirect to the URL provided in the request as a '_return' parameter.
* Will check that the parameter leads to a URL under the root path of the system.
* Will check that the parameter leads to a URL under the same origin as the application.
*/
protected function redirectToRequest(Request $request): RedirectResponse
{
$basePath = url('/');
$returnUrl = $request->input('_return') ?? $basePath;
if (!str_starts_with($returnUrl, $basePath)) {
// Only allow use of _return on requests where we expect CSRF to be active
// to prevent it potentially being used as an open redirect
$allowedMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
if (!in_array($request->getMethod(), $allowedMethods)) {
return redirect($basePath);
}
$intendedUrl = parse_url($returnUrl);
$baseUrl = parse_url($basePath);
$isSameOrigin = ($intendedUrl['host'] ?? '') === ($baseUrl['host'] ?? '')
&& ($intendedUrl['scheme'] ?? '') === ($baseUrl['scheme'] ?? '')
&& ($intendedUrl['port'] ?? 0) === ($baseUrl['port'] ?? 0);
if (!$isSameOrigin) {
return redirect($basePath);
}

View File

@@ -71,6 +71,8 @@ class ConfiguredHtmlPurifier
$config->set('Core.AllowHostnameUnderscore', true);
$config->set('CSS.AllowTricky', true);
$config->set('HTML.SafeIframe', true);
$config->set('HTML.TargetNoopener', false);
$config->set('HTML.TargetNoreferrer', false);
$config->set('Attr.EnableID', true);
$config->set('Attr.ID.HTML5', true);
$config->set('Output.FixInnerHTML', false);
@@ -141,6 +143,12 @@ class ConfiguredHtmlPurifier
'drawio-diagram',
'Number',
);
// Allow target="_blank" on links
$definition->addAttribute('a', 'target', 'Enum#_blank');
// Allow mention-ids on links
$definition->addAttribute('a', 'data-mention-user-id', 'Number');
}
public function purify(string $html): string

456
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
8e88c0fe2ea1b1bb500f6cd10cf3fcf04198e2d55fe71f292d091d4faa6eb7f3
e2acb4ea1edce1e2c9af0d7598a3de9ae1d44ff9e647f018f4f732ab5f560f9d

View File

@@ -478,4 +478,25 @@ HTML;
$resp->assertSee($expected, false);
}
}
public function test_allow_list_does_not_filter_cases()
{
$testCasesExpectedByInput = [
'<p><a href="https://example.com" target="_blank">New tab linkydoodle</a></p>',
'<p><a href="https://example.com/user/1" data-mention-user-id="5">@mentionusertext</a></p>',
'<details><summary>Hello</summary><p>Mydetailshere</p></details>',
];
config()->set('app.content_filtering', 'a');
$page = $this->entities->page();
$this->asEditor();
foreach ($testCasesExpectedByInput as $input) {
$page->html = $input;
$page->save();
$resp = $this->get($page->getUrl());
$resp->assertSee($input, false);
}
}
}

View File

@@ -47,6 +47,20 @@ class PageRevisionTest extends TestCase
$revisionView->assertSee('new revision content');
}
public function test_page_revision_preview_filters_html_content()
{
$this->asEditor();
$page = $this->entities->page();
$this->createRevisions($page, 1, ['name' => 'updated page', 'html' => '<script>dontwantthishere</script><style>dontwantthishere</style><p>expectthisthough</p>']);
$pageRevision = $page->revisions->last();
$this->createRevisions($page, 1, ['name' => 'updated page', 'html' => '<p>Updated content</p>']);
$revisionView = $this->get($page->getUrl() . '/revisions/' . $pageRevision->id);
$revisionView->assertStatus(200);
$revisionView->assertSee('expectthisthough');
$revisionView->assertDontSee('dontwantthishere');
}
public function test_page_revision_restore_updates_content()
{
$this->asEditor();
@@ -215,6 +229,34 @@ class PageRevisionTest extends TestCase
$html->assertElementContains('.item-list > .item-list-row:nth-child(2)', 'Changes');
}
public function test_revision_changes_view_shows_diff()
{
$this->asEditor();
$page = $this->entities->page();
$this->createRevisions($page, 1, ['name' => 'updated page', 'html' => '<p id="bkmrk-hello">Hello there dog</p>']);
$this->createRevisions($page, 1, ['name' => 'updated page', 'html' => '<p id="bkmrk-hello">Hello there cat</p>']);
$pageRevision = $page->revisions()->orderBy('id', 'desc')->first();
$revisionView = $this->get("{$page->getUrl()}/revisions/{$pageRevision->id}/changes");
$revisionView->assertStatus(200);
$revisionView->assertSee('<p id="bkmrk-hello">Hello there <del class="diffmod">dog</del><ins class="diffmod">cat</ins></p>', false);
}
public function test_revision_changes_view_filters_html_content()
{
$this->asEditor();
$page = $this->entities->page();
$html = '<script>dontwantthishere</script><style>dontwantthishere</style><p>expectthisthough</p>';
$this->createRevisions($page, 1, ['name' => 'updated page', 'html' => $html]);
$this->createRevisions($page, 1, ['name' => 'updated page', 'html' => $html]);
$pageRevision = $page->revisions()->orderBy('id', 'desc')->first();
$revisionView = $this->get("{$page->getUrl()}/revisions/{$pageRevision->id}/changes");
$revisionView->assertStatus(200);
$revisionView->assertSee('expectthisthough');
$revisionView->assertDontSee('dontwantthishere');
}
public function test_revision_restore_action_only_visible_with_permission()
{
$page = $this->entities->page();

View File

@@ -153,6 +153,26 @@ class UserPreferencesTest extends TestCase
->assertElementNotExists('.content-wrap .entity-list-item');
}
public function test_redirect_on_preference_change_checks_host()
{
$expectedByRedirect = [
'http://localhost/beans' => 'http://localhost/beans',
'https://localhost/beans' => 'http://localhost',
'http://localhost:9090/beans' => 'http://localhost',
'http://localhost.example.com/beans' => 'http://localhost',
'http://localhost@example.com/beans' => 'http://localhost',
];
$this->asEditor();
foreach ($expectedByRedirect as $url => $expected) {
$req = $this->patch("/preferences/change-view/bookshelf", [
'view' => 'grid',
'_return' => $url,
]);
$req->assertRedirect($expected);
}
}
public function test_update_code_language_favourite()
{
$editor = $this->users->editor();

View File

@@ -1 +1 @@
v25.12.7
v25.12.9