You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/07/02 22:46:10 UTC
(grails-core) 01/01: Require authentication for Forge analytics report endpoint
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch fix/forge-analytics-security
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 2aa481cd1b234fd45ed5c3990e2e517f3047d1c7
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu Jul 2 18:45:26 2026 -0400
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 +-
.github/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 +-
.../grails-forge-analytics-postgres/build.gradle | 10 ++
.../AnalyticsCallerJwtClaimsValidator.java | 44 ++++++
.../analytics/postgres/AnalyticsController.java | 8 +
.../src/main/resources/application.yml | 18 +++
.../AnalyticsControllerSecuritySpec.groovy | 162 +++++++++++++++++++++
.../postgres/StoreGeneratedProjectStatsSpec.groovy | 36 +++--
grails-forge/grails-forge-web-netty/build.gradle | 7 +
.../forge/netty/security/HstsHeaderFilter.java | 45 ++++++
.../src/main/resources/application.yml | 9 +-
.../netty/security/HstsHeaderFilterSpec.groovy | 54 +++++++
16 files changed, 404 insertions(+), 26 deletions(-)
diff --git a/.github/workflows/forge-deploy-next.yml b/.github/workflows/forge-deploy-next.yml
index 2774c362f3..c3b418a8f1 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-cjm [...]
--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 188a6cdf1f..d0a91922e7 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 [...]
--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 5ffc0ab89b..11715a4ce4 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-cjm [...]
--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 164b74295d..16e5fd7154 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_UR [...]
+ 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_UR [...]
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=${ [...]
+ 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=${ [...]
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 c880e7bc80..c302ed078f 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-snap [...]
--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 b24f32fb13..ecc8626bf7 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 04fe266cf8..1d05f52337 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 0000000000..23ca060755
--- /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<T> implements GenericJwtClaimsValidator<T> {
+
+ 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 2ac0279773..8ce49d80b9 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.Get;
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 class AnalyticsController {
}
@Get("/top/features")
+ @Secured(SecurityRule.IS_ANONYMOUS)
List<TotalDTO> topFeatures() {
return featureRepository.topFeatures();
}
@Get("/top/jdks")
+ @Secured(SecurityRule.IS_ANONYMOUS)
List<TotalDTO> topJdks() {
return featureRepository.topJdkVersion();
}
@Get("/top/buildTools")
+ @Secured(SecurityRule.IS_ANONYMOUS)
List<TotalDTO> topBuilds() {
return featureRepository.topBuildTools();
}
@Get("/top/gorm")
+ @Secured(SecurityRule.IS_ANONYMOUS)
List<TotalDTO> topGorm() {
return featureRepository.topGorm();
}
@Get("/top/reloading")
+ @Secured(SecurityRule.IS_ANONYMOUS)
List<TotalDTO> topReloading() {
return featureRepository.topReloading();
}
@@ -77,6 +84,7 @@ public class AnalyticsController {
* @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 5065d27cfa..c8595b5401 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 0000000000..19b64ba889
--- /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 681b769642..3d467fe660 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<HttpStatus> applicationGenerated(@NonNull @Body Generated generated);
+ @NonNull
+ private HttpRequest<Generated> 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 c0fa95e7d5..41a59b416a 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 0000000000..72738b0303
--- /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 5c1997791a..3d54e0cc9f 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 0000000000..4c084bc62a
--- /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<String> 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'
+ }
+ }
+}