From ce5f82c41c5303f64c61edd4b32bd58e72c9b5f7 Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sun, 14 Jun 2026 23:08:18 +0200 Subject: [PATCH 01/17] refactor: use dev version of Seablast Logger --- .github/workflows/polish-the-code.yml | 4 ++-- composer.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/polish-the-code.yml b/.github/workflows/polish-the-code.yml index 5fd7646..e86ac35 100644 --- a/.github/workflows/polish-the-code.yml +++ b/.github/workflows/polish-the-code.yml @@ -54,7 +54,7 @@ jobs: # Note: https://docs.github.com/en/actions/using-workflows/reusing-workflows The strategy property is not supported in any job that calls a reusable workflow. php-composer-unit-stan: - uses: WorkOfStan/seablast-actions/.github/workflows/php-composer-dependencies-reusable.yml@v0.2.9 + uses: WorkOfStan/seablast-actions/.github/workflows/php-composer-dependencies-reusable.yml@v0.2.10 with: # JSON php-version: '["7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"]' @@ -95,7 +95,7 @@ jobs: super-linter: needs: phpcs-phpcbf - uses: WorkOfStan/seablast-actions/.github/workflows/linter.yml@v0.2.9 + uses: WorkOfStan/seablast-actions/.github/workflows/linter.yml@v0.2.10 with: # exclude third-party code filter-regex-exclude: ".*/assets/uls/.*" diff --git a/composer.json b/composer.json index ec8ca5e..ac60e76 100644 --- a/composer.json +++ b/composer.json @@ -6,8 +6,8 @@ "php": ">=7.2 <8.6", "latte/latte": ">=2.10.8 <3.1", "nette/utils": "^3.2.10 || ^4.0.5 || ^4.1.0", - "seablast/interfaces": "^0.1.1", - "seablast/logger": "^1.0 || ^2.0.3", + "seablast/interfaces": "dev-main", + "seablast/logger": "dev-feature/refactor2605", "symfony/security-csrf": "^4.4.37 || ^5 || ^6 || ^7 || ^8", "tracy/tracy": "^2.9.8 || ^2.10.9 || ^2.11.0", "webmozart/assert": "^1.10.0 || ^2.1.5" From c37a1e4d20a2d182e1af5159a7bf622ec930a165 Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sat, 20 Jun 2026 18:05:32 +0200 Subject: [PATCH 02/17] feat: harden JSON API input handling --- .gitignore | 3 +- .htaccess | 5 +- CHANGELOG.md | 2 + CLAUDE.md | 4 + README.md | 1 + src/Apis/GenericRestApiJsonModel.php | 94 ++++++++++++++++++++- tests/GenericRestApiJsonModelTest.php | 116 ++++++++++++++++++++++++++ 7 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 tests/GenericRestApiJsonModelTest.php diff --git a/.gitignore b/.gitignore index a4c1419..f9f6708 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,11 @@ !/cache/.htaccess /log/* !/log/.htaccess -/.sass-cache/ +/.composer-cache /.php_cs.cache /.php-cs-fixer.cache /.phpunit.result.cache +/.sass-cache/ #disable private local files *.local.php diff --git a/.htaccess b/.htaccess index 7b52125..45492bc 100644 --- a/.htaccess +++ b/.htaccess @@ -9,6 +9,8 @@ RewriteRule ^src(/|$) - [R=404,L] RedirectMatch 404 \/tests\/ RedirectMatch 404 \/views\/ # hide these files +RedirectMatch 404 (^|/)composer\.(json|lock)$ +RedirectMatch 404 (^|/)\.phpunit\.result\.cache$ RedirectMatch 404 phpstan\.neon\.dist RedirectMatch 404 phpunit\.xml # hide files with these extensions @@ -17,4 +19,5 @@ RedirectMatch 404 \.neon$ RedirectMatch 404 \.sh$ RedirectMatch 404 \.yml$ # hide all the files in any directory that have no filename but only an extension (like .prettierignore) -RedirectMatch 404 /(\.[^.]+)$ +# hide all dotfiles in any directory, including multi-dot files like .phpunit.result.cache +RedirectMatch 404 (^|/)\.[^/]+$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d85106..07a10b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### `Security` in case of vulnerabilities +- Harden JSON API input handling with content-type and body-size checks before decoding, and hide Composer/root dot metadata through Apache rules. + ## [0.2.17.4] - 2026-05-03 feat: allow up to 200 records listed in admin view diff --git a/CLAUDE.md b/CLAUDE.md index b1d8d63..43ac8b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,8 @@ Important consequences: Current behavior: - requires `REQUEST_METHOD` in `Superglobals->server` +- for real HTTP input, requires JSON `Content-Type` before reading the body +- rejects real or injected JSON input over 1 MiB before decoding - reads JSON from `php://input`, or from `SeablastConstant::JSON_INPUT` when injected for tests - accepts only a decoded JSON object - requires `csrfToken` @@ -148,6 +150,8 @@ Default error behavior: - `400` for invalid JSON or wrong payload shape - `401` for missing or invalid CSRF token +- `413` for JSON request bodies over 1 MiB +- `415` for missing or non-JSON `Content-Type` Applications extending this class should call `parent::knowledge()` first and stop when it already returns `httpCode >= 400`. diff --git a/README.md b/README.md index e4de694..10e3f2b 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,7 @@ This feature applies to all fields with the same name across all tables (field n ## Security All JSON calls and form submits MUST contain `csrfToken` handed over to the view layer in the `$csrfToken` string latte variable. +JSON API calls using `GenericRestApiJsonModel` MUST send a JSON `Content-Type` header and stay within the default 1 MiB request body limit. ## Stack diff --git a/src/Apis/GenericRestApiJsonModel.php b/src/Apis/GenericRestApiJsonModel.php index e0e8855..34a2e0b 100644 --- a/src/Apis/GenericRestApiJsonModel.php +++ b/src/Apis/GenericRestApiJsonModel.php @@ -23,6 +23,9 @@ class GenericRestApiJsonModel implements SeablastModelInterface { use \Nette\SmartObject; + /** @var int Maximum JSON request body size in bytes */ + protected const JSON_INPUT_MAX_BYTES = 1048576; + /** @ var array Resulting knowledge. */ //todo move to SBdist//private $businessLogicResult; @@ -88,10 +91,21 @@ public function knowledge(): stdClass */ private function processInput(): void { + $jsonInputInjected = $this->configuration->exists(SeablastConstant::JSON_INPUT); + if (!$jsonInputInjected) { + if ($this->contentLengthExceedsLimit()) { + $this->rejectInput(413, 'JSON input exceeds the maximum allowed size.'); + return; + } + if (!$this->hasJsonContentType()) { + $this->rejectInput(415, 'Unsupported content type.'); + return; + } + } // Read JSON from standard input if not pre-prepared - $jsonInput = $this->configuration->exists(SeablastConstant::JSON_INPUT) + $jsonInput = $jsonInputInjected ? $this->configuration->getString(SeablastConstant::JSON_INPUT) - : file_get_contents('php://input'); + : $this->readJsonInput(); if (!is_string($jsonInput)) { Debugger::barDump(["Either JSON_INPUT or php://input isn't string", $jsonInput], 'ERROR on input'); Debugger::log("Either JSON_INPUT or php://input isn't string", ILogger::ERROR); @@ -99,6 +113,10 @@ private function processInput(): void $this->message = 'Invalid input'; return; } + if (strlen($jsonInput) > static::JSON_INPUT_MAX_BYTES) { + $this->rejectInput(413, 'JSON input exceeds the maximum allowed size.'); + return; + } $jsonDecoded = json_decode($jsonInput); Debugger::barDump($jsonDecoded, 'data json_decoded from php://input'); // Validate JSON input @@ -170,6 +188,78 @@ private function processInput(): void } } + /** + * Checks whether the announced request body size is too large. + * + * @return bool + */ + private function contentLengthExceedsLimit(): bool + { + if (!isset($this->superglobals->server['CONTENT_LENGTH'])) { + return false; + } + if (!is_scalar($this->superglobals->server['CONTENT_LENGTH'])) { + return false; + } + $contentLength = trim((string) $this->superglobals->server['CONTENT_LENGTH']); + if ($contentLength === '' || !ctype_digit($contentLength)) { + return false; + } + return (int) $contentLength > static::JSON_INPUT_MAX_BYTES; + } + + /** + * Checks whether request Content-Type is JSON. + * + * @return bool + */ + private function hasJsonContentType(): bool + { + $contentType = $this->superglobals->server['CONTENT_TYPE'] + ?? $this->superglobals->server['HTTP_CONTENT_TYPE'] + ?? ''; + if (!is_scalar($contentType)) { + return false; + } + $mediaTypeParts = explode(';', (string) $contentType, 2); + $mediaType = strtolower(trim($mediaTypeParts[0])); + if ($mediaType === 'application/json') { + return true; + } + return strpos($mediaType, '/') !== false && substr($mediaType, -5) === '+json'; + } + + /** + * Read only the maximum accepted JSON bytes plus one byte to detect overflow. + * + * @return string|false + */ + private function readJsonInput() + { + $stream = fopen('php://input', 'rb'); + if ($stream === false) { + return false; + } + $jsonInput = fread($stream, static::JSON_INPUT_MAX_BYTES + 1); + fclose($stream); + return $jsonInput; + } + + /** + * Populate a generic API validation error. + * + * @param int $httpCode + * @param string $message + * @return void + */ + private function rejectInput(int $httpCode, string $message): void + { + Debugger::barDump($message, 'ERROR on input'); + Debugger::log($message, ILogger::ERROR); + $this->httpCode = $httpCode; + $this->message = $message; + } + /** * Simple API response with httpCode and message. * diff --git a/tests/GenericRestApiJsonModelTest.php b/tests/GenericRestApiJsonModelTest.php new file mode 100644 index 0000000..1901418 --- /dev/null +++ b/tests/GenericRestApiJsonModelTest.php @@ -0,0 +1,116 @@ +knowledgeForInjectedJson('{}'); + + $this->assertSame(401, $knowledge->httpCode); + $this->assertSame('CSRF token missing', $knowledge->rest->message); + } + + public function testInjectedJsonOverLimitReturnsPayloadTooLarge(): void + { + $knowledge = $this->knowledgeForInjectedJson(str_repeat(' ', self::JSON_INPUT_MAX_BYTES + 1)); + + $this->assertSame(413, $knowledge->httpCode); + $this->assertSame('JSON input exceeds the maximum allowed size.', $knowledge->rest->message); + } + + public function testUnsupportedContentTypeReturnsUnsupportedMediaType(): void + { + $knowledge = $this->knowledgeForServer([ + 'CONTENT_TYPE' => 'text/plain', + ]); + + $this->assertSame(415, $knowledge->httpCode); + $this->assertSame('Unsupported content type.', $knowledge->rest->message); + } + + public function testMissingContentTypeReturnsUnsupportedMediaType(): void + { + $knowledge = $this->knowledgeForServer(); + + $this->assertSame(415, $knowledge->httpCode); + $this->assertSame('Unsupported content type.', $knowledge->rest->message); + } + + public function testStructuredJsonContentTypeWithParametersIsAccepted(): void + { + $knowledge = $this->knowledgeForServer([ + 'CONTENT_TYPE' => 'application/problem+json; charset=utf-8', + ]); + + $this->assertSame(400, $knowledge->httpCode); + $this->assertSame('Syntax error', $knowledge->rest->message); + } + + public function testContentLengthOverLimitReturnsPayloadTooLarge(): void + { + $knowledge = $this->knowledgeForServer([ + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => (string) (self::JSON_INPUT_MAX_BYTES + 1), + ]); + + $this->assertSame(413, $knowledge->httpCode); + $this->assertSame('JSON input exceeds the maximum allowed size.', $knowledge->rest->message); + } + + public function testMalformedJsonUnderLimitKeepsExistingParseError(): void + { + $knowledge = $this->knowledgeForInjectedJson('{'); + + $this->assertSame(400, $knowledge->httpCode); + $this->assertSame('Syntax error', $knowledge->rest->message); + } + + private function knowledgeForInjectedJson(string $jsonInput): stdClass + { + $configuration = new SeablastConfiguration(); + $configuration->setString(SeablastConstant::JSON_INPUT, $jsonInput); + + $model = new GenericRestApiJsonModel( + $configuration, + new Superglobals([], [], ['REQUEST_METHOD' => 'POST']) + ); + + return $model->knowledge(); + } + + /** + * @param array $server + */ + private function knowledgeForServer(array $server = []): stdClass + { + $model = new GenericRestApiJsonModel( + new SeablastConfiguration(), + new Superglobals([], [], array_merge(['REQUEST_METHOD' => 'POST'], $server)) + ); + + return $model->knowledge(); + } +} From d5fda3fc652a5ae32ed37eefe07f7d9ea7737b0f Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sat, 20 Jun 2026 18:15:10 +0200 Subject: [PATCH 03/17] fix: fix PHPStan type inference for the bounded JSON API input read length. --- CHANGELOG.md | 2 ++ README.md | 1 + index.php | 4 ---- src/Apis/GenericRestApiJsonModel.php | 4 +++- tests/GenericRestApiJsonModelTest.php | 29 ++++++++++++++------------- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a10b1..edc132f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### `Fixed` for any bugfixes +- Fix PHPStan type inference for the bounded JSON API input read length. + ### `Security` in case of vulnerabilities - Harden JSON API input handling with content-type and body-size checks before decoding, and hide Composer/root dot metadata through Apache rules. diff --git a/README.md b/README.md index 10e3f2b..7f7b6bc 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Total Downloads](https://img.shields.io/packagist/dt/seablast/seablast.svg)](https://packagist.org/packages/seablast/seablast) [![Latest Stable Version](https://img.shields.io/packagist/v/seablast/seablast.svg)](https://packagist.org/packages/seablast/seablast) +[![Polish the code](https://github.com/WorkOfStan/seablast/actions/workflows/polish-the-code.yml/badge.svg)](https://github.com/WorkOfStan/seablast/actions/workflows/polish-the-code.yml) This minimalist MVC framework added by [composer](https://getcomposer.org/) helps you to create a complex, yet easy to maintain, web application by configuration ONLY: diff --git a/index.php b/index.php index e0ce506..4b35cc3 100644 --- a/index.php +++ b/index.php @@ -34,10 +34,6 @@ $superglobals->setSession($_SESSION); // as only now the session started try { new SeablastView(new SeablastModel($controller, $superglobals)); -//} catch (\Seablast\Seablast\Exceptions\DbmsException $e) { -// // make sure that the database Tracy BarPanel is displayed when DbmsException is thrown -// $this->controller->getConfiguration()->showSqlBarPanel(); -// throw new Exceptions\DbmsException($e->getMessage(), $e->getCode(), $e); } catch (\PDOException $e) { // make sure that the database Tracy BarPanel with error is displayed when PDOException is thrown $setup->getConfiguration()->pdo()->indicateDatabaseError(); diff --git a/src/Apis/GenericRestApiJsonModel.php b/src/Apis/GenericRestApiJsonModel.php index 34a2e0b..4753a31 100644 --- a/src/Apis/GenericRestApiJsonModel.php +++ b/src/Apis/GenericRestApiJsonModel.php @@ -240,7 +240,9 @@ private function readJsonInput() if ($stream === false) { return false; } - $jsonInput = fread($stream, static::JSON_INPUT_MAX_BYTES + 1); + $readLength = static::JSON_INPUT_MAX_BYTES + 1; + Assert::greaterThanEq($readLength, 1); + $jsonInput = fread($stream, $readLength); fclose($stream); return $jsonInput; } diff --git a/tests/GenericRestApiJsonModelTest.php b/tests/GenericRestApiJsonModelTest.php index 1901418..814a3cc 100644 --- a/tests/GenericRestApiJsonModelTest.php +++ b/tests/GenericRestApiJsonModelTest.php @@ -29,16 +29,14 @@ public function testInjectedJsonUnderLimitReachesCsrfValidation(): void { $knowledge = $this->knowledgeForInjectedJson('{}'); - $this->assertSame(401, $knowledge->httpCode); - $this->assertSame('CSRF token missing', $knowledge->rest->message); + $this->assertRestResponse($knowledge, 401, 'CSRF token missing'); } public function testInjectedJsonOverLimitReturnsPayloadTooLarge(): void { $knowledge = $this->knowledgeForInjectedJson(str_repeat(' ', self::JSON_INPUT_MAX_BYTES + 1)); - $this->assertSame(413, $knowledge->httpCode); - $this->assertSame('JSON input exceeds the maximum allowed size.', $knowledge->rest->message); + $this->assertRestResponse($knowledge, 413, 'JSON input exceeds the maximum allowed size.'); } public function testUnsupportedContentTypeReturnsUnsupportedMediaType(): void @@ -47,16 +45,14 @@ public function testUnsupportedContentTypeReturnsUnsupportedMediaType(): void 'CONTENT_TYPE' => 'text/plain', ]); - $this->assertSame(415, $knowledge->httpCode); - $this->assertSame('Unsupported content type.', $knowledge->rest->message); + $this->assertRestResponse($knowledge, 415, 'Unsupported content type.'); } public function testMissingContentTypeReturnsUnsupportedMediaType(): void { $knowledge = $this->knowledgeForServer(); - $this->assertSame(415, $knowledge->httpCode); - $this->assertSame('Unsupported content type.', $knowledge->rest->message); + $this->assertRestResponse($knowledge, 415, 'Unsupported content type.'); } public function testStructuredJsonContentTypeWithParametersIsAccepted(): void @@ -65,8 +61,7 @@ public function testStructuredJsonContentTypeWithParametersIsAccepted(): void 'CONTENT_TYPE' => 'application/problem+json; charset=utf-8', ]); - $this->assertSame(400, $knowledge->httpCode); - $this->assertSame('Syntax error', $knowledge->rest->message); + $this->assertRestResponse($knowledge, 400, 'Syntax error'); } public function testContentLengthOverLimitReturnsPayloadTooLarge(): void @@ -76,16 +71,22 @@ public function testContentLengthOverLimitReturnsPayloadTooLarge(): void 'CONTENT_LENGTH' => (string) (self::JSON_INPUT_MAX_BYTES + 1), ]); - $this->assertSame(413, $knowledge->httpCode); - $this->assertSame('JSON input exceeds the maximum allowed size.', $knowledge->rest->message); + $this->assertRestResponse($knowledge, 413, 'JSON input exceeds the maximum allowed size.'); } public function testMalformedJsonUnderLimitKeepsExistingParseError(): void { $knowledge = $this->knowledgeForInjectedJson('{'); - $this->assertSame(400, $knowledge->httpCode); - $this->assertSame('Syntax error', $knowledge->rest->message); + $this->assertRestResponse($knowledge, 400, 'Syntax error'); + } + + private function assertRestResponse(stdClass $knowledge, int $httpCode, string $message): void + { + $this->assertSame($httpCode, $knowledge->httpCode); + $rest = $knowledge->rest; + $this->assertInstanceOf(stdClass::class, $rest); + $this->assertSame($message, $rest->message); } private function knowledgeForInjectedJson(string $jsonInput): stdClass From 6c2586a63b40cd148518c82c867023e9bbbb0fc1 Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sat, 27 Jun 2026 23:49:21 +0200 Subject: [PATCH 04/17] feat: expose only true boolean-like admin column names ### Changed - Introduce AGENTS.md and CLAUDE.md DRY combination ### `Fixed` for any bugfixes - Validate admin boolean-like columns against stored values before exposing boolean metadata. - Expose only true boolean-like admin column names to the table view model. --- .github/workflows/polish-the-code.yml | 11 +++--- .gitignore | 6 +-- CLAUDE.md => AGENTS.md | 4 +- CHANGELOG.md | 4 ++ src/Admin/TableViewModel.php | 57 +++++++++++++++++++++++---- 5 files changed, 63 insertions(+), 19 deletions(-) rename CLAUDE.md => AGENTS.md (99%) diff --git a/.github/workflows/polish-the-code.yml b/.github/workflows/polish-the-code.yml index e86ac35..1cb8ccd 100644 --- a/.github/workflows/polish-the-code.yml +++ b/.github/workflows/polish-the-code.yml @@ -33,11 +33,12 @@ jobs: # Limit the running time timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: # checkout PR HEAD commit ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 # required for merge-base check + # required for merge-base check + fetch-depth: 0 persist-credentials: false - uses: commit-check/commit-check-action@v2 env: @@ -54,7 +55,7 @@ jobs: # Note: https://docs.github.com/en/actions/using-workflows/reusing-workflows The strategy property is not supported in any job that calls a reusable workflow. php-composer-unit-stan: - uses: WorkOfStan/seablast-actions/.github/workflows/php-composer-dependencies-reusable.yml@v0.2.10 + uses: WorkOfStan/seablast-actions/.github/workflows/php-composer-dependencies-reusable.yml@v0.2.11 with: # JSON php-version: '["7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"]' @@ -95,11 +96,11 @@ jobs: super-linter: needs: phpcs-phpcbf - uses: WorkOfStan/seablast-actions/.github/workflows/linter.yml@v0.2.10 + uses: WorkOfStan/seablast-actions/.github/workflows/linter.yml@v0.2.11 with: # exclude third-party code filter-regex-exclude: ".*/assets/uls/.*" runs-on: "ubuntu-latest" - # todo fix fix assets/seablast.css and then allow again CSS validation + # todo fix assets/seablast.css and then allow again CSS validation validate-css: false validate-spell-codespell: true diff --git a/.gitignore b/.gitignore index f9f6708..1bc0af0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,13 +4,11 @@ /log/* !/log/.htaccess /.composer-cache -/.php_cs.cache -/.php-cs-fixer.cache -/.phpunit.result.cache +/.*.cache /.sass-cache/ #disable private local files -*.local.php +*.local.* #disable composer-managed libraries /vendor/ diff --git a/CLAUDE.md b/AGENTS.md similarity index 99% rename from CLAUDE.md rename to AGENTS.md index 43ac8b7..42161c7 100644 --- a/CLAUDE.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# CLAUDE.md +# AGENTS.md This file is the maintainer and integration guide for `seablast/seablast`. It is written for two audiences: @@ -353,7 +353,7 @@ If a change affects framework behavior that applications depend on, update all o - `README.md` for public usage guidance - `CHANGELOG.md` for release notes -- `CLAUDE.md` for maintainer and integration detail +- `AGENTS.md` for maintainer and integration detail - tests where the behavior is asserted That keeps Seablast honest for both maintainers and downstream applications. diff --git a/CHANGELOG.md b/CHANGELOG.md index edc132f..ae7321d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### `Changed` for changes in existing functionality +- Introduce AGENTS.md and CLAUDE.md DRY combination + ### `Deprecated` for soon-to-be removed features ### `Removed` for now removed features @@ -18,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### `Fixed` for any bugfixes - Fix PHPStan type inference for the bounded JSON API input read length. +- Validate admin boolean-like columns against stored values before exposing boolean metadata. +- Expose only true boolean-like admin column names to the table view model. ### `Security` in case of vulnerabilities diff --git a/src/Admin/TableViewModel.php b/src/Admin/TableViewModel.php index c76a3fa..8696e98 100644 --- a/src/Admin/TableViewModel.php +++ b/src/Admin/TableViewModel.php @@ -68,6 +68,11 @@ private function splitStringByFirstPipe($string): ?array return array(substr($string, 0, $position), substr($string, $position + 1)); } + private function escapeIdentifier(string $identifier): string + { + return '`' . str_replace('`', '``', $identifier) . '`'; + } + /// compare to $columnTypes = $this->adminHelper->columnTypes( // $this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE) // ); @@ -118,16 +123,22 @@ private function columnTypes(string $tableName): array $result = $this->configuration->mysqli()->query($query); $columnTypes = []; + $booleanLikeColumnIndexes = []; if ($result && is_object($result)) { while ($row = $result->fetch_assoc()) { $columnType = isset($row['COLUMN_TYPE']) ? strtolower((string) $row['COLUMN_TYPE']) : ''; $dataType = isset($row['DATA_TYPE']) ? strtolower((string) $row['DATA_TYPE']) : ''; - - $row['IS_BOOLEAN_LIKE'] = ( + Assert::scalar($row['COLUMN_NAME']); + $columnName = (string) $row['COLUMN_NAME']; + $isBooleanLike = ( $columnType === 'tinyint(1)' || $columnType === 'tinyint(1) unsigned' || $columnType === 'bit(1)' // || $dataType === 'boolean' || $dataType === 'bool' - ) ? 1 : 0; + ); + $row['IS_BOOLEAN_LIKE'] = $isBooleanLike ? 1 : 0; + if ($isBooleanLike) { + $booleanLikeColumnIndexes[$columnName] = count($columnTypes); + } $columnTypes[] = $row; } @@ -135,6 +146,31 @@ private function columnTypes(string $tableName): array $result->free(); } + if (!empty($booleanLikeColumnIndexes)) { + $checks = []; + foreach (array_keys($booleanLikeColumnIndexes) as $i => $columnName) { + $checks[] = 'COALESCE(MIN(' . $this->escapeIdentifier($columnName) + . ' IS NULL OR ' . $this->escapeIdentifier($columnName) + . ' IN (0,1)), 1) AS ' . $this->escapeIdentifier('b' . $i); + } + + $booleanCheckResult = $this->configuration->mysqli()->query( + 'SELECT ' . implode(', ', $checks) + . ' FROM ' . $this->escapeIdentifier($fullTableName) + ); + + if ($booleanCheckResult && is_object($booleanCheckResult)) { + $booleanCheckRow = $booleanCheckResult->fetch_assoc(); + if (is_array($booleanCheckRow)) { + foreach (array_keys($booleanLikeColumnIndexes) as $i => $columnName) { + $columnTypes[$booleanLikeColumnIndexes[$columnName]]['IS_BOOLEAN_LIKE'] = + !empty($booleanCheckRow['b' . $i]) ? 1 : 0; + } + } + $booleanCheckResult->free(); + } + } + return $columnTypes; } @@ -185,10 +221,14 @@ public function knowledge(): stdClass // dev $foreignKeys = $this->foreignKeys($this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE)); Debugger::barDump($foreignKeys, 'foreignKeys'); - Debugger::barDump( - $this->booleanLikeColumns($this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE)), - 'boolean like' - ); + $booleanLikeColumnNames = []; + foreach ($this->booleanLikeColumns( + $this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE) + ) as $columnName => $isBooleanLike) { + if ($isBooleanLike) { + $booleanLikeColumnNames[] = $columnName; + } + } // Get order and conditions from GET parameters $order = isset($this->superglobals->get['order']) ? $this->superglobals->get['order'] : ''; $conditions = []; @@ -268,7 +308,7 @@ public function knowledge(): stdClass . $this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE) . '` ' . $sql // todo allow paging - . ' LIMIT 0,200' + . ' LIMIT 0,250' ); $data = []; // Fetch each row and add it to the $data array @@ -283,6 +323,7 @@ public function knowledge(): stdClass 'columns' => $columns, 'editable' => $cols['edit'] ?? [], 'conditionDetails' => $conditionDetails, + 'booleanLike' => $booleanLikeColumnNames, // data 'table' => $data, ]; From ebb244e01307cbf5df1809af21a055079225572a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:50:40 +0000 Subject: [PATCH 05/17] chore(phpcf): apply PHP Code Beautifier fixes automatically on 2026-06-27 21:50:40 UTC --- src/Admin/TableViewModel.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Admin/TableViewModel.php b/src/Admin/TableViewModel.php index 8696e98..e7289b7 100644 --- a/src/Admin/TableViewModel.php +++ b/src/Admin/TableViewModel.php @@ -222,9 +222,11 @@ public function knowledge(): stdClass $foreignKeys = $this->foreignKeys($this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE)); Debugger::barDump($foreignKeys, 'foreignKeys'); $booleanLikeColumnNames = []; - foreach ($this->booleanLikeColumns( - $this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE) - ) as $columnName => $isBooleanLike) { + foreach ( + $this->booleanLikeColumns( + $this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE) + ) as $columnName => $isBooleanLike + ) { if ($isBooleanLike) { $booleanLikeColumnNames[] = $columnName; } From 0932d3d606158eca7aad08f214ce7f4404a9e56b Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sun, 28 Jun 2026 00:34:13 +0200 Subject: [PATCH 06/17] chore: bump seablast/interfaces to v0.1.3 --- CLAUDE.md | 8 ++++++++ composer.json | 2 +- src/Admin/TableViewModel.php | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..af0c7ea --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +@AGENTS.md + +## Claude Code + +- Use plan mode before large refactors. +- Prefer editing existing files over generating new abstractions unless justified. diff --git a/composer.json b/composer.json index ac60e76..3fce9b6 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "php": ">=7.2 <8.6", "latte/latte": ">=2.10.8 <3.1", "nette/utils": "^3.2.10 || ^4.0.5 || ^4.1.0", - "seablast/interfaces": "dev-main", + "seablast/interfaces": "^0.1.3", "seablast/logger": "dev-feature/refactor2605", "symfony/security-csrf": "^4.4.37 || ^5 || ^6 || ^7 || ^8", "tracy/tracy": "^2.9.8 || ^2.10.9 || ^2.11.0", diff --git a/src/Admin/TableViewModel.php b/src/Admin/TableViewModel.php index e7289b7..10b5340 100644 --- a/src/Admin/TableViewModel.php +++ b/src/Admin/TableViewModel.php @@ -221,6 +221,7 @@ public function knowledge(): stdClass // dev $foreignKeys = $this->foreignKeys($this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE)); Debugger::barDump($foreignKeys, 'foreignKeys'); + // Boolean like columns uses a checkbox in the admin.latte $booleanLikeColumnNames = []; foreach ( $this->booleanLikeColumns( From ae0906cb05c9b48fca3de1220285a65a1ad8dcfc Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sun, 28 Jun 2026 01:07:55 +0200 Subject: [PATCH 07/17] feat: expose string[] SeablastConstant::ADMIN_BOOLEAN_FIELDS --- CHANGELOG.md | 4 ++++ src/Admin/AdminModel.php | 4 +++- src/Admin/TableViewModel.php | 11 +++++++---- src/SeablastConstant.php | 4 ++++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7321d..748f84a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +feat: expose string[] SeablastConstant::ADMIN_BOOLEAN_FIELDS + ### `Added` for new features +- expose string[] SeablastConstant::ADMIN_BOOLEAN_FIELDS + ### `Changed` for changes in existing functionality - Introduce AGENTS.md and CLAUDE.md DRY combination diff --git a/src/Admin/AdminModel.php b/src/Admin/AdminModel.php index eb74fe0..e703d84 100644 --- a/src/Admin/AdminModel.php +++ b/src/Admin/AdminModel.php @@ -59,7 +59,7 @@ public function knowledge(): stdClass $insertable = false; } else { $knowledge = (array) $this->tableContent->knowledge(); - $table = $knowledge['table']; + $table = $knowledge['table']; $columns = $knowledge['columns']; $editable = $knowledge['editable']; $conditionDetails = $knowledge['conditionDetails']; @@ -71,6 +71,8 @@ public function knowledge(): stdClass $this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE), $this->adminHelper->getAllowedTables(SeablastConstant::ADMIN_TABLE_INSERT_ROW) ); + // TableViewModel produce it, admin.latte consumes it + $this->configuration->setArrayString(SeablastConstant::ADMIN_BOOLEAN_FIELDS, $knowledge['booleanLike']); } return (object) [ diff --git a/src/Admin/TableViewModel.php b/src/Admin/TableViewModel.php index 10b5340..599570a 100644 --- a/src/Admin/TableViewModel.php +++ b/src/Admin/TableViewModel.php @@ -214,13 +214,10 @@ private function foreignKeys(string $tableName): array public function knowledge(): stdClass { $cols = $this->adminHelper->getAllowedColumns(); - Debugger::barDump($cols, 'cols'); $columns = array_merge($cols['view'] ?? [], $cols['edit'] ?? []); - Debugger::barDump($columns, 'columns'); - + // dev $foreignKeys = $this->foreignKeys($this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE)); - Debugger::barDump($foreignKeys, 'foreignKeys'); // Boolean like columns uses a checkbox in the admin.latte $booleanLikeColumnNames = []; foreach ( @@ -232,6 +229,12 @@ public function knowledge(): stdClass $booleanLikeColumnNames[] = $columnName; } } + if ($this->configuration->getInt(SeablastConstant::SB_LOGGING_LEVEL) >= 5) { // Log as severity DEBUG + Debugger::barDump($cols, 'cols'); + Debugger::barDump($columns, 'columns'); + Debugger::barDump($foreignKeys, 'foreignKeys'); // dev + Debugger::barDump($booleanLikeColumnNames, 'booleanLike'); + } // Get order and conditions from GET parameters $order = isset($this->superglobals->get['order']) ? $this->superglobals->get['order'] : ''; $conditions = []; diff --git a/src/SeablastConstant.php b/src/SeablastConstant.php index 868f34e..0cb6347 100644 --- a/src/SeablastConstant.php +++ b/src/SeablastConstant.php @@ -178,6 +178,10 @@ class SeablastConstant * @var string int[] groupId */ public const USER_GROUPS = 'SB:USER_GROUPS'; + /** + * @var string string[] list of fields that are boolean, hence should be represented by a checkbox + */ + public const ADMIN_BOOLEAN_FIELDS = 'SB:ADMIN_BOOLEAN_FIELDS'; /** * @var string string[] list of fields that contain color, so that input type color is better than textarea */ From 7f2b6fbc1575de3b4bec37d05464385456792b43 Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sun, 28 Jun 2026 01:19:59 +0200 Subject: [PATCH 08/17] fix: assert string[] --- src/Admin/AdminModel.php | 2 ++ src/Admin/TableViewModel.php | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Admin/AdminModel.php b/src/Admin/AdminModel.php index e703d84..9126b4f 100644 --- a/src/Admin/AdminModel.php +++ b/src/Admin/AdminModel.php @@ -71,6 +71,8 @@ public function knowledge(): stdClass $this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE), $this->adminHelper->getAllowedTables(SeablastConstant::ADMIN_TABLE_INSERT_ROW) ); + /* x* @phpstan-ignore staticMethod.alreadyNarrowedType */ + Assert::allInteger($knowledge['booleanLike']); // TableViewModel produce it, admin.latte consumes it $this->configuration->setArrayString(SeablastConstant::ADMIN_BOOLEAN_FIELDS, $knowledge['booleanLike']); } diff --git a/src/Admin/TableViewModel.php b/src/Admin/TableViewModel.php index 599570a..3d90718 100644 --- a/src/Admin/TableViewModel.php +++ b/src/Admin/TableViewModel.php @@ -229,7 +229,7 @@ public function knowledge(): stdClass $booleanLikeColumnNames[] = $columnName; } } - if ($this->configuration->getInt(SeablastConstant::SB_LOGGING_LEVEL) >= 5) { // Log as severity DEBUG + if ($this->configuration->getInt(SeablastConstant::SB_LOGGING_LEVEL) >= 4) { // Dump when severity INFO Debugger::barDump($cols, 'cols'); Debugger::barDump($columns, 'columns'); Debugger::barDump($foreignKeys, 'foreignKeys'); // dev From 232b179a917ea4d11b38ac0a0d3e391fc8fc259a Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sun, 28 Jun 2026 01:22:33 +0200 Subject: [PATCH 09/17] fix: add missing Webmozart\Assert\Assert --- src/Admin/AdminHelper.php | 2 +- src/Admin/AdminModel.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Admin/AdminHelper.php b/src/Admin/AdminHelper.php index 13b95c3..a420a26 100644 --- a/src/Admin/AdminHelper.php +++ b/src/Admin/AdminHelper.php @@ -79,7 +79,7 @@ public function getAllowedTables(string $permission): array $tables = array_merge($tables, $this->configuration->getArrayString($permission . $suffix)); } } - if ($this->configuration->getInt(SeablastConstant::SB_LOGGING_LEVEL) >= 5) { // Log as severity DEBUG + if ($this->configuration->getInt(SeablastConstant::SB_LOGGING_LEVEL) >= 5) { // Dump when severity DEBUG Debugger::barDump($tables, 'List of tables with permission: ' . $permission); } return $tables; diff --git a/src/Admin/AdminModel.php b/src/Admin/AdminModel.php index 9126b4f..e219e91 100644 --- a/src/Admin/AdminModel.php +++ b/src/Admin/AdminModel.php @@ -9,6 +9,7 @@ use Seablast\Seablast\SeablastModelInterface; use Seablast\Seablast\Superglobals; use stdClass; +use Webmozart\Assert\Assert; /** * Retrieve items from database From 551c52750be3230c3167cb6ad4a7b0883dcfe7cb Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sun, 28 Jun 2026 01:24:35 +0200 Subject: [PATCH 10/17] fix: fix a typo --- src/Admin/AdminModel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Admin/AdminModel.php b/src/Admin/AdminModel.php index e219e91..493c739 100644 --- a/src/Admin/AdminModel.php +++ b/src/Admin/AdminModel.php @@ -73,7 +73,7 @@ public function knowledge(): stdClass $this->adminHelper->getAllowedTables(SeablastConstant::ADMIN_TABLE_INSERT_ROW) ); /* x* @phpstan-ignore staticMethod.alreadyNarrowedType */ - Assert::allInteger($knowledge['booleanLike']); + Assert::allString($knowledge['booleanLike']); // TableViewModel produce it, admin.latte consumes it $this->configuration->setArrayString(SeablastConstant::ADMIN_BOOLEAN_FIELDS, $knowledge['booleanLike']); } From a81d7bdffaf0c4bd3a38d6c82c5ae904333d7f19 Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sun, 28 Jun 2026 01:26:31 +0200 Subject: [PATCH 11/17] fix: add assertion --- src/Admin/AdminModel.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Admin/AdminModel.php b/src/Admin/AdminModel.php index 493c739..766464b 100644 --- a/src/Admin/AdminModel.php +++ b/src/Admin/AdminModel.php @@ -72,7 +72,8 @@ public function knowledge(): stdClass $this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE), $this->adminHelper->getAllowedTables(SeablastConstant::ADMIN_TABLE_INSERT_ROW) ); - /* x* @phpstan-ignore staticMethod.alreadyNarrowedType */ + Assert::isArray($knowledge['booleanLike']); + /* x* @phpstan-ignore staticMethod.alreadyNarrowedType */ Assert::allString($knowledge['booleanLike']); // TableViewModel produce it, admin.latte consumes it $this->configuration->setArrayString(SeablastConstant::ADMIN_BOOLEAN_FIELDS, $knowledge['booleanLike']); From 7306afea08b7f86398eaba8f8125345f6e01afec Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sun, 28 Jun 2026 01:38:34 +0200 Subject: [PATCH 12/17] chore: bump to seablast-logger 2606b --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3fce9b6..5becac5 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "latte/latte": ">=2.10.8 <3.1", "nette/utils": "^3.2.10 || ^4.0.5 || ^4.1.0", "seablast/interfaces": "^0.1.3", - "seablast/logger": "dev-feature/refactor2605", + "seablast/logger": "dev-feature/refactor2606b", "symfony/security-csrf": "^4.4.37 || ^5 || ^6 || ^7 || ^8", "tracy/tracy": "^2.9.8 || ^2.10.9 || ^2.11.0", "webmozart/assert": "^1.10.0 || ^2.1.5" From 4b84ab938247f8a91763baecd191cfa34c40c83b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:41:41 +0000 Subject: [PATCH 13/17] chore(phpcf): apply PHP Code Beautifier fixes automatically on 2026-06-27 23:41:41 UTC --- src/Admin/AdminModel.php | 2 +- src/Admin/TableViewModel.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Admin/AdminModel.php b/src/Admin/AdminModel.php index 766464b..f72f385 100644 --- a/src/Admin/AdminModel.php +++ b/src/Admin/AdminModel.php @@ -73,7 +73,7 @@ public function knowledge(): stdClass $this->adminHelper->getAllowedTables(SeablastConstant::ADMIN_TABLE_INSERT_ROW) ); Assert::isArray($knowledge['booleanLike']); - /* x* @phpstan-ignore staticMethod.alreadyNarrowedType */ + /* x* @phpstan-ignore staticMethod.alreadyNarrowedType */ Assert::allString($knowledge['booleanLike']); // TableViewModel produce it, admin.latte consumes it $this->configuration->setArrayString(SeablastConstant::ADMIN_BOOLEAN_FIELDS, $knowledge['booleanLike']); diff --git a/src/Admin/TableViewModel.php b/src/Admin/TableViewModel.php index 3d90718..936a766 100644 --- a/src/Admin/TableViewModel.php +++ b/src/Admin/TableViewModel.php @@ -215,7 +215,7 @@ public function knowledge(): stdClass { $cols = $this->adminHelper->getAllowedColumns(); $columns = array_merge($cols['view'] ?? [], $cols['edit'] ?? []); - + // dev $foreignKeys = $this->foreignKeys($this->configuration->getString(SeablastConstant::APP_SELECTED_TABLE)); // Boolean like columns uses a checkbox in the admin.latte @@ -232,7 +232,7 @@ public function knowledge(): stdClass if ($this->configuration->getInt(SeablastConstant::SB_LOGGING_LEVEL) >= 4) { // Dump when severity INFO Debugger::barDump($cols, 'cols'); Debugger::barDump($columns, 'columns'); - Debugger::barDump($foreignKeys, 'foreignKeys'); // dev + Debugger::barDump($foreignKeys, 'foreignKeys'); // dev Debugger::barDump($booleanLikeColumnNames, 'booleanLike'); } // Get order and conditions from GET parameters From 117397dff41ce078b9fa108730db12328c5f283d Mon Sep 17 00:00:00 2001 From: WorkOfStan Date: Sun, 28 Jun 2026 11:51:29 +0200 Subject: [PATCH 14/17] fix: harden CDN script loading with SRI - bump: Seablast Logger to v2.0.6 - security: Harden CDN script loading with SRI and document that hashes are tied to exact CDN response bytes. --- AGENTS.md | 16 ++++++++++ CHANGELOG.md | 30 ++++++++++++++----- composer.json | 2 +- tests/TemplateSecurityTest.php | 55 ++++++++++++++++++++++++++++++++++ views/BlueprintWeb.latte | 2 +- views/admin.latte | 2 +- 6 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 tests/TemplateSecurityTest.php diff --git a/AGENTS.md b/AGENTS.md index 42161c7..e020966 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,6 +189,22 @@ The layout also: - loads `assets/scripts/seablast-bridge.js` - optionally includes Seablast I18n ULS assets when the i18n flag is active +### Template CDN Security + +External scripts loaded from CDN must use Subresource Integrity (SRI) and +`crossorigin="anonymous"`. + +SRI hashes are tied to the exact URL and response bytes that the browser loads, +not only to the library name and version. For example, the official jQuery SRI +snippet for `https://code.jquery.com/jquery-3.7.1.min.js` does not match the +Google CDN URL currently used by `BlueprintWeb.latte`: + +- `https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js` + +If a CDN URL changes, recompute the SRI hash for the new URL from the raw +downloaded bytes. Do not compute it from decoded text, because even subtle byte +differences make the browser reject the script. + ## Authentication and Authorization If `SeablastConstant::SB_IDENTITY_MANAGER` is configured, the controller instantiates the class with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 748f84a..49c238f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,29 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -feat: expose string[] SeablastConstant::ADMIN_BOOLEAN_FIELDS - ### `Added` for new features -- expose string[] SeablastConstant::ADMIN_BOOLEAN_FIELDS - ### `Changed` for changes in existing functionality -- Introduce AGENTS.md and CLAUDE.md DRY combination - ### `Deprecated` for soon-to-be removed features ### `Removed` for now removed features ### `Fixed` for any bugfixes +### `Security` in case of vulnerabilities + +## [0.2.17.5] - 2026-06-28 + +feat: expose string[] SeablastConstant::ADMIN_BOOLEAN_FIELDS + +### Added + +- expose string[] SeablastConstant::ADMIN_BOOLEAN_FIELDS + +### Changed + +- Introduce AGENTS.md and CLAUDE.md DRY combination +- TableViewModel.php: change limit from 200 to 250 records to be listed in admin view +- bump: Seablast Logger to v2.0.6 + +### Fixed + - Fix PHPStan type inference for the bounded JSON API input read length. - Validate admin boolean-like columns against stored values before exposing boolean metadata. - Expose only true boolean-like admin column names to the table view model. -### `Security` in case of vulnerabilities +### Security - Harden JSON API input handling with content-type and body-size checks before decoding, and hide Composer/root dot metadata through Apache rules. +- Harden CDN script loading with SRI and document that hashes are tied to exact CDN response bytes. ## [0.2.17.4] - 2026-05-03 @@ -532,7 +545,8 @@ SeablastMysqli error logging improved, HTTPS identified - **model returns knowledge()** - a nice Under construction page -[Unreleased]: https://github.com/WorkOfStan/seablast/compare/v0.2.17.4...HEAD?w=1 +[Unreleased]: https://github.com/WorkOfStan/seablast/compare/v0.2.17.5...HEAD?w=1 +[0.2.17.5]: https://github.com/WorkOfStan/seablast/compare/v0.2.17.4...v0.2.17.5?w=1 [0.2.17.4]: https://github.com/WorkOfStan/seablast/compare/v0.2.17.3...v0.2.17.4?w=1 [0.2.17.3]: https://github.com/WorkOfStan/seablast/compare/v0.2.17.2...v0.2.17.3?w=1 [0.2.17.2]: https://github.com/WorkOfStan/seablast/compare/v0.2.17.1...v0.2.17.2?w=1 diff --git a/composer.json b/composer.json index 5becac5..72a9536 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "latte/latte": ">=2.10.8 <3.1", "nette/utils": "^3.2.10 || ^4.0.5 || ^4.1.0", "seablast/interfaces": "^0.1.3", - "seablast/logger": "dev-feature/refactor2606b", + "seablast/logger": "^2.0.6", "symfony/security-csrf": "^4.4.37 || ^5 || ^6 || ^7 || ^8", "tracy/tracy": "^2.9.8 || ^2.10.9 || ^2.11.0", "webmozart/assert": "^1.10.0 || ^2.1.5" diff --git a/tests/TemplateSecurityTest.php b/tests/TemplateSecurityTest.php new file mode 100644 index 0000000..a0294ff --- /dev/null +++ b/tests/TemplateSecurityTest.php @@ -0,0 +1,55 @@ +assertNotFalse($templateFiles, 'Should be able to list bundled Latte templates.'); + + $expectedIntegrityByUrl = [ + 'https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js' => 'sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs', + 'https://cdn.jsdelivr.net/gh/e3rd/WebHotkeys@0.9.4/WebHotkeys.js?register' => 'sha384-VtSHOatDaywsjcaoV86liUBBl28v5GV/w+ee6ls5ZXHWpbjiI2QuMc/r+JEYDILa', + ]; + $externalScriptTagCount = 0; + + foreach ($templateFiles as $templateFile) { + $template = file_get_contents($templateFile); + $this->assertNotFalse($template, sprintf('Should be able to read %s.', $templateFile)); + + preg_match_all('/]*\bsrc="(https:\/\/[^"]+)")[^>]*>/i', $template, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + ++$externalScriptTagCount; + $scriptTag = $match[0]; + $scriptUrl = $match[1]; + $this->assertSame( + 1, + preg_match('/\bintegrity="sha384-[A-Za-z0-9+\/=]+"/', $scriptTag), + sprintf('External script tag in %s must use sha384 SRI: %s', basename($templateFile), $scriptTag) + ); + $this->assertStringContainsString( + 'crossorigin="anonymous"', + $scriptTag, + sprintf('External script tag in %s must use anonymous CORS: %s', basename($templateFile), $scriptTag) + ); + if (array_key_exists($scriptUrl, $expectedIntegrityByUrl)) { + $this->assertStringContainsString( + sprintf('integrity="%s"', $expectedIntegrityByUrl[$scriptUrl]), + $scriptTag, + sprintf('External script tag in %s must use the expected SRI hash: %s', basename($templateFile), $scriptTag) + ); + } + } + } + + $this->assertGreaterThan(0, $externalScriptTagCount, 'Should find bundled external script tags to validate.'); + } +} diff --git a/views/BlueprintWeb.latte b/views/BlueprintWeb.latte index c02ed81..5e5dd31 100644 --- a/views/BlueprintWeb.latte +++ b/views/BlueprintWeb.latte @@ -20,7 +20,7 @@ ]; {* jQuery is required if 'I18n:SHOW_LANGUAGE_SELECTOR' and for admin.latte, therefore it's linked by default. If you want ultraminimalist blueprint without jQuery, use your own within your app *} - {* MUST be before send-auth-token and other scripts referred to within mainblock *} + {* MUST be before send-auth-token and other scripts referred to within mainblock *} {* so that seablast objects are ready before app code *}