diff --git a/.github/workflows/cicd_5-lts.yml b/.github/workflows/cicd_5-lts.yml index e97641861ddb..e592bcf95a93 100644 --- a/.github/workflows/cicd_5-lts.yml +++ b/.github/workflows/cicd_5-lts.yml @@ -15,10 +15,17 @@ on: push: branches: - release-* + workflow_dispatch: + inputs: + version: + description: 'Build version for Docker image tagging (e.g. 26.04.28-03). Defaults to the revision in .mvn/maven.config when left empty.' + required: false + type: string + default: '' # Concurrency group to manage multiple runs concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }} + group: ${{ github.workflow }}-${{ github.ref || github.run_id }} # Cancel any in-progress runs for the same branch/PR to prevent delays from changes during build cancel-in-progress: true @@ -30,16 +37,43 @@ jobs: with: change-detection: 'enabled' + # Resolve the build version: use the workflow_dispatch input when provided, otherwise + # read -Drevision and -Dchangelist from .mvn/maven.config on the current branch. + # This ensures the Docker image tag always matches what Maven computes for project.version. + setup: + name: Resolve Version + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + outputs: + version: ${{ steps.resolve.outputs.version }} + steps: + - uses: actions/checkout@v4 + - name: Resolve version from input or maven.config + id: resolve + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ -n "$INPUT_VERSION" ]; then + if [[ ! "$INPUT_VERSION" =~ ^[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[0-9]{1,2}|_lts_v[0-9]{1,2})$ ]]; then + echo "::error::Invalid version format: '$INPUT_VERSION'. Expected format: yy.mm.dd-## (e.g. 26.04.28-03)" + exit 1 + fi + echo "version=$INPUT_VERSION" >> "$GITHUB_OUTPUT" + else + revision=$(grep -oP '(?<=-Drevision=)\S+' .mvn/maven.config 2>/dev/null || true) + changelist=$(grep -oP '(?<=-Dchangelist=)\S+' .mvn/maven.config 2>/dev/null || true) + echo "version=${revision}${changelist}" >> "$GITHUB_OUTPUT" + fi + # Build job - only runs if no artifacts were found during initialization build: name: PR Build - needs: [ initialize ] + needs: [ initialize, setup ] if: fromJSON(needs.initialize.outputs.filters).build == 'true' && needs.initialize.outputs.found_artifacts == 'false' uses: ./.github/workflows/cicd_comp_build-phase.yml with: core-build: true run-pr-checks: false - version: '24.12.27' + version: ${{ needs.setup.outputs.version }} permissions: contents: read packages: write diff --git a/dotCMS/src/main/java/com/dotcms/publisher/business/PublishAuditAPIImpl.java b/dotCMS/src/main/java/com/dotcms/publisher/business/PublishAuditAPIImpl.java index b875079b65e9..27da9af58f14 100644 --- a/dotCMS/src/main/java/com/dotcms/publisher/business/PublishAuditAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/publisher/business/PublishAuditAPIImpl.java @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; @@ -224,23 +225,29 @@ public PublishAuditStatus getPublishAuditStatus(String bundleId) @CloseDBIfOpened public List getPublishAuditStatuses(List bundleIds) throws DotPublisherException { + if (bundleIds == null || bundleIds.isEmpty()) { + return Collections.emptyList(); + } try { final List result = new ArrayList<>(); - DotConnect dc = new DotConnect(); - final List parameter = bundleIds.stream().map(id -> "'" + id + "'").collect(Collectors.toList()); + final DotConnect dc = new DotConnect(); + final String placeholders = bundleIds.stream() + .map(id -> "?") + .collect(Collectors.joining(",")); - dc.setSQL(String.format(SELECT_ALL_BY_BUNDLES_IDS, String.join(",", parameter))); - List> items = dc.loadObjectResults(); + dc.setSQL(String.format(SELECT_ALL_BY_BUNDLES_IDS, placeholders)); + bundleIds.forEach(dc::addParam); + final List> items = dc.loadObjectResults(); - for(Map item: items) { - result.add(turnIntoPublishAuditStatus(NO_LIMIT_ASSETS, item)); + for (final Map item : items) { + result.add(turnIntoPublishAuditStatus(NO_LIMIT_ASSETS, item)); } return result; - }catch(Exception e){ - Logger.debug(PublisherUtil.class,e.getMessage(),e); - throw new DotPublisherException("Unable to get list of elements with error:"+e.getMessage(), e); + } catch (Exception e) { + Logger.error(PublishAuditAPIImpl.class, e.getMessage(), e); + throw new DotPublisherException("Unable to get list of elements with error:" + e.getMessage(), e); } } diff --git a/dotCMS/src/main/java/com/dotcms/publisher/business/PublisherQueueJob.java b/dotCMS/src/main/java/com/dotcms/publisher/business/PublisherQueueJob.java index 77a080228c90..1d43436d039f 100644 --- a/dotCMS/src/main/java/com/dotcms/publisher/business/PublisherQueueJob.java +++ b/dotCMS/src/main/java/com/dotcms/publisher/business/PublisherQueueJob.java @@ -15,6 +15,7 @@ import com.dotcms.publisher.endpoint.business.PublishingEndPointAPI; import com.dotcms.publisher.environment.bean.Environment; import com.dotcms.publisher.environment.business.EnvironmentAPI; +import com.dotcms.publisher.pusher.AuthCredentialPushPublishUtil; import com.dotcms.publisher.pusher.PushPublisher; import com.dotcms.publisher.pusher.PushPublisherConfig; import com.dotcms.publisher.util.PublisherUtil; @@ -653,6 +654,7 @@ private List getRemoteHistoryFromEndpoint(final List failResponse = PushPublishResourceUtil.getFailResponse(request, ppAuthToken); + + if (failResponse.isPresent()) { + return failResponse.get(); + } + PublishAuditStatus status = null; try { @@ -42,7 +54,18 @@ public Response get(@PathParam("bundleId") String bundleId) { @POST @Path("/getAll") @Produces(MediaType.APPLICATION_JSON) - public Response getAll( List bundleIds) { + public Response getAll(final List bundleIds, + @Context final HttpServletRequest request) { + + final AuthCredentialPushPublishUtil.PushPublishAuthenticationToken ppAuthToken = + AuthCredentialPushPublishUtil.INSTANCE.processAuthHeader(request); + + final Optional failResponse = PushPublishResourceUtil.getFailResponse(request, ppAuthToken); + + if (failResponse.isPresent()) { + return failResponse.get(); + } + try { final List statuses = auditAPI.getPublishAuditStatuses(bundleIds); diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite2a.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite2a.java index 88c1db2012ab..269a6592f6f2 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite2a.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite2a.java @@ -105,7 +105,8 @@ com.dotmarketing.portlets.categories.business.CategoryAPITest.class, com.dotmarketing.filters.FiltersTest.class, InterceptorHandlerTest.class, - com.dotcms.graphql.datafetcher.page.NumberContentsDataFetcherTest.class + com.dotcms.graphql.datafetcher.page.NumberContentsDataFetcherTest.class, + com.dotcms.rest.AuditPublishingResourceTest.class, }) public class MainSuite2a { diff --git a/dotcms-integration/src/test/java/com/dotcms/publisher/business/PublishAuditAPITest.java b/dotcms-integration/src/test/java/com/dotcms/publisher/business/PublishAuditAPITest.java index 543cd2facc38..52cce77af11a 100644 --- a/dotcms-integration/src/test/java/com/dotcms/publisher/business/PublishAuditAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/publisher/business/PublishAuditAPITest.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -386,6 +387,101 @@ public void allThePublishAuditHistoryWithOneAssets() throws DotPublisherExceptio }); } + /** + * Method to test: {@link PublishAuditAPI#getPublishAuditStatuses(List)} + * When: three audit statuses are inserted and two of them are requested by id + * Should: return exactly the two requested rows. + */ + @Test + public void test_getPublishAuditStatuses_returnsRequestedRows() throws DotPublisherException { + final String bundleId1 = UUIDGenerator.generateUuid(); + final String bundleId2 = UUIDGenerator.generateUuid(); + final String bundleId3 = UUIDGenerator.generateUuid(); + insertPublishAuditStatus(Status.SUCCESS, bundleId1); + insertPublishAuditStatus(Status.FAILED_TO_PUBLISH, bundleId2); + insertPublishAuditStatus(Status.SUCCESS, bundleId3); + + try { + final List result = publishAuditAPI.getPublishAuditStatuses( + Arrays.asList(bundleId1, bundleId2)); + + assertNotNull(result); + assertEquals(2, result.size()); + final Set ids = result.stream() + .map(PublishAuditStatus::getBundleId) + .collect(Collectors.toSet()); + assertTrue(ids.contains(bundleId1)); + assertTrue(ids.contains(bundleId2)); + assertFalse(ids.contains(bundleId3)); + } finally { + publishAuditAPI.deletePublishAuditStatus(bundleId1); + publishAuditAPI.deletePublishAuditStatus(bundleId2); + publishAuditAPI.deletePublishAuditStatus(bundleId3); + } + } + + /** + * Method to test: {@link PublishAuditAPI#getPublishAuditStatuses(List)} + * When: an empty list is passed + * Should: return an empty result, not throw or produce invalid SQL. + */ + @Test + public void test_getPublishAuditStatuses_emptyList_returnsEmpty() throws DotPublisherException { + final List result = publishAuditAPI.getPublishAuditStatuses(Collections.emptyList()); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + /** + * Method to test: {@link PublishAuditAPI#getPublishAuditStatuses(List)} + * When: null is passed + * Should: return an empty result, not throw NPE. + */ + @Test + public void test_getPublishAuditStatuses_nullList_returnsEmpty() throws DotPublisherException { + final List result = publishAuditAPI.getPublishAuditStatuses(null); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + /** + * Method to test: {@link PublishAuditAPI#getPublishAuditStatuses(List)} + * When: bundle ids contain SQL meta-characters (single quotes, OR 1=1, ; DROP TABLE) + * Should: bind the values as parameters, return an empty result, leave the + * publishing_queue_audit table intact. + * + * Regression test for the previous String.format-based IN-clause that + * concatenated quote-wrapped ids directly into the SQL. + */ + @Test + public void test_getPublishAuditStatuses_neutralizesSqlInjectionAttempts() throws DotPublisherException { + final String realBundleId = UUIDGenerator.generateUuid(); + insertPublishAuditStatus(Status.SUCCESS, realBundleId); + + try { + final List maliciousIds = Arrays.asList( + "x' OR '1'='1", + "x'; DROP TABLE publishing_queue_audit; --", + "x' UNION SELECT '" + realBundleId + "' --", + "x'/*", + "x\\' OR \\'1\\'=\\'1" + ); + + final List result = publishAuditAPI.getPublishAuditStatuses(maliciousIds); + + assertNotNull(result); + assertTrue("Malicious bundle ids should not match any rows; got " + + result.size() + " rows back, indicating values were not bound as parameters", + result.isEmpty()); + + final PublishAuditStatus stillThere = publishAuditAPI.getPublishAuditStatus(realBundleId); + assertNotNull("publishing_queue_audit row was lost after SQL injection attempt — " + + "DROP TABLE payload may have executed", stillThere); + } finally { + publishAuditAPI.deletePublishAuditStatus(realBundleId); + } + } + @Test public void test_getPublishAuditStatusByFilter() throws DotPublisherException, DotDataException { diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/AuditPublishingResourceTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/AuditPublishingResourceTest.java new file mode 100644 index 000000000000..b176e7e8d97d --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/rest/AuditPublishingResourceTest.java @@ -0,0 +1,51 @@ +package com.dotcms.rest; + +import com.dotcms.IntegrationTestBase; +import com.dotcms.util.IntegrationTestInitService; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; +import java.util.Collections; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; + +/** + * Verifies that {@link AuditPublishingResource} enforces push-publish token + * authentication on its endpoints. Both methods were previously reachable + * anonymously; they now require a valid PP auth token. + */ +public class AuditPublishingResourceTest extends IntegrationTestBase { + + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + } + + /** + * Method to test: {@link AuditPublishingResource#get(String, HttpServletRequest)} + * When: called with a request that carries no push-publish auth token + * Should: return 401 Unauthorized. + */ + @Test + public void test_get_rejectsAnonymousRequest() { + final HttpServletRequest request = mock(HttpServletRequest.class); + final Response response = new AuditPublishingResource().get("any-bundle-id", request); + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + } + + /** + * Method to test: {@link AuditPublishingResource#getAll(java.util.List, HttpServletRequest)} + * When: called with a request that carries no push-publish auth token + * Should: return 401 Unauthorized. + */ + @Test + public void test_getAll_rejectsAnonymousRequest() { + final HttpServletRequest request = mock(HttpServletRequest.class); + final Response response = new AuditPublishingResource() + .getAll(Collections.singletonList("any-bundle-id"), request); + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + } +} \ No newline at end of file diff --git a/dotcms-postman/src/main/resources/postman/Push_Publish_JWT_Token_Test.postman_collection.json b/dotcms-postman/src/main/resources/postman/Push_Publish_JWT_Token_Test.postman_collection.json index fcb1467194e1..6f0ef2cdb39d 100644 --- a/dotcms-postman/src/main/resources/postman/Push_Publish_JWT_Token_Test.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/Push_Publish_JWT_Token_Test.postman_collection.json @@ -284,6 +284,16 @@ } ], "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + }, "method": "GET", "header": [], "url": {