Compare commits

..

13 commits

Author SHA1 Message Date
El RIDO
8a77305838
Merge pull request #1885 from marouane-hassine/fix/1883-malformed-v2-json-500
fix: return "Invalid data." instead of HTTP 500 on malformed v2 payloads
2026-07-18 18:02:47 +02:00
marouanehassine
abd1418bc8 fix: reject malformed JSON API bodies instead of crashing
Address review feedback on #1885:

- reject valid JSON scalar bodies (number, bool, string) that decode
  without error but are not a usable parameter set, so they no longer
  trigger a type error further down (Request::getParams)
- drop the numeric coercion of `v` in Request::getData() and let the
  FormatV2 validator reject non-numeric versions, as suggested
- add RequestTest::testPostScalarJson coverage for scalar bodies
- align FormatV2Test assignments per StyleCI
- add CHANGELOG entry referencing #1883
2026-07-18 08:16:21 +02:00
marouanehassine
e80efda7c2 fix: return "Invalid data." instead of HTTP 500 on malformed v2 payloads
Under strict_types on PHP 8, malformed JSON API v2 payloads triggered
uncaught TypeErrors (HTTP 500) instead of the standard JSON error
response {"status":1,"message":"Invalid data."}:

- a non-numeric `v` reached `$data['v'] + 0` in Request::getData()
- a scalar `meta` reached `count($message['meta'])` in FormatV2::isValid()
- non-array adata[0] / non-string ct fields reached base64_decode(),
  strlen() and count() with the wrong types

Validate the types before the numeric coercion and before the
string/count operations, so all such inputs are rejected cleanly with
"Invalid data." instead of a fatal error. Both crashes are remotely
triggerable without authentication (availability only, no data exposure).

Adds FormatV2Test coverage for the malformed-type inputs.

Fixes #1883
2026-07-17 14:44:04 +02:00
El RIDO
886d4419d7
Merge pull request #1881 from PrivateBin/dompurify-3.4.12
update DOMpurify to 3.4.12
2026-07-16 18:03:42 +02:00
El RIDO
2f17c5f1d6
chore(deps): update DOMpurify to 3.4.12 2026-07-15 06:54:26 +02:00
El RIDO
360f2adeca
Merge pull request #1879 from PrivateBin/dependabot/github_actions/actions/setup-node-7
chore(deps): bump actions/setup-node from 6 to 7
2026-07-14 21:09:21 +02:00
El RIDO
b6f4278e07
Merge pull request #1878 from TowyTowy/fix/yourls-shorturl-null-fallback
fix: handle YOURLS 200 response without shorturl gracefully
2026-07-14 21:07:16 +02:00
dependabot[bot]
4a50fed678
chore(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 11:52:20 +00:00
TowyTowy
bb8b77d714 Use a string statusCode in the YourlsProxy test
YOURLS returns the status code as a string (see #1844); mirror that in
the regression test so it stays correct if the comparison ever changes.
2026-07-14 08:51:35 +02:00
TowyTowy
a0c0d154ee fix: handle YOURLS 200 response without shorturl gracefully
When YOURLS replies with statusCode 200 but no "shorturl" field,
YourlsProxy::_extractShortUrl() returned int 0 for its ?string return
type. Under the file's declare(strict_types=1) this raises an uncaught
TypeError, aborting paste creation instead of surfacing the intended
"Error parsing proxy response" message. Fall back to null (matching
ShlinkProxy) so the existing graceful error path is taken.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 23:02:26 +02:00
El RIDO
8504c3157c
Merge pull request #1876 from PrivateBin/dependabot/npm_and_yarn/js/ws-8.21.0
chore(deps): bump ws from 8.20.1 to 8.21.0 in /js
2026-07-11 12:25:07 +02:00
El RIDO
c03cd8f278
chore: prepare for next release 2026-07-11 11:47:08 +02:00
dependabot[bot]
f71fd1be95
chore(deps): bump ws from 8.20.1 to 8.21.0 in /js
Bumps [ws](https://github.com/websockets/ws) from 8.20.1 to 8.21.0.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.1...8.21.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 08:25:54 +00:00
16 changed files with 111 additions and 18 deletions

View file

@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20'

View file

@ -224,7 +224,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'

View file

@ -1,5 +1,10 @@
# PrivateBin version history
## 2.0.6 (not yet released)
* CHANGED: Upgrading libraries to: DOMpurify 3.4.12
* FIXED: Gracefully handle YOURLS replies with a 200 status code but no shorturl, instead of raising a TypeError
* FIXED: Return "Invalid data." instead of HTTP 500 on malformed v2 JSON payloads (#1883)
## 2.0.5 (2026-07-11)
* CHANGED: Show OS-specific copy hotkey hint (Cmd+c on Mac, Ctrl+c on others) (#1506)
* FIXED: Prevent browsers from rendering unsafe attachments like HTML in a new tab

View file

@ -15,7 +15,7 @@ require('./prettify');
global.prettyPrint = window.PR.prettyPrint;
global.prettyPrintOne = window.PR.prettyPrintOne;
global.showdown = require('./showdown-2.1.0');
global.DOMPurify = require('./purify-3.4.1');
global.DOMPurify = require('./purify-3.4.12');
global.baseX = require('./base-x-5.0.1').baseX;
global.Legacy = require('./legacy').Legacy;
require('./privatebin');

11
js/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "privatebin",
"version": "2.0.4",
"version": "2.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "privatebin",
"version": "2.0.4",
"version": "2.0.5",
"license": "zlib-acknowledgement",
"devDependencies": {
"@peculiar/webcrypto": "^1.5.0",
@ -3993,10 +3993,11 @@
"dev": true
},
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},

File diff suppressed because one or more lines are too long

2
js/purify-3.4.12.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -123,7 +123,7 @@ class Configuration
'js/legacy.js' => 'sha512-RQEo1hxpNc37i+jz/D9/JiAZhG8GFx3+SNxjYnI7jUgirDIqrCSj6QPAAZeaidditcWzsJ3jxfEj5lVm7ZwTRQ==',
'js/prettify.js' => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
'js/privatebin.js' => 'sha512-g58rPgrKnvxBDnT6lzYazdEfgY3XhRh7Yt7i1jkJw4oSvN0k/VPZ4zBSL/dCGfXGIOHqlqmX8DO5UFv2XYqsXg==',
'js/purify-3.4.1.js' => 'sha512-280a/Vb6fVFsYaeRrkuDp4EDmdYlt2XS+dlDEO/U9qljPrAraA2bIzHTNmP+9dpwPDDwTML+RS+h5iaagPwTzA==',
'js/purify-3.4.12.js' => 'sha512-Akf6HnAJZm0sWWWI4gp2GYff0NDnHUB02XJE5S7Hdq/Z5xtMjkuFacsDA8ZtViv1gi+onBxMhEMIaGyQeGxBng==',
'js/showdown-2.1.0.js' => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
'js/zlib-1.3.2.js' => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',
'js/zlib.js' => 'sha512-QOaEwssHqHRRcWJ2Un3Kl2Zhyprzl7T8zmsKN2FppFxW3VR+8UChYOx2iuL0HbXK42fuBWJm5PNQJxufulrt/w==',

View file

@ -56,7 +56,21 @@ class FormatV2
return false;
}
$cipherParams = $isComment ? $message['adata'] : $message['adata'][0];
$cipherParams = $isComment ? $message['adata'] : ($message['adata'][0] ?? null);
// Make sure the cipher parameters are a properly sized array.
if (!is_array($cipherParams) || count($cipherParams) < 8) {
return false;
}
// Make sure the ciphertext and the cipher parameters used in the
// string operations below are actually strings, so that malformed
// input yields "Invalid data." instead of a fatal type error.
if (!is_string($message['ct']) ||
!is_string($cipherParams[0] ?? null) ||
!is_string($cipherParams[1] ?? null)) {
return false;
}
// Make sure some fields are base64 data:
// - initialization vector
@ -119,6 +133,7 @@ class FormatV2
// require only the key 'expire' in the metadata of pastes
if (!$isComment && (
!is_array($message['meta']) ||
count($message['meta']) === 0 ||
!array_key_exists('expire', $message['meta']) ||
count($message['meta']) > 1

View file

@ -66,7 +66,7 @@ class YourlsProxy extends AbstractProxy
protected function _extractShortUrl(array $data): ?string
{
if ((int) ($data['statusCode'] ?? 0) === 200) {
return $data['shorturl'] ?? 0;
return $data['shorturl'] ?? null;
}
return null;
}

View file

@ -113,6 +113,11 @@ class Request
try {
$data = file_get_contents(self::$_inputStream);
$this->_params = Json::decode($data);
// a valid JSON scalar (number, bool or string) decodes
// without error, but is not a usable set of parameters
if (!is_array($this->_params)) {
$this->_params = array();
}
} catch (JsonException $e) {
// ignore error, $this->_params will remain empty
}
@ -189,8 +194,6 @@ class Request
foreach ($required_keys as $key) {
$data[$key] = $this->getParam($key, $key === 'v' ? 1 : '');
}
// forcing a cast to int or float
$data['v'] = $data['v'] + 0;
return $data;
}

View file

@ -66,7 +66,7 @@ if ($MARKDOWN) :
<?php
endif;
?>
<?php $this->_scriptTag('js/purify-3.4.1.js', 'defer'); ?>
<?php $this->_scriptTag('js/purify-3.4.12.js', 'defer'); ?>
<?php $this->_scriptTag('js/legacy.js', 'defer'); ?>
<?php $this->_scriptTag('js/privatebin.js', 'defer'); ?>
<!-- icon -->

View file

@ -50,7 +50,7 @@ if ($MARKDOWN) :
<?php
endif;
?>
<?php $this->_scriptTag('js/purify-3.4.1.js', 'defer'); ?>
<?php $this->_scriptTag('js/purify-3.4.12.js', 'defer'); ?>
<?php $this->_scriptTag('js/legacy.js', 'defer'); ?>
<?php $this->_scriptTag('js/privatebin.js', 'defer'); ?>
<!-- icon -->

View file

@ -72,4 +72,41 @@ class FormatV2Test extends TestCase
$paste = Helper::getPaste();
$this->assertFalse(FormatV2::isValid($paste), 'invalid meta key');
}
public function testFormatV2ValidatorRejectsMalformedTypes()
{
// malformed input must yield "false" (Invalid data) rather than a
// fatal type error under strict_types
$paste = Helper::getPastePost();
$paste['v'] = 'abc';
$this->assertFalse(FormatV2::isValid($paste), 'non-numeric version');
$paste = Helper::getPastePost();
$paste['v'] = array();
$this->assertFalse(FormatV2::isValid($paste), 'array version');
$paste = Helper::getPastePost();
$paste['ct'] = array('not', 'a', 'string');
$this->assertFalse(FormatV2::isValid($paste), 'non-string ciphertext');
$paste = Helper::getPastePost();
$paste['meta'] = 'not-an-array';
$this->assertFalse(FormatV2::isValid($paste), 'non-array meta');
$paste = Helper::getPastePost();
$paste['adata'][0] = 'not-an-array';
$this->assertFalse(FormatV2::isValid($paste), 'non-array cipher parameters');
$paste = Helper::getPastePost();
$paste['adata'][0] = array();
$this->assertFalse(FormatV2::isValid($paste), 'empty cipher parameters');
$paste = Helper::getPastePost();
$paste['adata'][0][0] = array();
$this->assertFalse(FormatV2::isValid($paste), 'non-string iv');
$comment = Helper::getCommentPost();
$comment['ct'] = 42;
$this->assertFalse(FormatV2::isValid($comment, true), 'non-string comment ciphertext');
}
}

View file

@ -158,6 +158,26 @@ class RequestTest extends TestCase
$this->assertEquals('create', $request->getOperation());
}
public function testPostScalarJson()
{
// a valid JSON scalar body (number, boolean or quoted string) must not
// be treated as a set of parameters, so that it is rejected cleanly
// instead of triggering a type error further down
foreach (array('1', '0.0', 'true', 'false', '"example"') as $scalar) {
$this->reset();
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
$file = Helper::createTempFile();
file_put_contents($file, $scalar);
Request::setInputStream($file);
$request = new Request;
unlink($file);
$this->assertTrue($request->isJsonApiCall(), 'is JSON API call');
$this->assertEquals('create', $request->getOperation());
$this->assertEquals('', $request->getParam('ct'), "scalar body {$scalar} yields no parameters");
}
}
public function testReadWithNegotiation()
{
$this->reset();

View file

@ -135,6 +135,19 @@ class YourlsProxyTest extends TestCase
$this->assertEquals($yourls->getError(), 'Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.');
}
public function testYourlsSuccessWithoutShortUrl()
{
// YOURLS may reply with statusCode 200 but without a shorturl field;
// this must be handled gracefully as an error instead of raising a
// TypeError (the method is declared to return ?string). YOURLS returns
// the status code as a string, so mirror that here.
file_put_contents($this->_mock_yourls_service, '{"statusCode":"200"}');
$yourls = new YourlsProxy($this->_conf, 'https://example.com/?foo#bar');
$this->assertTrue($yourls->isError());
$this->assertEquals($yourls->getError(), 'Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.');
}
public function testServerError()
{
// simulate some other server error that results in a non-JSON reply