Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ jobs:
# this install" — without it the user-visible promise lies).
# A release that silently drops any of the three makes every
# downstream redistribution non-compliant.
# - demos/.htaccess — blocks direct web access to uploaded
# demos on Apache tarball installs. It's a dotfile inside a
# gitignored directory, so it's the easiest file to lose.
- name: Verify required files are bundled in the zip
run: |
pkg="sourcebans-pp-${GITHUB_REF_NAME}.webpanel-only"
Expand All @@ -202,7 +205,8 @@ jobs:
"${pkg}/includes/vendor/smarty/smarty/src/Smarty.php" \
"${pkg}/LICENSE.txt" \
"${pkg}/LICENSE-plugins.txt" \
"${pkg}/THIRD-PARTY-NOTICES.txt"; do
"${pkg}/THIRD-PARTY-NOTICES.txt" \
"${pkg}/demos/.htaccess"; do
if ! unzip -l "${pkg}.zip" "${f}" >/dev/null 2>&1; then
echo "::error::Missing required artifact file in ${pkg}.zip: ${f}"
missing=1
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ Temporary Items
# Uploaded demo evidence (production) + synthetic demo files written by
# `./sbpp.sh db-seed` (dev). `.gitkeep` is the structural marker so the
# directory exists for fresh tarball installs and tests that probe
# `is_dir(SB_DEMOS)`. Both upload paths land MD5-hash basenames here;
# `is_dir(SB_DEMOS)`; `.htaccess` blocks direct web access on tarball
# installs. Both upload paths land 32-char hex basenames here;
# checking them in would (a) blow up the working tree on every db-seed
# and (b) leak real ban evidence into the source tree on a self-hoster
# install.
/web/demos/*
!/web/demos/.gitkeep
!/web/demos/.htaccess

# Local stash file used by the install-screenshot capture flow
# (`docs/README.md` → "Capturing screenshots"). Gitignored so an
Expand Down
32 changes: 25 additions & 7 deletions AGENTS.md

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ web/
├── config.php.template Template the installer + dev entrypoint render
├── exportbans.php Public ban-list export (CSV/XML)
├── export.php Full data export (owner-only ZIP bundle; see includes/Export/)
├── getdemo.php Demo file download
├── getdemo.php Demo file download (the only way demos are served)
├── demos/ Uploaded demos, random 32-hex names; .htaccess + Apache confs deny direct access
│
├── api/handlers/ JSON API: one file per topic, _register.php wires them
├── api/openapi-v1.yaml REST v1 OpenAPI source of truth
Expand Down Expand Up @@ -161,7 +162,9 @@ Both scripts include `init.php` first, which performs identical bootstrap.
`init.php` does the following, in order:

1. Defines path constants (`ROOT`, `INCLUDES_PATH`, `TEMPLATES_PATH`, …)
and the `IN_SB` sentinel that page files check.
and the `IN_SB` sentinel that page files check, then sends
`X-Content-Type-Options: nosniff` (the prod / dev Apache confs set
the same header for static files).
2. Redirects to `/install/` if `config.php` is missing (the
wizard is the canonical fresh-install path; #1335 M1 replaced
the bare-text `die()`). If `install/` or `updater/` are still
Expand Down Expand Up @@ -1181,7 +1184,9 @@ model:
Apache, bind-mounting `./web`), `db` (MariaDB 10.11), `adminer`
(DB UI), and `mailpit` (catch-all SMTP).
- **`docker/Dockerfile`** layers the PHP extensions, OPcache config, and
Composer onto `php:8.5-apache`.
Composer onto `php:8.5-apache`, plus `docker/apache/sbpp-dev.conf`
(the prod image's `demos/` deny + `nosniff` header, so E2E exercises
the same rules).
- **`docker/php/web-entrypoint.sh`** waits for MariaDB, renders
`web/config.php` from env vars (only if absent), runs `composer install`
if `vendor/` is empty, then `exec`s Apache.
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ Legend:
? = Other stuff
```

(26/10/26): Version 2.2.2-srcdslab
-----------------------
```
01. ! Security: uploaded demos can no longer be fetched directly from
/demos/; getdemo.php is the only download path (Apache rules
in the Docker images + a shipped demos/.htaccess for tarball
installs, nginx / IIS rules in the quickstart docs)
02. ! Security: uploaded demos are named from a CSPRNG instead of
md5(SteamID . time())
03. ! Security: every response sends X-Content-Type-Options: nosniff
04. ! Dev stack: demos/ is writable by the web container
```

(26/10/26): Version 2.2.1-srcdslab
-----------------------
```
Expand Down
6 changes: 5 additions & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ RUN apt-get update \
intl \
zip \
mbstring \
&& a2enmod rewrite \
&& a2enmod rewrite headers \
&& rm -rf /var/lib/apt/lists/*

COPY docker/apache/sbpp-dev-rewrite.conf /etc/apache2/conf-available/sbpp-dev-rewrite.conf
Expand Down Expand Up @@ -66,6 +66,10 @@ RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \
/etc/apache2/apache2.conf \
/etc/apache2/conf-available/*.conf

# Mirrors the prod image's demos/ deny + nosniff header (see the conf).
COPY docker/apache/sbpp-dev.conf /etc/apache2/conf-available/zz-sbpp-dev.conf
RUN a2enconf zz-sbpp-dev

COPY docker/php/web-entrypoint.sh /usr/local/bin/web-entrypoint
RUN chmod +x /usr/local/bin/web-entrypoint

Expand Down
6 changes: 4 additions & 2 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ To stop:
```
Dockerfile php:8.5-apache + pdo_mysql, intl, zip, mbstring, opcache, composer
docker-compose.yml web, db, adminer, mailpit
docker/apache/
sbpp-dev.conf denies direct /demos/ access + sends nosniff (mirrors sbpp-prod.conf)
docker/php/
web-entrypoint.sh waits for DB, renders config.php, runs composer install, fixes cache perms
web-entrypoint.sh waits for DB, renders config.php, runs composer install, fixes cache + demos perms
dev-prepend.php normalizes HTTP_HOST so init.php's localhost guard accepts :8080
docker/db-init/
00-render-schema.sh on first DB init: substitutes {prefix}/{charset}, loads struc/data, seeds admin
Expand Down Expand Up @@ -129,7 +131,7 @@ else not referenced by `:prefix_demos`) are never touched by the wipe.
`db-reset` drops the entire DB volume AND wipes MD5-named files from
`web/demos/` (orphans after the DB drop — the `_demos` rows that
pointed at them vanished with the volume). The wipe is scoped to
`[0-9a-f]{32}` basenames so `.gitkeep` stays put; non-MD5 manual
`[0-9a-f]{32}` basenames so `.gitkeep` and `.htaccess` stay put; non-MD5 manual
uploads (the icon / mapimg flows preserve original filenames) survive
too, though those don't normally land in `web/demos/`.

Expand Down
15 changes: 15 additions & 0 deletions docker/apache/sbpp-dev.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# SourceBans++ local-development Apache rules.
#
# Loaded as /etc/apache2/conf-available/zz-sbpp-dev.conf via a2enconf
# in docker/Dockerfile. NOT the production hardening; that lives in
# sbpp-prod.conf. Only rules the panel's behaviour depends on are
# mirrored here, so the dev + E2E stack matches the prod image.

# `demos/` is only reachable through getdemo.php. Same rule (and
# rationale) as the matching block in sbpp-prod.conf.
<DirectoryMatch "^${APACHE_DOCUMENT_ROOT}/demos(/|$)">
Require all denied
</DirectoryMatch>

Header onsuccess unset X-Content-Type-Options
Header always set X-Content-Type-Options "nosniff"
20 changes: 19 additions & 1 deletion docker/apache/sbpp-prod.conf
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
# - Deny access to dotfiles (.git, .env, .htaccess, ...).
# - Deny access to internal directories the panel reads but no
# external user should hit (vendor/, cache/, install/, updater/,
# configs/, includes/).
# configs/, includes/, demos/).
# - Send `X-Content-Type-Options: nosniff` on every response.
# - Document-root scoped, never global. A panel installed under a
# subpath is unsupported on this image (operators should use the
# reverse-proxy / Caddy stub for path-based deploys; the image
Expand Down Expand Up @@ -67,6 +68,23 @@
Require all denied
</DirectoryMatch>

# `demos/` holds uploaded ban evidence, including uploads from the
# anonymous submission form, stored without an extension. `getdemo.php`
# is the only download path (octet-stream + attachment + nosniff). The shipped
# `demos/.htaccess` does the same job on tarball installs, but this
# image runs with `AllowOverride None` (and the dotfile rule above
# denies the file itself), so the rule has to live here.
<DirectoryMatch "^${APACHE_DOCUMENT_ROOT}/demos(/|$)">
Require all denied
</DirectoryMatch>

# Never let a browser content-sniff a response into HTML or script.
# `always` also covers static files and error responses Apache
# generates itself. `web/init.php` sends the same header from PHP into
# the separate onsuccess table, so unset that copy to avoid a duplicate.
Header onsuccess unset X-Content-Type-Options
Header always set X-Content-Type-Options "nosniff"

# `config.php` is the install-state sentinel. It carries DB credentials,
# the JWT secret key, and the Steam API key. Deny the live file AND
# common editor / backup suffixes (`config.php.bak`, `.old`, `.save`,
Expand Down
7 changes: 6 additions & 1 deletion docker/php/web-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# 1. Wait for the database to accept connections.
# 2. Render web/config.php from environment variables if it's missing/empty.
# 3. Run `composer install` if vendor/ is absent.
# 4. Make sure cache/ is writable.
# 4. Make sure cache/ and demos/ are writable.
# 5. exec the original CMD (apache2-foreground).

set -euo pipefail
Expand Down Expand Up @@ -86,6 +86,11 @@ ensure_cache() {
mkdir -p "${WEB_ROOT}/cache" "${WEB_ROOT}/cache/sessions" "${WEB_ROOT}/templates_c"
chown -R www-data:www-data "${WEB_ROOT}/cache" "${WEB_ROOT}/templates_c"
chmod -R 0775 "${WEB_ROOT}/cache" "${WEB_ROOT}/templates_c"
# demos/ lives on the host bind mount, so chown would hand the host
# checkout to uid 33. Widen the directory mode instead so Apache can
# write uploads; Apache denies direct /demos/ requests.
mkdir -p "${WEB_ROOT}/demos"
chmod 0777 "${WEB_ROOT}/demos"
}

main() {
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/getting-started/quickstart-docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@ is running in Docker.
| `cache` | `/var/www/html/web/cache` | Optional. Smarty compile cache + PHP sessions. Wiping logs every admin out but doesn't lose data. |
| `smarty` | `/var/www/html/web/templates_c` | Optional. Same shape as `cache`, rebuilt on demand. |

The image's Apache config blocks direct web access to `demos/`.
Demos download only through the panel (`getdemo.php`). If a reverse
proxy serves files from the volume itself, deny `/demos/` there too.

Back up the `dbdata` and `demos` volumes regularly. The simplest
backup is a `mariadb-dump` for the DB and a `tar` for the demos:

Expand Down
46 changes: 46 additions & 0 deletions docs/src/content/docs/getting-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,52 @@ proceed rather than leave you with a panel that fails later. Fix the
permissions and reload.
</Aside>

### Block direct access to demos

`demos/` holds uploaded evidence, including uploads from the public
ban submission form. The panel serves demos only through
`getdemo.php`. Your web server must never serve files from `demos/`
directly.

<Tabs syncKey="webserver">
<TabItem label="Apache">
The release ships `demos/.htaccess`, which denies every request.
Apache only reads it when `AllowOverride` includes `AuthConfig`
(or `All`) for the panel directory. Some FTP clients hide
dotfiles, so check that the file uploaded.
</TabItem>

<TabItem label="nginx">
nginx ignores `.htaccess`. Add this to the panel's `server` block
(use `/sb/demos/` for a subfolder install) and reload nginx:

```nginx
location ^~ /demos/ {
deny all;
}
```
</TabItem>

<TabItem label="IIS">
Add `demos` as a hidden segment in the panel's `web.config`:

```xml
<system.webServer>
<security>
<requestFiltering>
<hiddenSegments>
<add segment="demos" />
</hiddenSegments>
</requestFiltering>
</security>
</system.webServer>
```
</TabItem>
</Tabs>

To check, open `https://<your-domain>/demos/.htaccess`. You should
get a 403 or 404 error, not the file.

## Step 3 — Run the install wizard

Open `https://<your-domain>/install/` in a browser (or
Expand Down
5 changes: 4 additions & 1 deletion docs/src/content/docs/updating/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ want the `sourcebans-pp-X.Y.Z.webpanel-only.zip` for the web side and
"default" before uploading.

3. **Upload and overwrite** all contents of the new `web/` folder
onto your existing SourceBans++ installation.
onto your existing SourceBans++ installation. Include
`demos/.htaccess` (some FTP clients hide dotfiles). On nginx or
IIS, add the rule from
[Block direct access to demos](/getting-started/quickstart/#block-direct-access-to-demos).

4. **Delete the `install/` directory** if it's still there from a
previous install. The panel actively refuses to boot while it
Expand Down
3 changes: 3 additions & 0 deletions web/demos/.htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Uploaded demos are served only through getdemo.php.
# Needs `AllowOverride AuthConfig` (or `All`) on this directory.
Require all denied
16 changes: 13 additions & 3 deletions web/includes/Upload/UploadHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
*
* Filename hygiene contract:
* - Demo uploads (#1113): the on-disk name is a fresh
* `md5(time() . rand())` so admin-controlled strings never reach
* {@see UploadHandler::randomDiskName()} so admin-controlled strings never reach
* `move_uploaded_file()`'s second argument; the original name is
* surfaced back to the opener via `window.opener.demo()` for the
* UI label only.
Expand Down Expand Up @@ -71,7 +71,7 @@ final class UploadHandler
* (`demo` / `icon` / `mapimg`) — the JS function the parent
* window is expected to define.
* @param bool $renameToHash When `true` (demo flow), the on-disk
* filename is replaced with `md5(time() . rand())` BEFORE the
* filename is replaced with {@see randomDiskName()} BEFORE the
* final move. The original filename is sent back to the opener
* as the second `window.opener.<cb>()` argument; the parent uses
* it as the human label. When `false` (icon / mapimage), the
Expand Down Expand Up @@ -171,7 +171,7 @@ private static function moveAndCallback(

$sanitised = self::sanitiseName($rawName);
$diskName = $renameToHash
? md5((string) time() . random_int(0, 1000))
? self::randomDiskName()
: $sanitised;

$dest = rtrim($destDir, '/\\') . '/' . $diskName;
Expand All @@ -187,6 +187,16 @@ private static function moveAndCallback(
return "<script>window.opener.{$callback}($args);self.close()</script>";
}

/**
* On-disk basename for an uploaded demo: 32 lowercase hex chars from
* a CSPRNG. Must stay 32-char lowercase hex because `api_bans_add`,
* `./sbpp.sh db-reset`, and the dev seeder key on that shape.
*/
public static function randomDiskName(): string
{
return bin2hex(random_bytes(16));
}

/**
* Strip directory-traversal characters from an admin-uploaded
* filename. The legacy `move_uploaded_file($_, SB_X . '/' . $name)`
Expand Down
7 changes: 7 additions & 0 deletions web/init.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@

define('IN_SB', true);

// Browsers must never content-sniff a panel response into HTML or
// script. Static files bypass PHP, so the Docker Apache confs set the
// same header server-wide.
if (!headers_sent()) {
header('X-Content-Type-Options: nosniff');
}

// ---------------------------------------------------
// Are we installed? (Issue #1335 M1 + C1)
// ---------------------------------------------------
Expand Down
3 changes: 1 addition & 2 deletions web/pages/page.submit.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,7 @@
}

if ($validsubmit) {
$filename = md5($SteamID . time());
//echo SB_DEMOS."/".$filename;
$filename = \Sbpp\Upload\UploadHandler::randomDiskName();
$demo = !empty($_FILES['demo_file']['name'])
&& move_uploaded_file((string) ($_FILES['demo_file']['tmp_name'] ?? ''), SB_DEMOS . "/" . $filename);
if ($demo || empty($_FILES['demo_file']['name'])) {
Expand Down
9 changes: 9 additions & 0 deletions web/tests/e2e/pages/SubmitBanFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface SubmissionFixture {
readonly reporterName: string;
/** Submitter email; required by FILTER_VALIDATE_EMAIL. */
readonly email: string;
/** Optional evidence upload; `name` must carry an allowed extension. */
readonly demo?: { readonly name: string; readonly contents: string };
}

/**
Expand Down Expand Up @@ -81,6 +83,13 @@ export async function anonymousSubmit(page: Page, fx: SubmissionFixture): Promis
// us inside the success branch without depending on a seeded
// :prefix_servers row.
await page.locator('[data-testid="submitban-server"]').selectOption('0');
if (fx.demo) {
await page.locator('[data-testid="submitban-demo"]').setInputFiles({
name: fx.demo.name,
mimeType: 'application/octet-stream',
buffer: Buffer.from(fx.demo.contents),
});
}

await Promise.all([
page.waitForLoadState('domcontentloaded'),
Expand Down
Loading
Loading