From 100762ea2e2e3b0236edaea1782b55248d547b7e Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Nov 2026 11:36:49 +0100 Subject: [PATCH 1/8] fix: Escapes OSM tag data in camera popups camera.php built its JSON by hand with htmlentities(), which leaves backslashes intact, and the client parsed it with eval() and inserted tag values into the popup as HTML. A tag value such as therefore became live markup (stored XSS for anyone able to edit OSM data), and a lone backslash broke parsing of the whole response. - camera.php now builds the response with json_encode() and returns raw values - tags named lat, lon, id, count, multi or poly no longer override the fields set by the server - error responses no longer include database error messages - the client uses JSON.parse() and escapes all keys and values; only http(s) URLs are rendered as links/images --- web/www/sunders/camera.php | 123 ++++++++----------- web/www/sunders/js/leafletembed_functions.js | 39 ++++-- 2 files changed, 79 insertions(+), 83 deletions(-) diff --git a/web/www/sunders/camera.php b/web/www/sunders/camera.php index 97a77e7..9aa2f85 100644 --- a/web/www/sunders/camera.php +++ b/web/www/sunders/camera.php @@ -4,6 +4,17 @@ define('MAX_POINTS_FOR_QUICKHULL', 3000); + // Fields set by this script; OSM tags with these keys are not passed on. + define('RESERVED_KEYS', array('lat', 'lon', 'id', 'count', 'multi', 'poly')); + + define('JSON_FLAGS', JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + + function exitWithError($message) { + header('Content-type: application/json; charset=utf-8'); + echo json_encode(array('error' => $message), JSON_FLAGS); + exit; + } + class OsmPoint { var $id; var $lat; @@ -179,63 +190,47 @@ || !array_key_exists('zoom', $_GET) || !array_key_exists('width', $_GET) || !array_key_exists('height', $_GET)) { - header('Content-type: application/json'); - $result = '{"error":"bbox, zoom, width and height parameters are required. ' + exitWithError('bbox, zoom, width and height parameters are required. ' .(array_key_exists('bbox', $_GET) ? '' : 'bbox is empty. ') .(array_key_exists('zoom', $_GET) ? '' : 'zoom is empty. ') .(array_key_exists('width', $_GET) ? '' : 'width is empty. ') - .(array_key_exists('height', $_GET) ? '' : 'height is empty. ') - .'"}'; - echo $result; - exit; + .(array_key_exists('height', $_GET) ? '' : 'height is empty. ')); } /* Check zoom */ $zoom = $_GET['zoom']; - if ( !is_numeric($zoom) + if ( !is_string($zoom) + || !is_numeric($zoom) || intval($zoom) < 1 || intval($zoom) > 19) { - header('Content-type: application/json'); - $result = '{"error":"unexpected zoom value : ' - .htmlentities($zoom) - .'"}'; - echo $result; - exit; + exitWithError('unexpected zoom value'); } /* Check bounds box --> bbox=boundsSW.lng,boundsSW.lat,boundsNE.lng,boundsNE.lat */ + if (!is_string($_GET['bbox'])) { + exitWithError('unexpected bbox value'); + } $bbox = explode(',', $_GET['bbox']); - if ( !is_numeric($bbox[0]) || $bbox[0] > 180 // 0 = SW.lng + if ( count($bbox) != 4 + || !is_numeric($bbox[0]) || $bbox[0] > 180 // 0 = SW.lng || !is_numeric($bbox[2]) || $bbox[2] < -180 // 2 = NE.lng || !is_numeric($bbox[1]) || $bbox[1] < -90 || $bbox[1] > 90 // 1 = SW.lat || !is_numeric($bbox[3]) || $bbox[3] < -90 || $bbox[3] > 90) { // 3 = NE.lat - header('Content-type: application/json'); - $result = '{"error":"unexpected bbox longitude and latitude values : ' - .'lon ['.htmlentities($bbox[0]).', '.htmlentities($bbox[2]).'], ' - .'lat ['.htmlentities($bbox[1]).', '.htmlentities($bbox[3]).']' - .'"}'; - echo $result; - exit; + exitWithError('unexpected bbox longitude and latitude values : ' + .'lon ['.$bbox[0].', '.$bbox[2].'], ' + .'lat ['.$bbox[1].', '.$bbox[3].']'); } if ($bbox[0] >= $bbox[2]) { // 0 = SW.lng | 2 = NE.lng - header('Content-type: application/json'); - $result = '{"error":"min longitude greater than max longitude : ' - .'lon ['.htmlentities($bbox[0]).', '.htmlentities($bbox[2]).']' - .'"}'; - echo $result; - exit; + exitWithError('min longitude greater than max longitude : ' + .'lon ['.$bbox[0].', '.$bbox[2].']'); } if ($bbox[1] >= $bbox[3]) { // 1 = SW.lat | 3 = NE.lat - header('Content-type: application/json'); - $result = '{"error":"min latitude greater than max latitude : ' - .'lat ['.htmlentities($bbox[1]).', '.htmlentities($bbox[3]).']' - .'"}'; - echo $result; - exit; + exitWithError('min latitude greater than max latitude : ' + .'lat ['.$bbox[1].', '.$bbox[3].']'); } $lonMin = $bbox[0]; // 0 = SW.lng @@ -247,12 +242,7 @@ $pixelHeight = (int) $_GET['height']; if ($pixelWidth == 0 || $pixelHeight == 0) { - header('Content-type: application/json'); - $result = '{"error":"width or height is null : ' - .htmlentities($pixelWidth).'x'.htmlentities($pixelHeight) - .'"}'; - echo $result; - exit; + exitWithError('width or height is null : '.$pixelWidth.'x'.$pixelHeight); } $lonWidth = $lonMax - $lonMin; // NE.lng - SW.lng @@ -268,10 +258,7 @@ /* Connect to database */ $mysqli = new mysqli(MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB); if($mysqli->connect_errno) { - header('Content-type: application/json'); - $result = '{"error":"error while connecting to db : ' . $mysqli->error . '"}'; - echo $result; - exit; + exitWithError('error while connecting to db'); } $rqtLonMin = $lonMin; // SW.lng @@ -365,10 +352,7 @@ $stmt->close(); } else { $mysqli->close(); - header('Content-type: application/json'); - $result = '{"error":"Error while request : ' . $mysqli->error . '"}'; - echo $result; - exit; + exitWithError('error while querying db'); } /* Unify some clusters if nodes center are near */ @@ -380,8 +364,7 @@ } } - $result='['; - $separator=''; + $result = array(); /* Writing selected points */ $sql='SELECT k, v @@ -393,53 +376,49 @@ $stmt->bind_param('d', $id); $stmt->bind_result($k, $v); - /* Writing grouped items or not */ + /* Writing grouped items or not. All values are passed on unescaped; the client + must escape them before inserting them into HTML. */ foreach($resArray as $val) { if ($val['count'] > 0) { - $result = $result.$separator - .'{"lat":"'.($val['latitude'] / $val['count']).'"' - .',"lon":"'.($val['longitude'] / $val['count']).'"'; + $entry = array( + 'lat' => (string) ($val['latitude'] / $val['count']), + 'lon' => (string) ($val['longitude'] / $val['count'])); if ($val['count'] == 1) { $id = $val['points'][0]->id; - $result = $result.',"id":"'.$id.'"'; + $entry['id'] = (string) $id; $stmt->execute(); while($stmt->fetch()) { - $result = $result.',"'.htmlentities($k).'":"'.htmlentities($v, ENT_COMPAT, 'UTF-8').'"'; + if (!in_array($k, RESERVED_KEYS, true)) { + $entry[$k] = (string) $v; + } } } else { - $result = $result - .',"count":"'.$val['count'].'"' - .',"multi":"yes"' - .',"poly":['; + $entry['count'] = (string) $val['count']; + $entry['multi'] = 'yes'; + $entry['poly'] = array(); if ($pointsCount < MAX_POINTS_FOR_QUICKHULL) { $convexPoly = quickHull($val['points'], $val['count']); - $sepPoly = ''; foreach($convexPoly as $point) { - $result = $result.$sepPoly.'{' - .'"lat":"'.$point->lat.'"' - .',"id":"'.$point->id.'"' - .',"lon":"'.$point->lon.'"}'; - $sepPoly=','; + $entry['poly'][] = array( + 'lat' => (string) $point->lat, + 'id' => (string) $point->id, + 'lon' => (string) $point->lon); } } - $result=$result.']'; } - $result = $result.'}'; - $separator=','; + $result[] = $entry; } } $stmt->close(); - $result = $result . ']'; - $mysqli->close(); - header('Content-type: application/json; Charset : utf-8'); - echo $result; + header('Content-type: application/json; charset=utf-8'); + echo json_encode($result, JSON_FLAGS); ?> diff --git a/web/www/sunders/js/leafletembed_functions.js b/web/www/sunders/js/leafletembed_functions.js index cb199a0..75e461e 100644 --- a/web/www/sunders/js/leafletembed_functions.js +++ b/web/www/sunders/js/leafletembed_functions.js @@ -129,7 +129,11 @@ function onClick(e) { function onStateChanged() { if (ajaxRequest.readyState == 4) { // 0 = UNSENT, 1 = OPENED, 2 = HEADERS_RECEIVED, 3 = LOADING, 4 = DONE if (ajaxRequest.status == 200) { // 200 = OK, 404 = Page not found - plotList = eval('(' + ajaxRequest.responseText + ')'); + try { + plotList = JSON.parse(ajaxRequest.responseText); + } catch(e) { + return; + } removeMarkers(); for (i=0; i/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + // Add camera popup to camera marker. +// All values in plot come unescaped from OSM data and must be escaped. function addCameraDetailsData(plotMarker, plot) { + var id = escapeHtml(plot.id); popupDataTable = '' - + '' - // + '' - + '' - + ''; + + '' + // + '' + + '' + + ''; for (x in plot) { if (plot[x] !== '' && x != 'multi' && x != 'id' && x != 'userid' && x != 'lat' && x != 'lon') { - popupDataTable = popupDataTable + ''; } From 6a640831753f0dacc4826567a7c4d6794fd046cc Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Nov 2026 11:36:49 +0100 Subject: [PATCH 2/8] fix: Escapes location search results Nominatim results were inserted via innerHTML and loaded as a JSONP script with an unencoded query. Results are now fetched as JSON, the query is URL encoded, text is escaped and coordinates must be numeric. --- web/www/sunders/js/search.js | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/web/www/sunders/js/search.js b/web/www/sunders/js/search.js index 4ba00a0..38f69a5 100644 --- a/web/www/sunders/js/search.js +++ b/web/www/sunders/js/search.js @@ -37,15 +37,32 @@ function searchLocation(maxResults) { searchLimit = 11; } - var searchScript = document.createElement('script'); - searchScript.src = 'https://nominatim.openstreetmap.org/search.php?q=' + searchString + '&format=json&json_callback=parseResult&limit=' + searchLimit; - document.body.appendChild(searchScript); + var searchUrl = 'https://nominatim.openstreetmap.org/search?q=' + encodeURIComponent(searchString) + + '&format=json&limit=' + encodeURIComponent(searchLimit); + fetch(searchUrl) + .then(function(response) { return response.json(); }) + .then(parseResult) + .catch(function() { parseResult([]); }); } } +// Escape a value for use in HTML text and in double-quoted attributes. +function escapeSearchHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function parseResult(response) { var searchResultDivs = ''; + if (!Array.isArray(response)) { + response = []; + } + if (response.length == searchLimit) { var lastResultIndex = searchLimit - 1; var nextSearchLimit = searchLimit + 10; @@ -67,10 +84,15 @@ function parseResult(response) { // Show the current 10 search results. for (var i=searchLimit-11; i' + - '
' + searchResult.class + ' / ' + searchResult.type + '
' + - '
' + searchResult.display_name + '
' + + '
' + + '
' + escapeSearchHtml(searchResult.class) + ' / ' + escapeSearchHtml(searchResult.type) + '
' + + '
' + escapeSearchHtml(searchResult.display_name) + '
' + '
'; } From 344232fb758e65cac6b778ce539b4f48cab638d3 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Nov 2026 11:36:49 +0100 Subject: [PATCH 3/8] fix: Hardens web container - document root is owned by root and not writable by www-data (the base image ships /var/www/html as www-data with mode 1777) - uses php.ini-production, so errors are not shown to clients - disables directory listings, expose_php and the detailed server signature --- web/Containerfile | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/web/Containerfile b/web/Containerfile index e8b5998..c84f8a7 100644 --- a/web/Containerfile +++ b/web/Containerfile @@ -1,4 +1,17 @@ FROM docker.io/library/php:apache RUN docker-php-ext-install mysqli bcmath -COPY ./www/* /var/www/html \ No newline at end of file + +# Use PHP's production defaults (no error output to clients) and hide the PHP version. +RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \ + && printf 'expose_php = Off\n' > "$PHP_INI_DIR/conf.d/zz-hardening.ini" + +# Disable directory listings and reduce the server signature. +RUN sed -i 's/^\(\s*Options\) Indexes FollowSymLinks/\1 FollowSymLinks/' /etc/apache2/apache2.conf \ + && sed -i -e 's/^ServerTokens .*/ServerTokens Prod/' -e 's/^ServerSignature .*/ServerSignature Off/' /etc/apache2/conf-available/security.conf + +COPY ./www/* /var/www/html + +# The web server must not be able to create or modify files in the document root. +RUN chown -R root:root /var/www/html \ + && chmod -R u=rwX,go=rX /var/www/html From 189105075c718607722eef940b115717133d79ea Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Nov 2026 11:36:49 +0100 Subject: [PATCH 4/8] fix: Publishes images only from main Every pushed ref built and pushed images, with the tag derived from the ref name. A branch named "latest" or a git tag named "main" therefore overwrote the production image, and ref names were interpolated into shell scripts unquoted (script injection). - images are only pushed for pushes to refs/heads/main - other refs are built and tested with a ci- tag, never pushed - registry login happens only when publishing, after the compose test - main additionally pushes an immutable sha- tag for pinning - expression values are passed to scripts via env The registry credentials still need to be restricted to main on the Forgejo side, since a pushed branch can modify this workflow. --- .forgejo/workflows/images.yml | 91 +++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 32 deletions(-) diff --git a/.forgejo/workflows/images.yml b/.forgejo/workflows/images.yml index 6dfac7b..87f45e6 100644 --- a/.forgejo/workflows/images.yml +++ b/.forgejo/workflows/images.yml @@ -4,6 +4,16 @@ on: push: workflow_dispatch: +# Images are only published for pushes to the main branch. All other refs (branches, +# renovate branches, tags) are built and tested locally, but never pushed. +# +# Note: a workflow run for a pushed ref uses the workflow file from that ref, so this +# condition alone does not stop someone with push access from publishing images. The +# registry credentials must additionally be restricted to the protected main branch. + +env: + IMAGE_PREFIX: git.hamburg.ccc.de/ccchh/sunders + jobs: build: runs-on: docker @@ -14,44 +24,61 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Set image tags + id: vars + env: + COMMIT_SHA: ${{ github.sha }} + PUBLISH: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + run: | + if [ "$PUBLISH" = "true" ]; then + echo "tag=latest" >> "$GITHUB_OUTPUT" + else + # Never derive tags from ref names: they are attacker-controlled and may collide with `latest`. + echo "tag=ci-${COMMIT_SHA}" >> "$GITHUB_OUTPUT" + fi + echo "sha_tag=sha-${COMMIT_SHA}" >> "$GITHUB_OUTPUT" + + - name: Build web image + env: + TAG: ${{ steps.vars.outputs.tag }} + run: | + docker build -f ./Containerfile -t "$IMAGE_PREFIX/web:$TAG" . + working-directory: ./web + + - name: Build data_handler image + env: + TAG: ${{ steps.vars.outputs.tag }} + run: | + docker build -f ./Containerfile -t "$IMAGE_PREFIX/data_handler:$TAG" . + working-directory: ./data_handler + + - name: Update docker-compose.yml image tags + env: + TAG: ${{ steps.vars.outputs.tag }} + run: | + sed -i "s/:latest/:${TAG}/g" docker-compose.yml + + - name: Start Docker Compose services + run: | + docker compose up -d --wait + docker compose down + - name: Login to Container Registry + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: git.hamburg.ccc.de username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Set image tags - id: vars - run: | - if [ "${{ github.ref_name }}" = "main" ] && [ "${{ github.event_name }}" = "push" ]; then - echo "tag=latest" >> $GITHUB_OUTPUT - else - # renovate creates sub branches with `/`; these break the tagging in the build process - echo tag=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9._-]/_/g') >> $GITHUB_OUTPUT - fi - - - name: Build web image - run: | - docker build -f ./Containerfile -t git.hamburg.ccc.de/ccchh/sunders/web:${{ steps.vars.outputs.tag }} . - working-directory: ./web - - - name: Build data_handler image - run: | - docker build -f ./Containerfile -t git.hamburg.ccc.de/ccchh/sunders/data_handler:${{ steps.vars.outputs.tag }} . - working-directory: ./data_handler - name: Push images to Container Registry + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + TAG: ${{ steps.vars.outputs.tag }} + SHA_TAG: ${{ steps.vars.outputs.sha_tag }} run: | - docker push git.hamburg.ccc.de/ccchh/sunders/web:${{ steps.vars.outputs.tag }} - docker push git.hamburg.ccc.de/ccchh/sunders/data_handler:${{ steps.vars.outputs.tag }} - if: github.event_name == 'push' - - - name: Update docker-compose.yml image tags - run: | - sed -i "s/:latest/:${{ steps.vars.outputs.tag }}/g" docker-compose.yml - - - name: Start Docker Compose services - run: | - docker compose up -d --wait - docker compose down \ No newline at end of file + for image in web data_handler; do + docker tag "$IMAGE_PREFIX/$image:$TAG" "$IMAGE_PREFIX/$image:$SHA_TAG" + docker push "$IMAGE_PREFIX/$image:$TAG" + docker push "$IMAGE_PREFIX/$image:$SHA_TAG" + done From 3b2828033342266459bd98cd9496d45df8e2a605 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Nov 2026 11:36:49 +0100 Subject: [PATCH 5/8] fix: Escapes generated SQL and validates sequence number create_camera_update_statements.php wrote OSM tag keys and values unescaped into /migrations/*.sql. Values are now escaped and node ids cast to int. The sequence number argument must be numeric and is bound as a parameter instead of being concatenated into SQL and the output path. --- .../utils/create_camera_update_statements.php | 40 +++++++++++++------ data_handler/utils/update_camera.php | 12 +++++- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/data_handler/utils/create_camera_update_statements.php b/data_handler/utils/create_camera_update_statements.php index cf18dfc..a2a072c 100755 --- a/data_handler/utils/create_camera_update_statements.php +++ b/data_handler/utils/create_camera_update_statements.php @@ -2,6 +2,18 @@ include "config.php"; +$seqNum = $_SERVER['argv'][1] ?? ''; +if (!ctype_digit($seqNum)) { + echo "Invalid sequence number\n"; + exit(1); +} + +// Quote a value for the generated SQL file. Values come from OSM data and are untrusted. +function sqlString($value) { + global $mysqli; + return "'" . $mysqli->real_escape_string((string) $value) . "'"; +} + $elementTypes = array(); $count = 0; $countDelete = 0; @@ -91,7 +103,7 @@ function endElement ($parser, $name) { $mode = ''; } else if ($name == 'node') { if ($mode == 'delete') { - $id = $curNodeAttrs['id']; + $id = (int) $curNodeAttrs['id']; if (! $deleteTagStmt->execute()) { echo "***** Error : Deleting tags $id : ". $deleteTagStmt->error . "\n"; @@ -119,7 +131,7 @@ function endElement ($parser, $name) { - $id = $curNodeAttrs['id']; + $id = (int) $curNodeAttrs['id']; if ($mode == 'modify') { if (! $deleteTagStmt->execute()) { @@ -140,36 +152,36 @@ function endElement ($parser, $name) { $k = 'lat'; $v = $curNodeAttrs['lat']; - $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, '$k', '$v');\n"; + $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, " . sqlString($k) . ", " . sqlString($v) . ");\n"; $k = 'lon'; $v = $curNodeAttrs['lon']; - $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, '$k', '$v');\n"; + $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, " . sqlString($k) . ", " . sqlString($v) . ");\n"; $k = 'userid'; $v = $curNodeAttrs['user']; - $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, '$k', '$v');\n"; + $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, " . sqlString($k) . ", " . sqlString($v) . ");\n"; $k = 'version'; $v = $curNodeAttrs['version']; - $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, '$k', '$v');\n"; + $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, " . sqlString($k) . ", " . sqlString($v) . ");\n"; $k = 'timestamp'; $v = $curNodeAttrs['timestamp']; - $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, '$k', '$v');\n"; + $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, " . sqlString($k) . ", " . sqlString($v) . ");\n"; foreach($curNodeTags as $k => $v) { - $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, '$k', '$v');\n"; + $sql_statement = $sql_statement . "INSERT INTO tag (id, k, v) VALUES ($id, " . sqlString($k) . ", " . sqlString($v) . ");\n"; } } else if ($mode == 'modify') { // delete former surveillance nodes that were modified to non-surveillance nodes - $id = $curNodeAttrs['id']; + $id = (int) $curNodeAttrs['id']; if (! $deleteTagStmt->execute()) { echo "***** Error : Deleting tags $id for non-surveillance node : ". $deleteTagStmt->error . "\n"; @@ -208,11 +220,13 @@ while ($data = fread($fp, 4096)) { } } -$sql="UPDATE sync_state SET v='" . $_SERVER['argv'][1] ."' WHERE k='sequenceNumber';"; -$mysqli->query($sql); -$sql_statement = $sql_statement . "UPDATE sync_state SET v='" . $_SERVER['argv'][1] ."' WHERE k='sequenceNumber';\n"; +$syncStmt = $mysqli->prepare("UPDATE sync_state SET v=? WHERE k='sequenceNumber'"); +$syncStmt->bind_param('s', $seqNum); +$syncStmt->execute(); +$syncStmt->close(); +$sql_statement = $sql_statement . "UPDATE sync_state SET v='" . $seqNum ."' WHERE k='sequenceNumber';\n"; $sql_statement = $sql_statement . "COMMIT;"; -$sql_file = "/migrations/".$_SERVER['argv'][1].".sql"; +$sql_file = "/migrations/".$seqNum.".sql"; file_put_contents($sql_file, $sql_statement); $mysqli->close(); diff --git a/data_handler/utils/update_camera.php b/data_handler/utils/update_camera.php index dc88e48..539e0ca 100755 --- a/data_handler/utils/update_camera.php +++ b/data_handler/utils/update_camera.php @@ -2,6 +2,12 @@ include "config.php"; +$seqNum = $_SERVER['argv'][1] ?? ''; +if (!ctype_digit($seqNum)) { + echo "Invalid sequence number\n"; + exit(1); +} + $elementTypes = array(); $count = 0; $countDelete = 0; @@ -281,8 +287,10 @@ while ($data = fread($fp, 4096)) { } } -$sql="UPDATE sync_state SET v='" . $_SERVER['argv'][1] ."' WHERE k='sequenceNumber';"; -$mysqli->query($sql); +$syncStmt = $mysqli->prepare("UPDATE sync_state SET v=? WHERE k='sequenceNumber'"); +$syncStmt->bind_param('s', $seqNum); +$syncStmt->execute(); +$syncStmt->close(); $mysqli->commit(); printDebug(); From cdf90a04d32c60ee3dc7805a1916f0ccb87ea37d Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Nov 2026 11:36:49 +0100 Subject: [PATCH 6/8] fix: Corrects country code check in statistics filter Operator precedence made the check always true; it now accepts two capital letters or '--' as intended. --- web/www/sunders/stats/index.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/www/sunders/stats/index.php b/web/www/sunders/stats/index.php index 7a0dac4..56ef660 100644 --- a/web/www/sunders/stats/index.php +++ b/web/www/sunders/stats/index.php @@ -84,8 +84,8 @@ } else { $i18n = $i18nCountries; $i18nDefault = $i18nCountriesDefault; - // Check if $key consists of 2 capital letters. - $key = preg_match('/\b[A-Z]{2}\b/', $valsArray[$i]) === 1 || '--' ? $valsArray[$i] : 'dontfindme'; + // Check if $key consists of 2 capital letters or is '--' (unknown country). + $key = (preg_match('/^[A-Z]{2}$/', $valsArray[$i]) === 1 || $valsArray[$i] === '--') ? $valsArray[$i] : 'dontfindme'; } if (!isValidValue($i18n, $i18nDefault, $key)) { // ignore 'vals' and 'cols' From 6ec1b9fc5a0c0fc3fa4825056abdea7245d60b7e Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Nov 2026 11:48:03 +0100 Subject: [PATCH 7/8] fix: Builds images with kaniko There are no Windows runners; build the images with kaniko in a container on the `docker` runner, as on the link-updates branch, while keeping the publishing restrictions: - only pushes to refs/heads/main publish, as `latest` and `sha-` - other refs build with --no-push under a ci- tag - registry credentials are only passed to the build when publishing The docker compose smoke test is dropped, as there is no Docker daemon inside the kaniko container. --- .forgejo/workflows/images.yml | 85 ++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/.forgejo/workflows/images.yml b/.forgejo/workflows/images.yml index 462bab2..30a037b 100644 --- a/.forgejo/workflows/images.yml +++ b/.forgejo/workflows/images.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: # Images are only published for pushes to the main branch. All other refs (branches, -# renovate branches, tags) are built and tested locally, but never pushed. +# renovate branches, tags) are built, but never pushed, and do not get registry credentials. # # Note: a workflow run for a pushed ref uses the workflow file from that ref, so this # condition alone does not stop someone with push access from publishing images. The @@ -16,23 +16,27 @@ env: jobs: build: - runs-on: windows-2022 + runs-on: docker + container: + image: ghcr.io/osscontainertools/kaniko:alpine steps: - - name: Checkout repository - uses: actions/checkout@v7 + - name: Install required system packages + run: apk add --no-cache nodejs git - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + - name: Checkout repository + uses: actions/checkout@v4 - name: Set image tags id: vars env: - COMMIT_SHA: ${{ github.sha }} - PUBLISH: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + COMMIT_SHA: ${{ forgejo.sha }} + PUBLISH: ${{ forgejo.event_name == 'push' && forgejo.ref == 'refs/heads/main' }} run: | if [ "$PUBLISH" = "true" ]; then + echo "publish=true" >> "$GITHUB_OUTPUT" echo "tag=latest" >> "$GITHUB_OUTPUT" else + echo "publish=false" >> "$GITHUB_OUTPUT" # Never derive tags from ref names: they are attacker-controlled and may collide with `latest`. echo "tag=ci-${COMMIT_SHA}" >> "$GITHUB_OUTPUT" fi @@ -40,45 +44,42 @@ jobs: - name: Build web image env: + IMAGE: web + PUBLISH: ${{ steps.vars.outputs.publish }} TAG: ${{ steps.vars.outputs.tag }} + SHA_TAG: ${{ steps.vars.outputs.sha_tag }} + KANIKO_GIT_HAMBURG_CCC_DE_USER: ${{ steps.vars.outputs.publish == 'true' && secrets.REGISTRY_USERNAME || '' }} + KANIKO_GIT_HAMBURG_CCC_DE_PASSWORD: ${{ steps.vars.outputs.publish == 'true' && secrets.REGISTRY_TOKEN || '' }} run: | - docker buildx build -f ./Containerfile -t "$IMAGE_PREFIX/web:$TAG" --load . - working-directory: ./web + if [ "$PUBLISH" = "true" ]; then + set -- --destination="$IMAGE_PREFIX/$IMAGE:$TAG" --destination="$IMAGE_PREFIX/$IMAGE:$SHA_TAG" + else + set -- --destination="$IMAGE_PREFIX/$IMAGE:$TAG" --no-push + fi + /kaniko/executor \ + --dockerfile="$GITHUB_WORKSPACE/$IMAGE/Containerfile" \ + --context="dir://$GITHUB_WORKSPACE/$IMAGE" \ + --no-push-cache \ + --credential-helpers=env \ + "$@" - name: Build data_handler image env: - TAG: ${{ steps.vars.outputs.tag }} - run: | - docker buildx build -f ./Containerfile -t "$IMAGE_PREFIX/data_handler:$TAG" --load . - working-directory: ./data_handler - - - name: Update docker-compose.yml image tags - env: - TAG: ${{ steps.vars.outputs.tag }} - run: | - sed -i "s/:latest/:${TAG}/g" docker-compose.yml - - - name: Start Docker Compose services - run: | - docker compose up -d --wait - docker compose down - - - name: Login to Container Registry - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: docker/login-action@v4 - with: - registry: git.hamburg.ccc.de - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Push images to Container Registry - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - env: + IMAGE: data_handler + PUBLISH: ${{ steps.vars.outputs.publish }} TAG: ${{ steps.vars.outputs.tag }} SHA_TAG: ${{ steps.vars.outputs.sha_tag }} + KANIKO_GIT_HAMBURG_CCC_DE_USER: ${{ steps.vars.outputs.publish == 'true' && secrets.REGISTRY_USERNAME || '' }} + KANIKO_GIT_HAMBURG_CCC_DE_PASSWORD: ${{ steps.vars.outputs.publish == 'true' && secrets.REGISTRY_TOKEN || '' }} run: | - for image in web data_handler; do - docker tag "$IMAGE_PREFIX/$image:$TAG" "$IMAGE_PREFIX/$image:$SHA_TAG" - docker push "$IMAGE_PREFIX/$image:$TAG" - docker push "$IMAGE_PREFIX/$image:$SHA_TAG" - done + if [ "$PUBLISH" = "true" ]; then + set -- --destination="$IMAGE_PREFIX/$IMAGE:$TAG" --destination="$IMAGE_PREFIX/$IMAGE:$SHA_TAG" + else + set -- --destination="$IMAGE_PREFIX/$IMAGE:$TAG" --no-push + fi + /kaniko/executor \ + --dockerfile="$GITHUB_WORKSPACE/$IMAGE/Containerfile" \ + --context="dir://$GITHUB_WORKSPACE/$IMAGE" \ + --no-push-cache \ + --credential-helpers=env \ + "$@" From 46b7509c37d92b50f5710f84ddccc2862daacf8e Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Nov 2026 11:57:18 +0100 Subject: [PATCH 8/8] chore: Reverts workflow changes The image build workflow is handled separately in the kaniko-build branch; this pull request no longer changes it. --- .forgejo/workflows/images.yml | 98 +++++++++++++---------------------- 1 file changed, 35 insertions(+), 63 deletions(-) diff --git a/.forgejo/workflows/images.yml b/.forgejo/workflows/images.yml index 30a037b..0306fee 100644 --- a/.forgejo/workflows/images.yml +++ b/.forgejo/workflows/images.yml @@ -4,82 +4,54 @@ on: push: workflow_dispatch: -# Images are only published for pushes to the main branch. All other refs (branches, -# renovate branches, tags) are built, but never pushed, and do not get registry credentials. -# -# Note: a workflow run for a pushed ref uses the workflow file from that ref, so this -# condition alone does not stop someone with push access from publishing images. The -# registry credentials must additionally be restricted to the protected main branch. - -env: - IMAGE_PREFIX: git.hamburg.ccc.de/ccchh/sunders - jobs: build: - runs-on: docker - container: - image: ghcr.io/osscontainertools/kaniko:alpine + runs-on: windows-2022 steps: - - name: Install required system packages - run: apk add --no-cache nodejs git - - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to Container Registry + uses: docker/login-action@v4 + with: + registry: git.hamburg.ccc.de + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + - name: Set image tags id: vars - env: - COMMIT_SHA: ${{ forgejo.sha }} - PUBLISH: ${{ forgejo.event_name == 'push' && forgejo.ref == 'refs/heads/main' }} run: | - if [ "$PUBLISH" = "true" ]; then - echo "publish=true" >> "$GITHUB_OUTPUT" - echo "tag=latest" >> "$GITHUB_OUTPUT" + if [ "${{ github.ref_name }}" = "main" ] && [ "${{ github.event_name }}" = "push" ]; then + echo "tag=latest" >> $GITHUB_OUTPUT else - echo "publish=false" >> "$GITHUB_OUTPUT" - # Never derive tags from ref names: they are attacker-controlled and may collide with `latest`. - echo "tag=ci-${COMMIT_SHA}" >> "$GITHUB_OUTPUT" + # renovate creates sub branches with `/`; these break the tagging in the build process + echo tag=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9._-]/_/g') >> $GITHUB_OUTPUT fi - echo "sha_tag=sha-${COMMIT_SHA}" >> "$GITHUB_OUTPUT" - name: Build web image - env: - IMAGE: web - PUBLISH: ${{ steps.vars.outputs.publish }} - TAG: ${{ steps.vars.outputs.tag }} - SHA_TAG: ${{ steps.vars.outputs.sha_tag }} - KANIKO_GIT_HAMBURG_CCC_DE_USER: ${{ steps.vars.outputs.publish == 'true' && secrets.REGISTRY_USERNAME || '' }} - KANIKO_GIT_HAMBURG_CCC_DE_PASSWORD: ${{ steps.vars.outputs.publish == 'true' && secrets.REGISTRY_TOKEN || '' }} run: | - if [ "$PUBLISH" = "true" ]; then - set -- --destination="$IMAGE_PREFIX/$IMAGE:$TAG" --destination="$IMAGE_PREFIX/$IMAGE:$SHA_TAG" - else - set -- --destination="$IMAGE_PREFIX/$IMAGE:$TAG" --no-push - fi - /kaniko/executor \ - --dockerfile="$GITHUB_WORKSPACE/$IMAGE/Containerfile" \ - --context="dir://$GITHUB_WORKSPACE/$IMAGE" \ - --no-push-cache \ - --credential-helpers=env \ - "$@" + docker buildx build -f ./Containerfile -t git.hamburg.ccc.de/ccchh/sunders/web:${{ steps.vars.outputs.tag }} --load . + working-directory: ./web - name: Build data_handler image - env: - IMAGE: data_handler - PUBLISH: ${{ steps.vars.outputs.publish }} - TAG: ${{ steps.vars.outputs.tag }} - SHA_TAG: ${{ steps.vars.outputs.sha_tag }} - KANIKO_GIT_HAMBURG_CCC_DE_USER: ${{ steps.vars.outputs.publish == 'true' && secrets.REGISTRY_USERNAME || '' }} - KANIKO_GIT_HAMBURG_CCC_DE_PASSWORD: ${{ steps.vars.outputs.publish == 'true' && secrets.REGISTRY_TOKEN || '' }} run: | - if [ "$PUBLISH" = "true" ]; then - set -- --destination="$IMAGE_PREFIX/$IMAGE:$TAG" --destination="$IMAGE_PREFIX/$IMAGE:$SHA_TAG" - else - set -- --destination="$IMAGE_PREFIX/$IMAGE:$TAG" --no-push - fi - /kaniko/executor \ - --dockerfile="$GITHUB_WORKSPACE/$IMAGE/Containerfile" \ - --context="dir://$GITHUB_WORKSPACE/$IMAGE" \ - --no-push-cache \ - --credential-helpers=env \ - "$@" + docker buildx build -f ./Containerfile -t git.hamburg.ccc.de/ccchh/sunders/data_handler:${{ steps.vars.outputs.tag }} --load . + working-directory: ./data_handler + + - name: Push images to Container Registry + run: | + docker push git.hamburg.ccc.de/ccchh/sunders/web:${{ steps.vars.outputs.tag }} + docker push git.hamburg.ccc.de/ccchh/sunders/data_handler:${{ steps.vars.outputs.tag }} + if: github.event_name == 'push' + + - name: Update docker-compose.yml image tags + run: | + sed -i "s/:latest/:${{ steps.vars.outputs.tag }}/g" docker-compose.yml + + - name: Start Docker Compose services + run: | + docker compose up -d --wait + docker compose down \ No newline at end of file