CallBackery

 view release on metacpan or  search on metacpan

CHANGES  view on Meta::CPAN

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)

CHANGES  view on Meta::CPAN

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>

CHANGES  view on Meta::CPAN

   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 )