From 2aa481cd1b234fd45ed5c3990e2e517f3047d1c7 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 2 Jul 2026 18:45:26 -0400 Subject: [PATCH 1/2] Require authentication for Forge analytics report endpoint The Forge analytics `POST /analytics/report` endpoint previously accepted unauthenticated writes, letting anyone inject fabricated project-generation statistics. Secure it with a Google-issued bearer JWT that is validated by signature (Google JWKS), issuer, audience, and a caller-subject claim pinned to the Forge web service account's unique id, so only the intended caller can report analytics. Public read endpoints (`GET /top/*`) stay anonymous. Also add an HSTS response filter to the Forge web (Netty) app and wire the analytics audience and caller-subject env vars through the Cloud Run deploy workflows and README. Assisted-by: claude-code:claude-opus-4-8 --- .github/workflows/forge-deploy-next.yml | 5 +- .../workflows/forge-deploy-prev-snapshot.yml | 5 +- .github/workflows/forge-deploy-prev.yml | 5 +- .github/workflows/forge-deploy-release.yml | 9 +- .github/workflows/forge-deploy-snapshot.yml | 5 +- grails-forge/README.md | 8 +- .../build.gradle | 10 ++ .../AnalyticsCallerJwtClaimsValidator.java | 44 +++++ .../postgres/AnalyticsController.java | 8 + .../src/main/resources/application.yml | 18 ++ .../AnalyticsControllerSecuritySpec.groovy | 162 ++++++++++++++++++ .../StoreGeneratedProjectStatsSpec.groovy | 36 ++-- .../grails-forge-web-netty/build.gradle | 7 + .../netty/security/HstsHeaderFilter.java | 45 +++++ .../src/main/resources/application.yml | 9 +- .../security/HstsHeaderFilterSpec.groovy | 54 ++++++ 16 files changed, 404 insertions(+), 26 deletions(-) create mode 100644 grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsCallerJwtClaimsValidator.java create mode 100644 grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy create mode 100644 grails-forge/grails-forge-web-netty/src/main/java/org/grails/forge/netty/security/HstsHeaderFilter.java create mode 100644 grails-forge/grails-forge-web-netty/src/test/groovy/org/grails/forge/netty/security/HstsHeaderFilterSpec.groovy diff --git a/.github/workflows/forge-deploy-next.yml b/.github/workflows/forge-deploy-next.yml index 2774c362f3c..c3b418a8f12 100644 --- a/.github/workflows/forge-deploy-next.yml +++ b/.github/workflows/forge-deploy-next.yml @@ -98,7 +98,7 @@ jobs: gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-next --image $IMAGE_NAME --region us-central1 - --update-env-vars=HOSTNAME="next.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }} + --update-env-vars=HOSTNAME="next.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }},GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-next-cjmq3uyfcq-uc.a.run.app" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} @@ -155,4 +155,5 @@ jobs: - name: "🚀 Deploy Docker image" run: | gcloud components install beta --quiet - gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-next --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} + CALLER_SUBJECT="$(gcloud iam service-accounts describe ${{ secrets.GCLOUD_EMAIL }} --format='value(uniqueId)')" + gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-next --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-next-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} diff --git a/.github/workflows/forge-deploy-prev-snapshot.yml b/.github/workflows/forge-deploy-prev-snapshot.yml index 188a6cdf1fe..d0a91922e78 100644 --- a/.github/workflows/forge-deploy-prev-snapshot.yml +++ b/.github/workflows/forge-deploy-prev-snapshot.yml @@ -98,7 +98,7 @@ jobs: gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-prev-snapshot --image $IMAGE_NAME --region us-central1 - --update-env-vars=HOSTNAME="prev-snapshot.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }} + --update-env-vars=HOSTNAME="prev-snapshot.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }},GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-prev-snapshot-cjmq3uyfcq-uc.a.run.app" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} @@ -155,4 +155,5 @@ jobs: - name: "🚀 Deploy Docker image" run: | gcloud components install beta --quiet - gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-prev-snapshot --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} \ No newline at end of file + CALLER_SUBJECT="$(gcloud iam service-accounts describe ${{ secrets.GCLOUD_EMAIL }} --format='value(uniqueId)')" + gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-prev-snapshot --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-prev-snapshot-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} diff --git a/.github/workflows/forge-deploy-prev.yml b/.github/workflows/forge-deploy-prev.yml index 5ffc0ab89b4..11715a4ce4d 100644 --- a/.github/workflows/forge-deploy-prev.yml +++ b/.github/workflows/forge-deploy-prev.yml @@ -98,7 +98,7 @@ jobs: gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-prev --image $IMAGE_NAME --region us-central1 - --update-env-vars=HOSTNAME="prev.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }} + --update-env-vars=HOSTNAME="prev.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }},GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-prev-cjmq3uyfcq-uc.a.run.app" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} @@ -155,4 +155,5 @@ jobs: - name: "🚀 Deploy Docker image" run: | gcloud components install beta --quiet - gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-prev --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} \ No newline at end of file + CALLER_SUBJECT="$(gcloud iam service-accounts describe ${{ secrets.GCLOUD_EMAIL }} --format='value(uniqueId)')" + gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-prev --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-prev-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} diff --git a/.github/workflows/forge-deploy-release.yml b/.github/workflows/forge-deploy-release.yml index 164b74295d9..16e5fd7154b 100644 --- a/.github/workflows/forge-deploy-release.yml +++ b/.github/workflows/forge-deploy-release.yml @@ -103,9 +103,9 @@ jobs: release_version: ${{ needs.build.outputs.release_version }} IMAGE_NAME: us-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_APP_NAME }}/${{ secrets.GCP_APP_NAME }}:${{ needs.build.outputs.release_version }} run: | - gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-latest --image $IMAGE_NAME --region us-central1 --update-env-vars=HOSTNAME="latest.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_LATEST_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_LATEST_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }} --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} + gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-latest --image $IMAGE_NAME --region us-central1 --update-env-vars=HOSTNAME="latest.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_LATEST_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_LATEST_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }},GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-latest-cjmq3uyfcq-uc.a.run.app" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} version="$(echo "${release_version//./}" | tr '[A-Z]' '[a-z]')" - gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-$version --image $IMAGE_NAME --region us-central1 --update-env-vars=HOSTNAME="grailsforge-600-cjmq3uyfcq-uc.a.run.app",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_LATEST_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_LATEST_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }} --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} + gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-$version --image $IMAGE_NAME --region us-central1 --update-env-vars=HOSTNAME="grailsforge-600-cjmq3uyfcq-uc.a.run.app",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_LATEST_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_LATEST_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }},GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-$version-cjmq3uyfcq-uc.a.run.app" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} deployAnalytics: name: "Deploy analytics to Google Cloud Run" runs-on: ubuntu-24.04 @@ -163,6 +163,7 @@ jobs: IMAGE_NAME: us-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_APP_NAME }}/${{ secrets.GCP_APP_NAME }}-analytics:${{ needs.build.outputs.release_version }} run: | gcloud components install beta --quiet - gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-latest --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} + CALLER_SUBJECT="$(gcloud iam service-accounts describe ${{ secrets.GCLOUD_EMAIL }} --format='value(uniqueId)')" + gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-latest --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-latest-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} version="$(echo "${release_version//./}" | tr '[A-Z]' '[a-z]')" - gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-$version --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} + gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-$version --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-$version-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} diff --git a/.github/workflows/forge-deploy-snapshot.yml b/.github/workflows/forge-deploy-snapshot.yml index c880e7bc80f..c302ed078fc 100644 --- a/.github/workflows/forge-deploy-snapshot.yml +++ b/.github/workflows/forge-deploy-snapshot.yml @@ -96,7 +96,7 @@ jobs: gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-snapshot --image $IMAGE_NAME --region us-central1 - --update-env-vars=HOSTNAME="snapshot.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }} + --update-env-vars=HOSTNAME="snapshot.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }},GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-snapshot-cjmq3uyfcq-uc.a.run.app" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} @@ -151,4 +151,5 @@ jobs: - name: "🚀 Deploy Docker image" run: | gcloud components install beta --quiet - gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-snapshot --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} + CALLER_SUBJECT="$(gcloud iam service-accounts describe ${{ secrets.GCLOUD_EMAIL }} --format='value(uniqueId)')" + gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-snapshot --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-snapshot-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }} diff --git a/grails-forge/README.md b/grails-forge/README.md index b24f32fb13e..ecc8626bf71 100644 --- a/grails-forge/README.md +++ b/grails-forge/README.md @@ -201,6 +201,7 @@ Create two databases `grailsforge-production` and `grailsforge-snapshot` | `MICRONAUT_ENVIRONMENTS` | `gcp` | | `MICRONAUT_HTTP_SERVICES_ANALYTICS_READ_TIMEOUT` | `20s` | | `MICRONAUT_HTTP_SERVICES_ANALYTICS_URL` | URL of cloud run `grailsforge-analytics-latest` | +| `GRAILS_FORGE_ANALYTICS_AUDIENCE` | URL of cloud run `grailsforge-analytics-latest` | | `GITHUB_OAUTH_APP_CLIENT_ID` | The Client ID shared after creating GitHub OAuth App | | `GITHUB_OAUTH_APP_CLIENT_SECRET` | The Client secret shared after creating the GitHub OAuth app | | `GITHUB_USER_AGENT` | URL of the grails-forge-ui static website. For example, `https://start.grails.org/`. The default value is `https://start.grails.org/`. | @@ -214,6 +215,8 @@ Create two databases `grailsforge-production` and `grailsforge-snapshot` | `DB_NAME` | `grailsforge-production` | | `MICRONAUT_ENV_DEDUCTION` | `false` | | `MICRONAUT_ENVIRONMENTS` | `gcp` | +| `GRAILS_FORGE_ANALYTICS_AUDIENCE` | URL of cloud run `grailsforge-analytics-latest` | +| `GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT` | Unique ID of the service account used by `grailsforge-latest` | | `DATASOURCES_DEFAULT_USERNAME` | `***` | | `DATASOURCES_DEFAULT_PASSWORD` | `***` | | `DATASOURCES_DEFAULT_URL` | `jdbc:postgresql:///grailsforge-production` | @@ -229,6 +232,7 @@ Create two databases `grailsforge-production` and `grailsforge-snapshot` | `MICRONAUT_ENVIRONMENTS` | `gcp` | | `MICRONAUT_HTTP_SERVICES_ANALYTICS_READ_TIMEOUT` | `20s` | | `MICRONAUT_HTTP_SERVICES_ANALYTICS_URL` | URL of cloud run `grailsforge-analytics-snapshot` | +| `GRAILS_FORGE_ANALYTICS_AUDIENCE` | URL of cloud run `grailsforge-analytics-snapshot` | | `GITHUB_OAUTH_APP_CLIENT_ID` | The Client ID shared after creating GitHub OAuth App | | `GITHUB_OAUTH_APP_CLIENT_SECRET` | The Client secret shared after creating the GitHub OAuth app | | `GITHUB_USER_AGENT` | URL of the grails-forge-ui static website. The default value is `https://start.grails.org/`. | @@ -243,8 +247,8 @@ Create two databases `grailsforge-production` and `grailsforge-snapshot` | `DB_NAME` | `grailsforge-snapshot` | | `MICRONAUT_ENV_DEDUCTION` | `false` | | `MICRONAUT_ENVIRONMENTS` | `gcp` | +| `GRAILS_FORGE_ANALYTICS_AUDIENCE` | URL of cloud run `grailsforge-analytics-snapshot` | +| `GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT` | Unique ID of the service account used by `grailsforge-snapshot` | | `DATASOURCES_DEFAULT_USERNAME` | `***` | | `DATASOURCES_DEFAULT_PASSWORD` | `***` | | `DATASOURCES_DEFAULT_URL` | `jdbc:postgresql:///grailsforge-snapshot` | - - diff --git a/grails-forge/grails-forge-analytics-postgres/build.gradle b/grails-forge/grails-forge-analytics-postgres/build.gradle index 04fe266cf81..1d05f52337a 100644 --- a/grails-forge/grails-forge-analytics-postgres/build.gradle +++ b/grails-forge/grails-forge-analytics-postgres/build.gradle @@ -18,6 +18,7 @@ */ plugins { + id 'groovy' id 'org.apache.grails.buildsrc.properties' id 'org.apache.grails.buildsrc.dependency-validator' id 'io.micronaut.application' version "$micronautApplicationPluginVersion" @@ -37,6 +38,7 @@ dependencies { implementation "com.google.cloud.sql:postgres-socket-factory:$postgresSocketFactoryVersion" implementation 'io.micronaut.data:micronaut-data-jdbc' implementation 'io.micronaut.flyway:micronaut-flyway' + implementation 'io.micronaut.security:micronaut-security-jwt' implementation 'io.micronaut.sql:micronaut-jdbc-hikari' implementation 'org.postgresql:postgresql' @@ -54,7 +56,15 @@ dependencies { testImplementation 'ch.qos.logback:logback-classic' testImplementation 'io.micronaut:micronaut-http-client' + testImplementation 'io.micronaut.test:micronaut-test-spock' + testImplementation 'org.apache.groovy:groovy' + testImplementation "org.spockframework:spock-core", { + exclude group: 'org.apache.groovy', module: 'groovy-all' + } + testImplementation 'org.apache.groovy:groovy-test' testImplementation 'org.testcontainers:postgresql' + testRuntimeOnly 'net.bytebuddy:byte-buddy' + testRuntimeOnly "org.objenesis:objenesis:$objenesisVersion" } application { mainClass = 'org.grails.forge.analytics.postgres.Main' diff --git a/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsCallerJwtClaimsValidator.java b/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsCallerJwtClaimsValidator.java new file mode 100644 index 00000000000..23ca0607556 --- /dev/null +++ b/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsCallerJwtClaimsValidator.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.forge.analytics.postgres; + +import io.micronaut.context.annotation.Value; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.security.token.Claims; +import io.micronaut.security.token.jwt.validator.GenericJwtClaimsValidator; + +import jakarta.inject.Singleton; + +@Singleton +public class AnalyticsCallerJwtClaimsValidator implements GenericJwtClaimsValidator { + + static final String CALLER_SUBJECT_PROPERTY = "grails.forge.analytics.caller-subject"; + + private final String callerSubject; + + public AnalyticsCallerJwtClaimsValidator(@Value("${" + CALLER_SUBJECT_PROPERTY + "}") String callerSubject) { + this.callerSubject = callerSubject; + } + + @Override + public boolean validate(@NonNull Claims claims, T request) { + Object subject = claims.get(Claims.SUBJECT); + return callerSubject.equals(subject); + } +} diff --git a/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java b/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java index 2ac0279773d..8ce49d80b9b 100644 --- a/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java +++ b/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java @@ -26,6 +26,8 @@ import io.micronaut.http.annotation.Post; import io.micronaut.scheduling.TaskExecutors; import io.micronaut.scheduling.annotation.ExecuteOn; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.rules.SecurityRule; import org.grails.forge.analytics.Generated; import jakarta.transaction.Transactional; @@ -47,26 +49,31 @@ public AnalyticsController( } @Get("/top/features") + @Secured(SecurityRule.IS_ANONYMOUS) List topFeatures() { return featureRepository.topFeatures(); } @Get("/top/jdks") + @Secured(SecurityRule.IS_ANONYMOUS) List topJdks() { return featureRepository.topJdkVersion(); } @Get("/top/buildTools") + @Secured(SecurityRule.IS_ANONYMOUS) List topBuilds() { return featureRepository.topBuildTools(); } @Get("/top/gorm") + @Secured(SecurityRule.IS_ANONYMOUS) List topGorm() { return featureRepository.topGorm(); } @Get("/top/reloading") + @Secured(SecurityRule.IS_ANONYMOUS) List topReloading() { return featureRepository.topReloading(); } @@ -77,6 +84,7 @@ List topReloading() { * @return A future */ @Post("/report") + @Secured(SecurityRule.IS_AUTHENTICATED) @Transactional @ExecuteOn(TaskExecutors.IO) HttpStatus applicationGenerated(@NonNull @Body Generated generated) { diff --git a/grails-forge/grails-forge-analytics-postgres/src/main/resources/application.yml b/grails-forge/grails-forge-analytics-postgres/src/main/resources/application.yml index 5065d27cfa8..c8595b54015 100644 --- a/grails-forge/grails-forge-analytics-postgres/src/main/resources/application.yml +++ b/grails-forge/grails-forge-analytics-postgres/src/main/resources/application.yml @@ -6,6 +6,24 @@ datasources: #password: 'secret' driver-class-name: org.postgresql.Driver dialect: POSTGRES +grails: + forge: + analytics: + caller-subject: ${GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT} +micronaut: + security: + authentication: bearer + redirect: + enabled: false + token: + jwt: + claims-validators: + issuer: https://accounts.google.com + audience: ${GRAILS_FORGE_ANALYTICS_AUDIENCE} + signatures: + jwks: + google: + url: https://www.googleapis.com/oauth2/v3/certs --- flyway: enabled: enabled diff --git a/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy b/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy new file mode 100644 index 00000000000..19b64ba8892 --- /dev/null +++ b/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.grails.forge.analytics.postgres + +import io.micronaut.context.annotation.Property +import io.micronaut.core.annotation.NonNull +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpStatus +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.data.jdbc.runtime.JdbcOperations +import io.micronaut.security.token.generator.TokenGenerator +import io.micronaut.test.annotation.MockBean +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import org.grails.forge.analytics.Generated +import org.grails.forge.analytics.SelectedFeature +import org.grails.forge.application.ApplicationType +import org.grails.forge.options.DevelopmentReloading +import org.grails.forge.options.GormImpl +import org.grails.forge.options.JdkVersion +import org.grails.forge.options.ServletImpl +import spock.lang.Specification + +import jakarta.inject.Inject + +@MicronautTest +@Property(name = 'datasources.default.enabled', value = 'false') +@Property(name = 'flyway.enabled', value = 'false') +@Property(name = 'micronaut.security.authentication', value = 'bearer') +@Property(name = 'micronaut.security.redirect.enabled', value = 'false') +@Property(name = 'micronaut.security.token.jwt.claims-validators.audience', value = 'forge-analytics') +@Property(name = 'micronaut.security.token.jwt.claims-validators.issuer', value = 'forge-test') +@Property(name = 'micronaut.security.token.jwt.signatures.secret.generator.secret', value = 'pleaseChangeThisSecretForANewOneAndMakeItLongEnough') +@Property(name = 'grails.forge.analytics.caller-subject', value = '1234567890') +class AnalyticsControllerSecuritySpec extends Specification { + + @Inject @Client('/analytics') HttpClient client + @Inject ApplicationRepository applicationRepository + @Inject JdbcOperations jdbcOperations + @Inject TokenGenerator tokenGenerator + + void 'report generation data requires bearer authentication'() { + given: + Generated generated = new Generated( + ApplicationType.WEB, + GormImpl.HIBERNATE, + ServletImpl.TOMCAT, + DevelopmentReloading.DEVTOOLS, + JdkVersion.DEFAULT_OPTION + ) + generated.setSelectedFeatures([new SelectedFeature('google-cloud-function')]) + + when: + client.toBlocking().exchange(HttpRequest.POST('/report', generated), String) + + then: + HttpClientResponseException e = thrown(HttpClientResponseException) + e.status == HttpStatus.UNAUTHORIZED + 0 * applicationRepository._ + 0 * jdbcOperations._ + } + + void 'top analytics data is not rejected as unauthenticated'() { + when: + client.toBlocking().exchange(HttpRequest.GET('/top/features'), String) + + then: + HttpClientResponseException e = thrown(HttpClientResponseException) + e.status == HttpStatus.INTERNAL_SERVER_ERROR + 0 * applicationRepository._ + } + + void 'report generation data rejects bearer token with wrong audience'() { + given: + Generated generated = new Generated( + ApplicationType.WEB, + GormImpl.HIBERNATE, + ServletImpl.TOMCAT, + DevelopmentReloading.DEVTOOLS, + JdkVersion.DEFAULT_OPTION + ) + generated.setSelectedFeatures([new SelectedFeature('google-cloud-function')]) + + when: + client.toBlocking().exchange(HttpRequest.POST('/report', generated).bearerAuth(wrongAudienceToken()), String) + + then: + HttpClientResponseException e = thrown(HttpClientResponseException) + e.status == HttpStatus.UNAUTHORIZED + 0 * applicationRepository._ + 0 * jdbcOperations._ + } + + void 'report generation data rejects bearer token from wrong caller'() { + given: + Generated generated = new Generated( + ApplicationType.WEB, + GormImpl.HIBERNATE, + ServletImpl.TOMCAT, + DevelopmentReloading.DEVTOOLS, + JdkVersion.DEFAULT_OPTION + ) + generated.setSelectedFeatures([new SelectedFeature('google-cloud-function')]) + + when: + client.toBlocking().exchange(HttpRequest.POST('/report', generated).bearerAuth(wrongCallerToken()), String) + + then: + HttpClientResponseException e = thrown(HttpClientResponseException) + e.status == HttpStatus.UNAUTHORIZED + 0 * applicationRepository._ + 0 * jdbcOperations._ + } + + @MockBean(ApplicationRepository) + @NonNull + ApplicationRepository applicationRepository() { + Mock(ApplicationRepository) + } + + @MockBean(JdbcOperations) + @NonNull + JdbcOperations jdbcOperations() { + Mock(JdbcOperations) + } + + @NonNull + private String wrongAudienceToken() { + tokenGenerator.generateToken([ + sub: '1234567890', + iss: 'forge-test', + aud: 'other-service' + ]).orElseThrow() + } + + @NonNull + private String wrongCallerToken() { + tokenGenerator.generateToken([ + sub: 'other-caller', + iss: 'forge-test', + aud: 'forge-analytics' + ]).orElseThrow() + } +} diff --git a/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/StoreGeneratedProjectStatsSpec.groovy b/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/StoreGeneratedProjectStatsSpec.groovy index 681b7696424..3d467fe6609 100644 --- a/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/StoreGeneratedProjectStatsSpec.groovy +++ b/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/StoreGeneratedProjectStatsSpec.groovy @@ -22,11 +22,13 @@ package org.grails.forge.analytics.postgres import io.micronaut.context.env.Environment import io.micronaut.core.annotation.NonNull import io.micronaut.data.model.Pageable +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse import io.micronaut.data.model.query.builder.sql.Dialect import io.micronaut.http.HttpStatus -import io.micronaut.http.annotation.Body -import io.micronaut.http.annotation.Post import io.micronaut.http.client.annotation.Client +import io.micronaut.http.client.HttpClient +import io.micronaut.security.token.generator.TokenGenerator import org.grails.forge.analytics.Generated import org.grails.forge.analytics.SelectedFeature import org.grails.forge.application.ApplicationType @@ -43,7 +45,6 @@ import spock.lang.Shared import spock.lang.Specification import jakarta.inject.Inject -import java.util.concurrent.CompletableFuture @MicronautTest( transactional = false, @@ -62,12 +63,17 @@ class StoreGeneratedProjectStatsSpec extends Specification implements TestProper ["datasources.default.url":postgres.getJdbcUrl(), "datasources.default.username":postgres.getUsername(), "datasources.default.password":postgres.getPassword(), - "datasources.default.dialect": Dialect.POSTGRES.name()] + "datasources.default.dialect": Dialect.POSTGRES.name(), + "micronaut.security.token.jwt.claims-validators.audience":"forge-analytics", + "micronaut.security.token.jwt.claims-validators.issuer":"forge-test", + "micronaut.security.token.jwt.signatures.secret.generator.secret":"pleaseChangeThisSecretForANewOneAndMakeItLongEnough", + "grails.forge.analytics.caller-subject":"1234567890"] } - @Inject AnalyticsClient client + @Inject @Client('/analytics') HttpClient client @Inject ApplicationRepository repository @Inject FeatureRepository featureRepository + @Inject TokenGenerator tokenGenerator void "test save generation data"() { given: @@ -81,10 +87,10 @@ class StoreGeneratedProjectStatsSpec extends Specification implements TestProper generated.setSelectedFeatures([new SelectedFeature("google-cloud-function")]) when: - HttpStatus status = client.applicationGenerated(generated).get() + HttpResponse response = client.toBlocking().exchange(authorizedRequest(generated)) then: - status == HttpStatus.ACCEPTED + response.status == HttpStatus.ACCEPTED when: def application = repository.list(Pageable.UNPAGED)[0] @@ -117,9 +123,17 @@ class StoreGeneratedProjectStatsSpec extends Specification implements TestProper featureRepository.topReloading() } - @Client("/analytics") - static interface AnalyticsClient { - @Post("/report") - CompletableFuture applicationGenerated(@NonNull @Body Generated generated); + @NonNull + private HttpRequest authorizedRequest(@NonNull Generated generated) { + HttpRequest.POST('/report', generated).bearerAuth(bearerToken()) + } + + @NonNull + private String bearerToken() { + tokenGenerator.generateToken([ + sub: '1234567890', + iss: 'forge-test', + aud: 'forge-analytics' + ]).orElseThrow() } } diff --git a/grails-forge/grails-forge-web-netty/build.gradle b/grails-forge/grails-forge-web-netty/build.gradle index c0fa95e7d56..41a59b416a8 100644 --- a/grails-forge/grails-forge-web-netty/build.gradle +++ b/grails-forge/grails-forge-web-netty/build.gradle @@ -19,6 +19,7 @@ import org.gradle.api.tasks.JavaExec plugins { + id 'groovy' id 'org.apache.grails.buildsrc.properties' id 'org.apache.grails.buildsrc.dependency-validator' id 'io.micronaut.application' version "$micronautApplicationPluginVersion" @@ -50,8 +51,14 @@ dependencies { testImplementation 'io.micronaut:micronaut-http-client' testImplementation 'io.micronaut.test:micronaut-test-spock' + testImplementation 'org.apache.groovy:groovy' + testImplementation "org.spockframework:spock-core", { + exclude group: 'org.apache.groovy', module: 'groovy-all' + } + testImplementation 'org.apache.groovy:groovy-test' testCompileOnly 'io.micronaut:micronaut-inject-groovy' + testRuntimeOnly "org.objenesis:objenesis:$objenesisVersion" } tasks.named('test', Test) { useJUnitPlatform() diff --git a/grails-forge/grails-forge-web-netty/src/main/java/org/grails/forge/netty/security/HstsHeaderFilter.java b/grails-forge/grails-forge-web-netty/src/main/java/org/grails/forge/netty/security/HstsHeaderFilter.java new file mode 100644 index 00000000000..72738b0303b --- /dev/null +++ b/grails-forge/grails-forge-web-netty/src/main/java/org/grails/forge/netty/security/HstsHeaderFilter.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.forge.netty.security; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.annotation.Value; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.ResponseFilter; +import io.micronaut.http.annotation.ServerFilter; + +@Requires(property = HstsHeaderFilter.ENABLED_PROPERTY, value = "true") +@ServerFilter(ServerFilter.MATCH_ALL_PATTERN) +public class HstsHeaderFilter { + + static final String ENABLED_PROPERTY = "grails.forge.security.hsts.enabled"; + static final String HEADER_NAME = "Strict-Transport-Security"; + + private final String headerValue; + + public HstsHeaderFilter(@Value("${grails.forge.security.hsts.header-value}") @NonNull String headerValue) { + this.headerValue = headerValue; + } + + @ResponseFilter + void filterResponse(@NonNull MutableHttpResponse response) { + response.getHeaders().set(HEADER_NAME, headerValue); + } +} diff --git a/grails-forge/grails-forge-web-netty/src/main/resources/application.yml b/grails-forge/grails-forge-web-netty/src/main/resources/application.yml index 5c1997791ab..3d54e0cc9f8 100644 --- a/grails-forge/grails-forge-web-netty/src/main/resources/application.yml +++ b/grails-forge/grails-forge-web-netty/src/main/resources/application.yml @@ -29,6 +29,10 @@ micronaut: url: https://api.github.com grails: forge: + security: + hsts: + enabled: ${GRAILS_FORGE_HSTS_ENABLED:true} + header-value: ${GRAILS_FORGE_HSTS_HEADER:max-age=31536000; includeSubDomains; preload} redirect-url: ${GITHUB_REDIRECT_URL:'https://start.grails.org/'} github: client-id: ${GITHUB_OAUTH_APP_CLIENT_ID} @@ -41,8 +45,11 @@ gcp: auth: patterns: - /analytics/** + services: + analytics: + audience: ${GRAILS_FORGE_ANALYTICS_AUDIENCE} jackson: mapper: ACCEPT_CASE_INSENSITIVE_ENUMS: true deserialization: - READ_UNKNOWN_ENUM_VALUES_AS_NULL: true \ No newline at end of file + READ_UNKNOWN_ENUM_VALUES_AS_NULL: true diff --git a/grails-forge/grails-forge-web-netty/src/test/groovy/org/grails/forge/netty/security/HstsHeaderFilterSpec.groovy b/grails-forge/grails-forge-web-netty/src/test/groovy/org/grails/forge/netty/security/HstsHeaderFilterSpec.groovy new file mode 100644 index 00000000000..4c084bc62ae --- /dev/null +++ b/grails-forge/grails-forge-web-netty/src/test/groovy/org/grails/forge/netty/security/HstsHeaderFilterSpec.groovy @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.grails.forge.netty.security + +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import spock.lang.Specification + +import jakarta.inject.Inject + +@MicronautTest +class HstsHeaderFilterSpec extends Specification { + + @Inject @Client('/') HttpClient client + + void 'adds HSTS header to HTTP responses'() { + when: + HttpResponse response = client.toBlocking().exchange(HttpRequest.GET('/hsts-test'), String) + + then: + response.headers.get(HstsHeaderFilter.HEADER_NAME) == 'max-age=31536000; includeSubDomains; preload' + } + + @Controller('/hsts-test') + static class HstsTestController { + + @Get + String index() { + 'ok' + } + } +} From fa5214f942a3c1932a64eeeb1e479a33070086ec Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 9 Jul 2026 15:35:03 -0400 Subject: [PATCH 2/2] Clarify Forge analytics endpoint security Assisted-by: opencode:gpt-5.5 --- .../analytics/postgres/AnalyticsController.java | 2 +- .../AnalyticsControllerSecuritySpec.groovy | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java b/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java index 8ce49d80b9b..b52e43c88ac 100644 --- a/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java +++ b/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java @@ -36,6 +36,7 @@ @Controller("/analytics") @ExecuteOn(TaskExecutors.IO) +@Secured(SecurityRule.IS_AUTHENTICATED) public class AnalyticsController { private final ApplicationRepository applicationRepository; @@ -84,7 +85,6 @@ List topReloading() { * @return A future */ @Post("/report") - @Secured(SecurityRule.IS_AUTHENTICATED) @Transactional @ExecuteOn(TaskExecutors.IO) HttpStatus applicationGenerated(@NonNull @Body Generated generated) { diff --git a/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy b/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy index 19b64ba8892..3d6315ac815 100644 --- a/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy +++ b/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy @@ -54,6 +54,7 @@ class AnalyticsControllerSecuritySpec extends Specification { @Inject @Client('/analytics') HttpClient client @Inject ApplicationRepository applicationRepository + @Inject FeatureRepository featureRepository @Inject JdbcOperations jdbcOperations @Inject TokenGenerator tokenGenerator @@ -80,11 +81,12 @@ class AnalyticsControllerSecuritySpec extends Specification { void 'top analytics data is not rejected as unauthenticated'() { when: - client.toBlocking().exchange(HttpRequest.GET('/top/features'), String) + def response = client.toBlocking().exchange(HttpRequest.GET('/top/features'), String) then: - HttpClientResponseException e = thrown(HttpClientResponseException) - e.status == HttpStatus.INTERNAL_SERVER_ERROR + response.status == HttpStatus.OK + response.body() == '[]' + 1 * featureRepository.topFeatures() >> [] 0 * applicationRepository._ } @@ -136,6 +138,12 @@ class AnalyticsControllerSecuritySpec extends Specification { Mock(ApplicationRepository) } + @MockBean(FeatureRepository) + @NonNull + FeatureRepository featureRepository() { + Mock(FeatureRepository) + } + @MockBean(JdbcOperations) @NonNull JdbcOperations jdbcOperations() {