CallBackery
view release on metacpan or search on metacpan
0.62.0 2026-10-29 17:40:14 +0100 Tobias Oetiker <tobi@oetiker.ch>
- Restoring a configuration backup ran its database part through the
sqlite3 command line tool, so a crafted backup could run commands and
read or write files on the server. The database part is now replayed
as plain SQL: shell commands, ATTACH and full-text tables with a Perl
tokenizer are refused, ordinary full-text tables still restore.
(CVE-2026-118992)
- The secret that signs session cookies came from a generator with only
about 4 billion possible values, so one cookie was enough to find the
secret and sign in as any user. On startup a .secret file made by the
old generator is replaced with 32 random bytes, which logs everyone out
once; an empty one is replaced too, and a secret written by hand is
kept. (CVE-2026-118986)
- Taking a configuration backup left a world-readable copy of the whole
configuration database, password hashes and stored secrets included,
in /tmp/cbdump<pid>. Backups no longer write this file; delete any
leftover /tmp/cbdump* files. (CVE-2026-118990)
- User passwords were stored as unsalted HMAC-SHA1, so equal passwords
had equal hashes and a leaked user table was quick to crack. Passwords
are now stored as salted scrypt, in the format the HIN Access Gateway
uses, and may contain any Unicode character; an old hash still logs in
and is replaced at that login. Each login attempt, also with an unknown
name, now costs one scrypt check, and Crypt::ScryptKDF is a new
prerequisite. (CVE-2026-13576)
- A configuration backup used the password itself as the key, with no
salt and no integrity check: "pw" and "pwx" opened the same backup, and
anyone who knew the password could change one unnoticed. New backups
use a key derived from the password with PBKDF2 and a random salt, and
a changed backup is refused with "Wrong password!". Old backups still
restore with a warning in the log; releases before this one cannot
restore new backups. (CVE-2026-118991)
- Every logged-in user received their own password hash from the server,
and users without the admin right also received the admin-only user
note, in the Users table and the user edit form as well. Neither is
sent any more. (CVE-2026-118996)
- A session cookie stayed valid forever, also after logout. The new
BACKEND setting max_session_age ends a session the given number of
seconds after the login; without it sessions do not expire, as before.
(CVE-2026-118987)
0.58.3 2026-07-30 16:11:29 +0200 Tobias Oetiker <tobi@oetiker.ch>
- Config: restoring a configuration blob replaced the configuration
database file, giving it a new inode. Every connection open at the
time stayed attached to the old, now unlinked file: it went on
reading the pre-restore configuration and failed on its first write
with SQLITE_READONLY_DBMOVED, which SQLite reports as "attempt to
write a readonly database". With a prefork server and background
helpers that is most of the processes on the machine. The
replacement database is now built beside the live one and copied in
with SQLite's online backup API, so the inode is preserved and open
connections simply see the restored data. If the write lock cannot
be had within 30 seconds the restore now gives up with an error and
leaves the database untouched, rather than restoring it half way.
0.58.2 2026-07-20 12:54:27 +0200 Tobias Oetiker <tobi@oetiker.ch>
- Frontend ui.plugin.Form: make sure trigger load does not overwrite
fix for automated testing which fills forms very quickly
0.58.1 2026-07-16 08:39:18 +0200 Tobias Oetiker <tobi@oetiker.ch>
see Song.pm for example
* 121c4ab - (HEAD -> login-log) log login acctivity (4 minutes ago) <Tobias Oetiker>
* bf1b222 - (origin/master, origin/HEAD, master) fix generator to work with mojo8 (3 months ago) <Tobias Oetiker>
* 4087cfe - trying to get travis going again (3 months ago) <Tobias Oetiker>
* fc77dca - fix some regressions (3 months ago) <Tobias Oetiker>
* 7c715da - fix userform syntax (3 months ago) <Tobias Oetiker>
* dc01dcb - teach Promises to callbackery (3 months ago) <Tobias Oetiker>
* 35bcb56 - 0.13.8 (8 months ago) <Tobias Oetiker>
* f0cb691 - remove stray 0 at the end of the sqlite dump (8 months ago) <Tobias Oetiker>
* 043a79c - (tag: v0.13.7) use database dump in the backup file not the raw db file (8 months ago) <Tobias Oetiker>
* 6dc7875 - (tag: v0.13.4) fix spelling of instanciation ... it is instantiation (8 months ago) <Tobias Oetiker>
* 60c4ec8 - (tag: v0.13.3) must load CallBackery::User (9 months ago) <Tobias Oetiker>
* 2fad13b - (tag: v0.13.2) limit width of note column in forms (9 months ago) <Tobias Oetiker>
* a732cd7 - (tag: v0.13.1) fix call to userObject (9 months ago) <Tobias Oetiker>
* 4878b5b - (tag: v0.13.0) Fighting circular references (9 months ago) <Tobias Oetiker>
* 7547c30 - update PERL_MODULES (10 months ago) <Tobias Oetiker>
* 7d871b9 - (tag: v0.12.9) fix deprecated route syuntax (no more braces) (10 months ago) <Tobias Oetiker>
* d22bf7e - update template PERL_MODULES (10 months ago) <Tobias Oetiker>
* 36e18d1 - (tag: v0.12.8) updated dependncy to require latest qooxdoo plugin (10 months ago) <Tobias Oetiker>
* 71ba760 - widen tests (10 months ago) <Tobias Oetiker>
lib/CallBackery/Config.pm view on Meta::CPAN
sub unpack16 {
my $self = shift;
my $string = shift;
my $len = substr($string,0,16);
if ( $len !~ /^[0-9a-f]{16}$/ or hex($len) > length($string)-16 ){
die mkerror(3844,trm("Wrong password!"));
}
return substr($string,16,hex($len));
}
# PBKDF2-HMAC-SHA256 rounds for the backup key; part of the CBK2 format
my $BLOB_KDF_ROUNDS = 100_000;
sub _randomBytes ($self,$n) {
open my $fh, '<:raw', '/dev/urandom';
read($fh, my $bytes, $n) == $n or croak "short read from /dev/urandom";
return $bytes;
}
sub _blobKeys ($self,$password,$salt) {
$password = encode('UTF-8',$password // '');
lib/CallBackery/Config.pm view on Meta::CPAN
$crypt->set_iv($iv);
my $blob = 'CBK2'.$salt.$iv.$crypt->encrypt($self->pack16($zipData));
return $blob.hmac_sha256($blob,$macKey);
}
# how long to keep trying when someone else holds a lock on the config
# database; package scoped so that tests can shorten it
our $RESTORE_BUSY_TIMEOUT_MS = 30_000;
# Build a replacement config database with $builder and then copy it over the
# live one with SQLite's online backup API, so that the database keeps its
# inode.
#
# Replacing the file instead (the way this used to work) leaves every long
# lived handle in the system attached to the old, now unlinked inode: the
# config daemon and its workers, the application server, and any helper that
# happens to be running at the time. Those handles go on reading stale
# configuration and fail on their first write with SQLITE_READONLY_DBMOVED,
# which SQLite reports as "attempt to write a readonly database".
#
# The staging database lives next to the config database rather than in a
lib/CallBackery/Config.pm view on Meta::CPAN
no autodie;
unlink glob $staging.'*';
use autodie;
$builder->($staging);
chmod 0600, $staging;
my $dbh = DBI->connect("dbi:SQLite:dbname=$cfgDb",'','',{
RaiseError => 1,
PrintError => 0,
AutoCommit => 1,
});
# the backup runs the destination's busy handler while it waits for
# the write lock; on top of that we retry, because a backup that
# starts while another connection sits in a read transaction gives up
# rather than waiting
$dbh->sqlite_busy_timeout($RESTORE_BUSY_TIMEOUT_MS);
my $deadline = Time::HiRes::time() + $RESTORE_BUSY_TIMEOUT_MS / 1000;
my ($busy,$tries) = (undef,0);
while (1) {
$busy = undef;
last if eval { $dbh->sqlite_backup_from_file($staging) };
$busy = $@ || $dbh->errstr || 'unknown error';
last if Time::HiRes::time() >= $deadline;
# first attempt and then roughly every five seconds, so that a
# long wait is visible without a log line per retry
$self->log->warn("Config database busy, retrying restore: $busy")
if $tries++ % 50 == 0;
Time::HiRes::sleep(0.1);
}
$dbh->disconnect;
# giving up here is safe: the live database has not been touched, so
lib/CallBackery/Config.pm view on Meta::CPAN
unlink glob $staging.'*';
die $err if $err;
return;
}
# DBD::SQLite registers an FTS3/FTS4 tokenizer called "perl" on every
# connection; a table using it calls the Perl sub named in its definition
# whenever the table is used. A restored schema must not name it.
sub _refusePerlTokenizer ($self,$dbh) {
my $schema = $dbh->selectcol_arrayref(q{SELECT sql FROM sqlite_master WHERE sql IS NOT NULL});
die mkerror(3846,trm("The backup contains a table with a Perl tokenizer"))
if grep { /tokeni[sz]e\W*perl/i } @$schema;
return;
}
=head2 $cfg->restoreConfigBlob(configBlob)
retore the confguration state
A blob in the format used before CBK2 has no authentication; it is still
restored, with a warning, and should be replaced by a new backup.
=cut
sub restoreConfigBlob {
my $self = shift;
my $config = shift;
my $password = shift;
require Archive::Zip;
if ($config =~ /^CBK2/ and length($config) % 16 == 4) {
require Crypt::Rijndael;
my ($salt,$iv) = (substr($config,4,16),substr($config,20,16));
my ($encKey,$macKey) = $self->_blobKeys($password,$salt);
my $mac = substr($config,-32,32,'');
die mkerror(3844,trm("Wrong password!"))
unless secure_compare($mac,hmac_sha256($config,$macKey));
my $crypt = Crypt::Rijndael->new($encKey,Crypt::Rijndael::MODE_CBC());
$crypt->set_iv($iv);
$config = $self->unpack16($crypt->decrypt(substr($config,36)));
}
else {
$self->log->warn("Restoring a backup in the old, unauthenticated format; take a new backup to replace it");
my $crypt = $self->getCrypt($password);
$config = $self->unpack16($crypt->decrypt($config));
}
my $user = $self->app->userObject->new(app=>$self->app,userId=>'__CONFIG', log=>$self->log);
open my $fh ,'<', \$config;
my $zip = Archive::Zip->new();
$zip->readFromFileHandle($fh);
my %stateFileCache;
for my $member ($zip->members){
lib/CallBackery/GuiPlugin/Abstract.pm view on Meta::CPAN
=cut
has mayAnonymous => sub {
return 0;
};
=head2 stateFiles
A list of files that contain the state of the settings configured by
this plugin this is used both for backup purposes and to replicate the
settings to a second installation.
=cut
has stateFiles => sub {
[];
};
=head2 unconfigureFiles
lib/CallBackery/User.pm view on Meta::CPAN
has log => sub ($self) {
$self->controller ? $self->controller->log : $self->app->log;
}, weak => 1;
=head2 $self->userId
By default the userId is numeric and represents a user account. For system tasks, it gets set to alphabetic identifiers.
The following alphabetic identifiers do exist:
__CONSOLE when running in the config console mode
__CONFIG for backup and restore tasks
=cut
=head2 userId
return the user id if the session user is valid.
t/config-blob-auth.t view on Meta::CPAN
setupTestConfig();
my $t = Test::Mojo->new('CallBackery');
my $cfg = $t->app->config;
my $db = $t->app->database;
$db->setConfigValue('blobProbe','saved');
my $blob = $cfg->getConfigBlob('hunter2');
isnt $cfg->getConfigBlob('hunter2'), $blob,
'two backups of the same data are not identical';
$db->setConfigValue('blobProbe','changed');
ok !eval { $cfg->restoreConfigBlob($blob,'hunter2x'); 1 },
'a password that only adds the old padding character is refused';
like "$@", qr/Wrong password/, 'with the wrong password error';
my $tampered = $blob;
substr($tampered,-1,1) ^= "\x01";
ok !eval { $cfg->restoreConfigBlob($tampered,'hunter2'); 1 },
'a modified backup is refused';
is $db->getConfigValue('blobProbe'), 'changed', 'and nothing was restored';
$cfg->restoreConfigBlob($blob,'hunter2');
is $db->getConfigValue('blobProbe'), 'saved', 'the right password restores';
# a backup made before the format change still restores, with a warning
require Archive::Zip;
my $zip = Archive::Zip->new;
$zip->addString("CREATE TABLE legacy (x);\n",'{DATABASEDUMP}');
open my $fh, '>', \my $zipData;
$zip->writeToFileHandle($fh,0);
close $fh;
my @warn;
$t->app->log->level('warn');
$t->app->log->on(message => sub { push @warn, $_[2] if $_[1] eq 'warn' });
$cfg->restoreConfigBlob($cfg->getCrypt('hunter2')->encrypt($cfg->pack16($zipData)),'hunter2');
ok scalar(grep { /old, unauthenticated format/ } @warn),
'an old-format backup is restored with a warning to replace it';
done_testing();
t/config-blob-tmpfile.t view on Meta::CPAN
setupTestConfig();
my $t = Test::Mojo->new('CallBackery');
my $cfg = $t->app->config;
my $db = $t->app->database;
unlink "/tmp/cbdump$$";
plan skip_all => "cannot remove /tmp/cbdump$$" if -e "/tmp/cbdump$$";
$db->setConfigValue('dumpProbe','before');
my $blob = $cfg->getConfigBlob('secret');
ok !-e "/tmp/cbdump$$", 'the backup leaves no database dump in /tmp';
$db->setConfigValue('dumpProbe','after');
$cfg->restoreConfigBlob($blob,'secret');
is $db->getConfigValue('dumpProbe'), 'before', 'the backup still restores';
done_testing();
t/config-restore-safe.t view on Meta::CPAN
"PRAGMA writable_schema=ON;\nINSERT INTO sqlite_schema(type,name,tbl_name,rootpage,sql) VALUES('table','v','v',0,'"
.($perlFts =~ s/'/''/gr)."');\nPRAGMA writable_schema=OFF;\nINSERT INTO v VALUES ('x');\n") {
ok !eval { $cfg->restoreConfigBlob($blob->('{DATABASEDUMP}',"CREATE TABLE probe (x);\n$sql"),'secret'); 1 },
"a dump with '".($sql =~ s/\n.*//sr)."' is refused";
}
is_deeply [glob "$dir/*"], [], 'and it ran no command and created no file';
# a full text index, as sqlite3 .dump writes it, still restores
$db->mojoSqlDb->query('CREATE VIRTUAL TABLE ftsprobe USING fts4(body)');
$db->mojoSqlDb->query(q{INSERT INTO ftsprobe VALUES ('hello world')});
my $backup = $cfg->getConfigBlob('secret');
$db->mojoSqlDb->query('DROP TABLE ftsprobe');
ok eval { $cfg->restoreConfigBlob($backup,'secret'); 1 }, 'a backup with a full text table restores' or diag $@;
is $db->mojoSqlDb->query(q{SELECT count(*) AS n FROM ftsprobe WHERE ftsprobe MATCH 'world'})->hash->{n}, 1,
'and the full text search works';
my $rawDb = sub {
my $sql = shift;
my $raw = tempdir(CLEANUP => 1).'/raw.db';
my $dbh = DBI->connect("dbi:SQLite:dbname=$raw",'','',{RaiseError => 1});
$dbh->do('CREATE TABLE plain (x)');
if ($sql) {
$dbh->do('PRAGMA writable_schema=ON');
( run in 1.609 second using v1.01-cache-2.11-cpan-3f962061493 )