Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Build context for docker/Dockerfile.prod (repo root). Keeps the
# production image reproducible from environment variables instead of
# accidentally baking in whatever a contributor's local dev stack wrote
# to disk.
#
# web/config.php is the load-bearing exclusion here: docker/php/dev-*
# generates it for the local `./sbpp.sh up` stack (DB_HOST=db, dev
# creds) and it is gitignored but NOT excluded from the docker build
# context by default. Without this file, `docker build -f
# docker/Dockerfile.prod .` copies that dev config.php straight into
# the image (Dockerfile.prod's builder stage does `COPY web/
# /build/web/` with no filtering), so every container started from the
# image already has a "config.php present" sentinel pointing at the
# dev stack's internal hostname 'db' -- the prod entrypoint's
# render_config step sees the file already exists and never writes the
# real DATABASE_URL / DB_* values, regardless of what the operator
# configures. Symptom: SQLSTATE[HY000] [2002] getaddrinfo for db
# failed, reproducible on a freshly created service with correct env
# vars because the bug ships inside the image, not in any runtime
# volume.
#
# Also exclude editor / backup suffixes (config.php.bak, .old, …). Those
# often carry the same DB + JWT secrets and are NOT denied by a bare
# `<Files "config.php">` Apache rule. Keep config.php.template — it is
# the documented blank for tarball / non-Docker installs.
web/config.php
web/config.php.*
!web/config.php.template

# Host-side env files (repo root or under web/). Never meaningful inside
# the runtime image; a stray `web/.env` would otherwise COPY in with
# credentials.
.env
.env.*
**/.env
**/.env.*

# Regenerated by the builder stage's `composer install`; shipping the
# host's (possibly dev-only, possibly stale) vendor tree would both
# bloat the build context and risk skew from what composer.lock pins.
web/includes/vendor/

# Runtime-writable directories the Dockerfile pre-creates empty and
# the compose file bind-mounts volumes over. Never meaningful to ship
# from the host.
web/cache/
web/templates_c/
web/demos/

# Never ship VCS metadata or host-side tooling artifacts into the
# build context.
.git/
.github/
docs/
game/
node_modules/
web/node_modules/
web/tests/e2e/node_modules/
*.log
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ services:
# `web/../docker/...` resolves naturally there; this mount keeps
# the local `./sbpp.sh test` runner symmetric with CI.
- ./docker:/var/www/html/docker:ro
# `.dockerignore` lives at the repo root (Docker build context).
# Mount it so DockerIgnoreSecretsTest can read it the same way
# ProdApacheConfigTest reads `docker/apache/sbpp-prod.conf`.
- ./.dockerignore:/var/www/html/.dockerignore:ro
# Read-only mounts of the docs tree + the two root-level markdown
# files (AGENTS.md / CHANGELOG.md) for the same reason as the
# docker/ mount above: file-shape integration tests under
Expand Down
10 changes: 5 additions & 5 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ tests under `web/tests/integration/` can read production-only configs
(`docker/apache/sbpp-prod.conf`, `docker/Dockerfile.prod`, etc.) the
runtime panel never touches; CI's `actions/checkout@v4` pulls the full
repo, so this mount keeps the local `./sbpp.sh test` runner symmetric
with the CI gate. The `./docs` tree, `AGENTS.md`, and `CHANGELOG.md`
are mounted read-only at the same level for the same reason — gates
like `DocsUpgradeLinkRegressionTest` (#1474) verify that
panel-side deep-links to `sbpp.github.io` still resolve against the
matching docs source file on disk.
with the CI gate. The `./docs` tree, `AGENTS.md`, `CHANGELOG.md`, and
`.dockerignore` are mounted read-only at the same level for the same
reason — gates like `DocsUpgradeLinkRegressionTest` (#1474) and
`DockerIgnoreSecretsTest` verify panel-side deep-links and build-context
exclusions against the matching source files on disk.

## Common tasks

Expand Down
11 changes: 8 additions & 3 deletions docker/apache/sbpp-prod.conf
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,15 @@
</DirectoryMatch>

# `config.php` is the install-state sentinel. It carries DB credentials,
# the JWT secret key, and the Steam API key. Never serve it.
<Files "config.php">
# the JWT secret key, and the Steam API key. Deny the live file AND
# common editor / backup suffixes (`config.php.bak`, `.old`, `.save`,
# `.swp`, …) plus `config.php.template` (placeholder values only, but
# still not a browser asset). Basename-only `<FilesMatch>` — the
# `config.php` prefix does not collide with any published asset under
# `web/scripts/` or `web/themes/default/js/`.
<FilesMatch "(?i)^config\.php(\..+)?$">
Require all denied
</Files>
</FilesMatch>

# `composer.json` / `composer.lock` are checked-in but should never be
# fetched (they reveal exact dependency versions for vulnerability
Expand Down
58 changes: 58 additions & 0 deletions docker/php/prod-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,59 @@ validate_identifiers() {
die "DB_CHARSET='${DB_CHARSET:-}' must match [A-Za-z0-9_]+."
;;
esac

# SB_SECRET_KEY is NOT validated here. Env may be stale or unset while
# an existing config.php carries the real key (or the reverse). The
# effective key PHP will load is validated after render_config via
# validate_effective_secret_key. Pre-write env checks live inside
# render_config so a bad env value cannot be persisted into a fresh
# config.php.
}

# ---------------------------------------------------------------------------
# SB_SECRET_KEY gate (shared by render_config + validate_effective_*)
# ---------------------------------------------------------------------------
#
# HMAC-SHA256 needs >= 32 decoded bytes. JWT::signingKeyFromSecret is the
# single PHP-side source of truth (invalid base64 AND short-but-valid
# base64). Pass the candidate via SB_SECRET_KEY_CHECK so shell quoting
# cannot mangle the secret.
assert_sb_secret_key() {
secret="$1"
context="$2"
if [ -z "$secret" ]; then
die "SB_SECRET_KEY is empty (${context}). Generate one with: openssl rand -base64 47."
fi
if ! SB_SECRET_KEY_CHECK="$secret" php -r '
require "/var/www/html/web/includes/vendor/autoload.php";
\Sbpp\Auth\JWT::signingKeyFromSecret((string) getenv("SB_SECRET_KEY_CHECK"));
' >/dev/null 2>&1; then
die "SB_SECRET_KEY must be base64 that decodes to at least 32 bytes (${context}). Generate one with: openssl rand -base64 47. Then set SB_SECRET_KEY / fix config.php and restart."
fi
}

# After render_config, config.php is the install-state sentinel and the
# panel's request-time source of truth for SB_SECRET_KEY (env is unset
# before apache starts). Validate THAT value — not getenv — so a stale
# invalid env cannot block a healthy persisted install, and a corrected
# env cannot mask a bad key still sitting in config.php.
validate_effective_secret_key() {
log "validating effective SB_SECRET_KEY from ${SBPP_CONFIG_PATH}"
if [ ! -s "${SBPP_CONFIG_PATH}" ]; then
die "${SBPP_CONFIG_PATH} missing after render_config — cannot validate JWT signing key."
fi
if ! php -r '
require "/var/www/html/web/includes/vendor/autoload.php";
define("IN_SB", true);
require getenv("SBPP_CONFIG_PATH");
if (!defined("SB_SECRET_KEY")) {
fwrite(STDERR, "SB_SECRET_KEY constant missing\n");
exit(1);
}
\Sbpp\Auth\JWT::signingKeyFromSecret((string) SB_SECRET_KEY);
' >/dev/null 2>&1; then
die "Effective SB_SECRET_KEY in ${SBPP_CONFIG_PATH} is not usable (must be base64 decoding to at least 32 bytes). Fix or delete the file, set SB_SECRET_KEY, and restart. Generate with: openssl rand -base64 47."
fi
}

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -506,6 +559,10 @@ render_config() {
SB_SECRET_KEY="$(openssl rand -base64 47 | tr -d '\n')"
log "step 4: minted fresh SB_SECRET_KEY (47-byte base64) — persist by re-reading from this file or set SB_SECRET_KEY env var"
export SB_SECRET_KEY
else
# Fail before writing so a bad env value cannot become a sticky
# install-state sentinel that every restart re-loads.
assert_sb_secret_key "$SB_SECRET_KEY" "environment before writing config.php"
fi

# Single-quote string literals; escape `'` and `\` to defend the
Expand Down Expand Up @@ -920,6 +977,7 @@ main() {
configure_apache # step 2
wait_for_db # step 3
render_config # step 4
validate_effective_secret_key # step 4b — key PHP will actually load
first_boot_install # step 5
run_pending_migrations # step 6
strip_install_dirs # step 7
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ Common failures and fixes:
| `step 4: minted fresh SB_SECRET_KEY` on every restart. | You haven't set `SB_SECRET_KEY` in `.env`. The first boot wrote a randomly-generated key into the writable layer's `config.php`, but a container recreation rebuilds the layer fresh. Set the env var to persist. |
| Healthcheck flips to `unhealthy` repeatedly. | Run `curl https://<host>/health.php` to see the failure reason. If it's a DB connect error, the panel and DB containers have lost their network. `docker compose ... restart` usually resolves it. |
| Panel renders but Steam OpenID login fails. | `STEAMAPIKEY` is empty or wrong. Set it in `.env`; bring the panel back with `docker compose ... up -d`. |
| Login fatals with `CannotDecodeContent` / "invalid base64" / a plain `SB_SECRET_KEY must be base64 that decodes to at least 32 bytes` page (or the entrypoint dies with the same text). | The key PHP loads must be base64 decoding to at least 32 bytes. On Docker, `config.php` wins once it exists (the entrypoint validates that file, not a stale env var). Generate with `openssl rand -base64 47`, set `SB_SECRET_KEY` (or fix/delete `config.php`), restart. Rotating the key logs every admin out. |
| Apache logs `mod_remoteip` warnings about `RemoteIPInternalProxy`. | The `SBPP_TRUSTED_PROXIES` value isn't a valid CIDR. The entrypoint passes the value through verbatim; check for typos. |

For panel-level errors (login fails after Steam OAuth, "Driver not
Expand Down
70 changes: 66 additions & 4 deletions web/includes/Auth/JWT.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,16 @@
namespace Sbpp\Auth;

use DateTimeImmutable;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Encoding\CannotDecodeContent;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Signer\InvalidKeyProvided;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Validation\Constraint\IdentifiedBy;
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\ValidationData;

final class JWT
{
Expand Down Expand Up @@ -54,9 +53,72 @@ public static function validate(Token $token): bool
return $config->validator()->validate($token, ...$constrains);
}

/**
* Minimum decoded length for {@see SB_SECRET_KEY}. HMAC-SHA256
* needs a 256-bit key; shorter values decode as base64 but fail
* later inside Lcobucci's signer.
*/
public const MIN_SECRET_BYTES = 32;

/**
* Decode an operator-supplied SB_SECRET_KEY into a signing key.
*
* Public so tests can pin the invalid-key operator message without
* redefining the SB_SECRET_KEY constant mid-process. Decodes once,
* then rejects keys shorter than {@see MIN_SECRET_BYTES}.
*/
public static function signingKeyFromSecret(string $secret): InMemory
{
try {
$key = InMemory::base64Encoded($secret);
} catch (CannotDecodeContent|InvalidKeyProvided $e) {
self::failInvalidSecretKey($e);
}

if (strlen($key->contents()) < self::MIN_SECRET_BYTES) {
self::failInvalidSecretKey(new \InvalidArgumentException(
'decoded SB_SECRET_KEY is shorter than ' . self::MIN_SECRET_BYTES . ' bytes',
));
}

return $key;
}

private static function getConfig(): Configuration
{
return Configuration::forSymmetricSigner(new Sha256(), InMemory::base64Encoded(SB_SECRET_KEY));
return Configuration::forSymmetricSigner(new Sha256(), self::signingKeyFromSecret(SB_SECRET_KEY));
}

/**
* Operator-facing abort when SB_SECRET_KEY cannot be used to sign.
*
* Covers non-base64 values (UUID / hex / random password) and
* base64 that decodes to fewer than {@see MIN_SECRET_BYTES}. Both
* used to surface as late Lcobucci exceptions on first login;
* surface the fix instead of the library stack.
*/
private static function failInvalidSecretKey(\Throwable $cause): never
{
$message = "SB_SECRET_KEY must be base64 that decodes to at least "
. self::MIN_SECRET_BYTES . " bytes (256 bits for HMAC-SHA256).\n"
. "The panel's session cookies are signed with that value, so login cannot continue.\n"
. "\n"
. "Generate a valid key:\n"
. " openssl rand -base64 47\n"
. "\n"
. "Then set SB_SECRET_KEY to that output (env var or config.php) and restart.\n"
. "Rotating the key logs every admin out.";

error_log('[Sbpp\Auth\JWT] invalid SB_SECRET_KEY: ' . $cause->getMessage());

if (PHP_SAPI === 'cli') {
fwrite(STDERR, $message . "\n");
exit(1);
}

http_response_code(500);
header('Content-Type: text/plain; charset=UTF-8');
die($message);
}
}

Expand Down
44 changes: 38 additions & 6 deletions web/includes/Auth/UserManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,22 @@ public function GetUserArray(?int $aid = null): array|false
return false; // ohnoes some type of db error
}

$user = $this->mapAdminRow($aid, $res);
$this->admins[$aid] = $user;
return $user;
}

/**
* Shared row-to-user-array mapping for {@see GetUserArray()} and
* {@see GetAllAdmins()} so the two call sites can't drift on the
* shape of the cached admin array.
*
* @param array<string, mixed> $res
* @return array<string, mixed>
*/
private function mapAdminRow(int $aid, array $res): array
{
$user = [];
//$user['user'] = stripslashes($res[0]);
$user['aid'] = $aid; //immediately obvious
$user['user'] = $res['user'];
$user['authid'] = $res['authid'];
Expand All @@ -81,7 +95,7 @@ public function GetUserArray(?int $aid = null): array|false
$user['srv_flags'] = $res['srv_flags'] . $res['sgflags'];
$user['group_name'] = $res['wgname'];
$user['lastvisit'] = $res['lastvisit'];
$this->admins[$aid] = $user;

return $user;
}

Expand Down Expand Up @@ -182,13 +196,31 @@ public function GetAid(): int
}


/**
* Loads every admin row in one query (the same JOIN `GetUserArray()`
* uses, minus the `WHERE aid = :aid` filter) and warms the in-memory
* admin cache.
*/
public function GetAllAdmins(): array
{
$this->dbh->query('SELECT aid FROM `:prefix_admins`');
$res = $this->dbh->resultset();
foreach ($res as $admin) {
$this->GetUserArray($admin['aid']);
$this->dbh->query(
"SELECT adm.aid aid, adm.user user, adm.authid authid, adm.password password, adm.gid gid, adm.email email, adm.validate validate, adm.extraflags extraflags,
adm.immunity admimmunity,sg.immunity sgimmunity, adm.srv_password srv_password, adm.srv_group srv_group, adm.srv_flags srv_flags,sg.flags sgflags,
wg.flags wgflags, wg.name wgname, adm.lastvisit lastvisit
FROM `:prefix_admins` AS adm
LEFT JOIN `:prefix_groups` AS wg ON adm.gid = wg.gid
LEFT JOIN `:prefix_srvgroups` AS sg ON adm.srv_group = sg.name"
);
$rows = $this->dbh->resultset();

foreach ($rows as $res) {
$aid = (int) $res['aid'];
if ($aid <= 0) {
continue;
}
$this->admins[$aid] = $this->mapAdminRow($aid, $res);
}

return $this->admins;
}

Expand Down
Loading
Loading