diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..157d34135 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,62 @@ + + +# Security + +The Apache Grails Spring Security plugins follow the [Apache Software Foundation security process](https://www.apache.org/security/). Vulnerability reports are handled privately by the ASF Security Team and triaged against the project's threat model. + +## Reporting a vulnerability + +**Do not** open a public GitHub issue, discussion, or pull request for a suspected vulnerability. Email the ASF Security Team at [security@apache.org](mailto:security@apache.org), and include `grails-spring-security` in the subject line. + +A good report includes: + +- The plugin and version affected (e.g. `core-plugin 8.0.0-M1`, `spring-security-rest 8.0.0-M1`). +- The configuration in effect at the time (relevant `grails.plugin.springsecurity.*` properties). +- Reproduction steps, the smallest test case that demonstrates the issue, and the observed vs expected behavior. +- The disposition you believe the report should receive (see [THREAT_MODEL.md §13](./THREAT_MODEL.md)). This is not binding on the triagers but it accelerates the back-and-forth. + +The ASF Security Team will acknowledge receipt, route the report to the project PMC, and coordinate disclosure once a fix or mitigation is available. + +## What is in scope + +The [THREAT_MODEL.md](./THREAT_MODEL.md) at the root of this repository is the authoritative reference for what these plugins claim, what they do not claim, and how reports are triaged. It binds the 8.0.x branch; release branches fork their own version of the document. + +Before reporting, please skim the threat model for: + +- [§3 Out of scope](./THREAT_MODEL.md#§3-out-of-scope-explicit-non-goals) - non-goals the plugins do not defend against. +- [§5a Build-time and configuration variants](./THREAT_MODEL.md#§5a-build-time-and-configuration-variants) - configuration values that change which security properties hold. Reports that require a non-default configuration are typically closed as `OUT-OF-MODEL: non-default-build`. +- [§8 Security properties the plugins provide](./THREAT_MODEL.md#§8-security-properties-the-plugins-provide) - the claims the plugins make. A report against one of these is `VALID` if reproducible. +- [§9 Security properties the plugins do NOT provide](./THREAT_MODEL.md#§9-security-properties-the-plugins-do-not-provide) - disclaimers. Reports against these are `BY-DESIGN: property-disclaimed`. +- [§11 Known misuse patterns](./THREAT_MODEL.md#§11-known-misuse-patterns) - documented anti-patterns. Reports of these are typically `VALID-HARDENING`. +- [§11a Known non-findings](./THREAT_MODEL.md#§11a-known-non-findings-recurring-false-positives) - patterns automated scanners repeatedly flag that are not vulnerabilities given the model. + +## Supported versions + +| Branch | Compatible with | Security fixes | +|---|---|---| +| 8.0.x | Grails 8 / Spring Boot 4 / Spring Security 7 | Yes | +| 7.0.x | Grails 7 / Spring Boot 3 / Spring Security 6 | Yes | +| 6.0.x | Grails 6 | Best-effort | +| 5.0.x and earlier | Grails 5 and earlier | End of life | + +Reports against end-of-life branches are accepted but may not result in a release. + +## Additional resources + +- [Apache Software Foundation Security](https://www.apache.org/security/) +- [Apache Grails Security Project Page](https://grails.apache.org/security.html) +- [Spring Security advisories](https://spring.io/security) - the plugins inherit Spring Security's vulnerability scope; advisories there often apply transitively here. diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 000000000..5d92dc112 --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,809 @@ + + +# Threat Model - Apache Grails Spring Security + +## §1 Header + +- **Project**: Apache Grails Spring Security (`apache/grails-spring-security`) +- **Version binding**: 8.0.x branch. A report against version *N* is triaged against this document as it stood at *N*, not at HEAD. +- **Date**: 2026-01 +- **Author**: Apache Grails PMC and contributors (initial draft). +- **Status**: **DRAFT - awaiting PMC review.** PR #1224 proposes resolutions for all 22 open questions originally collected in §14; that section has been reshaped into a ratification log recording the resolutions, code citations, and the follow-up items each resolution surfaced. Promotion of `*(inferred)*` tags to `*(maintainer)*` in this PR is conditional on PMC review of the resolutions. +- **Reporting cross-reference**: findings that may violate a property claimed in §8 should be reported privately per [`SECURITY.md`](./SECURITY.md) (which routes to the [ASF Security Team](https://www.apache.org/security/)). Findings that fall under §3 (out of scope), §9 (disclaimed properties), or §11a (known non-findings) will be closed publicly citing the relevant section of this document. +- **Provenance legend**: every non-trivial claim is tagged. + - *(documented)* - stated in the project's own docs (per-plugin `docs/src/docs/*.adoc` files, `README.md`). + - *(maintainer)* - stated by a maintainer in response to a question from this drafting process. + - *(inferred)* - reasoned from code structure, absence of a feature, or general domain knowledge. Each must have a matching entry in §14. + +**Project description**: Apache Grails Spring Security is a family of eight Grails plugins (plus a compatibility shim) that wire Spring Security 7.x into a Grails 8.x application. The plugins provide authentication (form, basic, digest, X.509, remember-me, LDAP, CAS, OAuth2, JWT/REST), authorization (role-based via `@Secured` / Requestmap / static rules, object-level via Spring Security ACL), session management (fixation prevention, optional concurrent control), channel security (HTTP/HTTPS redirect), IP restrictions, and a UI plugin shipping CRUD controllers for users, roles, requestmaps, ACL entries, registration, and password reset. The unit of trust modeled here is "a Grails application that has installed one or more of these plugins"; the plugins are not deployed as a standalone network service. *(documented: [README.md](./README.md), per-plugin `docs/src/docs/introduction.adoc`)* + +--- + +## §2 Scope and intended use + +**Primary intended use**: provide authentication, authorization, session management, and account-management UI for Grails web applications running on Spring Boot 4 / Spring Security 7. *(documented: [README.md](./README.md))* + +**Caller roles**: + +| Role | Trust level | Description | +|---|---|---| +| **Unauthenticated HTTP client** | **Untrusted** | Sends HTTP requests to a deployed Grails application. Source of all attacker-controllable input on the auth-ingress side. *(inferred)* | +| **Authenticated low-privilege HTTP client** | **Partially trusted** | Has completed authentication via one of the supported flows. Holds a valid `Authentication` in the `SecurityContext` with limited authorities. Attempts horizontal/vertical privilege escalation. *(inferred)* | +| **Application developer / operator** | **Trusted** | Writes controllers, services, GSP templates; configures `application.yml` / `application.groovy` / Requestmap rows; chooses which plugins to install. *(inferred)* | +| **Plugin / `grails` profile author** | **Trusted-by-association** | Author of a third-party Grails plugin or `grails` profile that the operator installs alongside this plugin. Code from a plugin runs with full application privileges. The framework does not isolate plugin code. *(inferred)* | +| **External identity provider** | **Trusted-by-association** | An LDAP server, CAS server, or OAuth2 authorization server selected by the operator. Compromise of any of these is equivalent to authentication bypass within the application. *(inferred)* | + +### Component-family table + +The repository ships nine independent Gradle subprojects. The model carves them into in-model and out-of-model families: + +| Family | Representative entry point(s) | Touches outside process? | In or out of model | +|---|---|---|---| +| Core authentication / authorization | [`SpringSecurityCoreGrailsPlugin`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy), [`SpringSecurityUtils`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityUtils.groovy), [`AbstractFilterInvocationDefinition`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/AbstractFilterInvocationDefinition.groovy) | Yes - HTTP via Spring MVC, DB via GORM | **In** | +| GORM user store | [`GormUserDetailsService`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GormUserDetailsService.groovy), [`GormPersistentTokenRepository`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/rememberme/GormPersistentTokenRepository.groovy) | Yes - DB via GORM | **In** | +| Spring Boot autoconfig exclusion | [`SecurityAutoConfigurationExcluder`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy), [`ComponentBasedConfigBlender`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy) | No (startup wiring) | **In** | +| ACL (object-level permissions) | [`SpringSecurityAclGrailsPlugin`](./plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/SpringSecurityAclGrailsPlugin.groovy), [`GormAclLookupStrategy`](./plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/jdbc/GormAclLookupStrategy.groovy) | Yes - DB via GORM | **In** | +| Compatibility shim (re-implements Spring Security 5.x classes) | [`FilterSecurityInterceptor`](./spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.groovy), [`AffirmativeBased`](./spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AffirmativeBased.groovy), [`AntPathRequestMatcher`](./spring-security-compat/src/main/groovy/org/springframework/security/web/util/matcher/AntPathRequestMatcher.groovy), [`RunAsManagerImpl`](./spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManagerImpl.groovy) | No | **In** | +| LDAP authentication | [`SpringSecurityLdapGrailsPlugin`](./plugin-ldap/plugin/src/main/groovy/grails/plugin/springsecurity/ldap/SpringSecurityLdapGrailsPlugin.groovy), [`GrailsLdapAuthoritiesPopulator`](./plugin-ldap/plugin/src/main/groovy/grails/plugin/springsecurity/ldap/userdetails/GrailsLdapAuthoritiesPopulator.groovy) | Yes - LDAP/LDAPS network calls | **In** | +| CAS single sign-on | [`SpringSecurityCasGrailsPlugin`](./plugin-cas/plugin/src/main/groovy/grails/plugin/springsecurity/cas/SpringSecurityCasGrailsPlugin.groovy) | Yes - HTTPS callbacks to CAS server | **In** | +| OAuth2 client | [`OAuth2AbstractProviderService`](./plugin-oauth2/plugin/src/main/groovy/grails/plugin/springsecurity/oauth2/service/OAuth2AbstractProviderService.groovy), [`OAuth2ProviderConfiguration`](./plugin-oauth2/plugin/src/main/groovy/grails/plugin/springsecurity/oauth2/util/OAuth2ProviderConfiguration.groovy) | Yes - HTTPS calls to OAuth2 provider | **In** | +| REST / JWT authentication | [`RestAuthenticationFilter`](./plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFilter.groovy), [`RestTokenValidationFilter`](./plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/RestTokenValidationFilter.groovy), [`JwtService`](./plugin-rest/spring-security-rest/grails-app/services/grails/plugin/springsecurity/rest/JwtService.groovy) | Yes - optional Redis/Memcached network | **In** | +| REST token storage backends | [`GormTokenStorageService`](./plugin-rest/spring-security-rest-gorm/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GormTokenStorageService.groovy), [`RedisTokenStorageService`](./plugin-rest/spring-security-rest-redis/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/RedisTokenStorageService.groovy), [`MemcachedTokenStorageService`](./plugin-rest/spring-security-rest-memcached/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/memcached/MemcachedTokenStorageService.groovy), [`GrailsCacheTokenStorageService`](./plugin-rest/spring-security-rest-grailscache/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GrailsCacheTokenStorageService.groovy) | Yes - DB / Redis / Memcached | **In** | +| UI plugin (HTTP controllers, the ONLY plugin that ships endpoints) | [`UserController`](./plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/UserController.groovy), [`RoleController`](./plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RoleController.groovy), [`RequestmapController`](./plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RequestmapController.groovy), [`RegisterController`](./plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RegisterController.groovy), [`AclEntryController`](./plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclEntryController.groovy), [`SecurityInfoController`](./plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/SecurityInfoController.groovy) | Yes - HTTP, SMTP (mail) | **In** | +| Examples / functional test apps | `plugin-*/examples/`, `plugin-rest/spring-security-rest-testapp-profile/` | n/a | **Out** - not shipped in plugin distributions. See §3. | +| Per-plugin documentation | `plugin-*/docs/src/docs/*.adoc` | n/a | **Out** - documentation source, not runtime code. | + +The UI plugin is the only family in this repository that registers live HTTP endpoints in the host application; every other plugin contributes Spring beans, filters, domain classes, and configuration. Its threat profile is therefore qualitatively different and gets dedicated treatment in §11. + +--- + +## §3 Out of scope (explicit non-goals) + +The plugins **do not** attempt to defend against, and **do not** model, the following. Triagers may close findings citing this section. + +- **Application controllers, services, and domain classes outside these plugins.** The plugins provide an authentication and authorization framework; the application's own HTTP endpoints and business logic are out of scope. *(inferred)* +- **Transport security (TLS).** Provided by the Spring Boot embedded container or by a reverse proxy in front of the application. The LDAP, CAS, and OAuth2 plugins assume TLS to the external IdP is configured by the operator. *(inferred)* +- **External identity provider implementations.** Vulnerabilities in the LDAP server (OpenLDAP, AD, ApacheDS), the CAS server (Apereo CAS, Jasig CAS), or any OAuth2 authorization server are triaged in those projects. The plugin's contract is to forward credentials and consume responses; it does not audit the IdP. *(inferred)* +- **Spring Security itself.** Triaged at . The plugins re-expose Spring Security's public APIs but do not own its threat model. The compatibility shim in `spring-security-compat/` is an exception: classes vendored under `org.springframework.security.*` package names but maintained in this repository ARE in model. *(inferred)* +- **ScribeJava, Nimbus JOSE+JWT, pac4j, Jedis, spymemcached.** Third-party libraries the plugins depend on. *(inferred)* +- **JDK, JDBC drivers, JVM vulnerabilities.** Upstream. *(inferred)* +- **Third-party Grails plugins.** Plugins outside this repository run with full application privileges; their threat models are the responsibility of their authors. *(inferred)* +- **Example applications under `plugin-*/examples/` and the `spring-security-rest-testapp-profile` skeleton.** Not shipped in plugin distributions. Functional-test fixtures only. *(inferred)* +- **Build-time supply chain** (Gradle plugin portal, Maven Central, signing, reproducible builds, GitHub Action pinning). Important, but not threat-model content per the Apache security-team rubric. *(inferred)* +- **Side-channel attacks** (timing, cache, power, micro-architectural). No constant-time guarantees are made anywhere in the plugins. *(inferred)* +- **Denial of service via large bcrypt work factor on attacker-supplied passwords.** `password.bcrypt.logrounds` defaults to 10 and `security.ui.password.maxLength` defaults to 64; raising either to a value that lets an attacker exhaust CPU on `/login` is a `non-default-build` operator-responsibility issue. *(inferred)* + +--- + +## §4 Trust boundaries and data flow + +The principal trust boundaries modeled here are: + +1. **HTTP request boundary** - data crossing from an unauthenticated end user into the Spring Security filter chain. +2. **Authenticated request boundary** - data crossing from a low-privilege authenticated session into authorization decisions (`@Secured`, Requestmap, ACL). +3. **External IdP boundary** - data crossing from an LDAP / CAS / OAuth2 server response into a local `Authentication` object. +4. **Token store boundary** - data crossing into / out of Redis, Memcached, or the GORM database for REST/JWT session lookup. + +### Primary data flow A: form login (default chain) + +``` +[Unauthenticated HTTP client] + | + v (HTTPS expected; operator's responsibility) +[Embedded servlet container] <-- boundary: not plugin + | + v +[FilterChainProxy / springSecurityFilterChain] <-- HTTP enters plugin + | + v +[channelProcessingFilter (if secureChannel.definition non-empty)] <-- HTTPS enforcement gate + | + v +[securityContextPersistenceFilter] + | + v +[logoutFilter] + | + v +[ipAddressFilter (if ipRestrictions non-empty)] <-- uses request.remoteAddr ONLY + | + v +[authenticationProcessingFilter] <-- credentials extracted from form POST + | | + | +--> [authenticationManager (ProviderManager)] + | | + | +--> [daoAuthenticationProvider --> GormUserDetailsService --> DB] + | +--> [anonymousAuthenticationProvider] + | +--> [rememberMeAuthenticationProvider] + | +--> [merged user-defined providers (ComponentBasedConfigBlender)] + v +[rememberMeAuthenticationFilter (cookie path)] + | + v +[anonymousAuthenticationFilter] + | + v +[exceptionTranslationFilter] + | + v +[filterInvocationInterceptor (FilterSecurityInterceptor)] <-- authorization gate + | | + | +--> [objectDefinitionSource: @Secured | Requestmap | InterceptUrlMap] + | +--> [accessDecisionManager: AuthenticatedVetoableDecisionManager] + | +--> voters: authenticatedVoter, roleVoter (RoleHierarchy), webExpressionVoter (SpEL), closureVoter + v +[Application controller action] <-- developer code +``` + +### Primary data flow B: REST/JWT chain (when plugin-rest is installed) + +``` +[Unauthenticated HTTP client] + | + v POST /api/login + JSON body {username, password} +[RestAuthenticationFilter] + | DefaultJsonPayloadCredentialsExtractor parses body + | + v +[authenticationManager] (same providers as flow A) + | on success -> + v +[tokenGenerator (SignedJwtTokenGenerator | SecureRandomTokenGenerator | EncryptedJwtTokenGenerator)] + | + v +[tokenStorageService.storeToken(token, userDetails)] + | JWT backend: NO-OP (stateless) + | GORM/Redis/Memcached/Grails Cache: persists serialized principal + v +[RestAuthenticationSuccessHandler] --> JSON response body with access_token + refresh_token + +[Subsequent request] + | Authorization: Bearer + v +[RestTokenValidationFilter] + | + v +[RestAuthenticationProvider] + | JwtService.parse(token): + | SignedJWT -> MACVerifier(jwtSecret) + | EncryptedJWT -> RSADecrypter(privateKey) + | PlainJWT -> JOSEException ONLY if (jwtSecret || keyProvider) is non-null + | tokenStorageService.loadUserByToken(token) -> UserDetails + v +[SecurityContextHolder.getContext().setAuthentication(...)] + | + v +[filterInvocationInterceptor] (authorization gate, same as flow A) +``` + +The trust transition occurs at the entry filter (`authenticationProcessingFilter` for form login, `RestAuthenticationFilter` for REST). Within the plugins, **request parameters, headers, cookies, request bodies, and bearer tokens are treated as attacker-controlled until a verified `Authentication` is in the context**. The token validation path is the matching transition on the inbound side of every subsequent request. + +### Reachability preconditions per component family + +A triager applies these tests before deciding a finding is in-model: + +| Component family | Reachability precondition for a finding to be in-model | +|---|---| +| Core filter chain | Reachable from an HTTP request with no developer-authored guard preceding it. *(inferred)* | +| Authorization (`FilterSecurityInterceptor`, `AbstractFilterInvocationDefinition`) | Reachable when a request URL falls under a `staticRules` / Requestmap / `@Secured` entry, OR when `rejectIfNoRule` is `true` (default) AND the URL is uncovered. *(inferred)* | +| GORM user store | Reachable from every authentication attempt. *(inferred)* | +| ACL | Reachable from any method secured with `@PreAuthorize("hasPermission(...)")` or `@PostFilter`, AND the application has populated `AclObjectIdentity`/`AclEntry` rows. *(inferred)* | +| Compat shim (`spring-security-compat`) | Reachable on every secured-method invocation; the shim's classes are the actual runtime classes, not Spring Security's. *(inferred)* | +| LDAP | Reachable from authentication when `ldap` plugin is installed AND a request reaches `LdapAuthenticationProvider`. *(inferred)* | +| CAS | Reachable from any HTTP request when `cas` plugin is installed (the SLO filter runs at `HIGHEST_PRECEDENCE`). *(inferred)* | +| OAuth2 | Reachable from `/oauth2/authenticate/{provider}` and `/oauth2/callback/{provider}` when the `oauth2` plugin is installed. *(inferred)* | +| REST / JWT | Reachable from any HTTP request when `spring-security-rest` is installed (the validation filter runs on every request unless `active: false`). *(inferred)* | +| UI controllers | Reachable from any HTTP request; **the UI plugin ships no default protection for its own endpoints**. Reachability is therefore unconditional unless the operator configures Requestmap/staticRules entries. *(inferred)* | +| Token storage backends | Reachable on every REST request via `loadUserByToken`. *(inferred)* | + +The reachability column for **UI controllers** is the load-bearing claim of this section: shipping CRUD controllers without default authorization means that installing the UI plugin and forgetting to configure Requestmap rows is functionally equivalent to publishing an admin console on the open internet. A report against `/user/save`, `/role/save`, `/requestmap/save`, or `/aclEntry/save` is in-model if the operator did not explicitly add a protection rule. See §11. + +--- + +## §5 Assumptions about the environment + +**Runtime**: + +- JDK 21 or higher. *(documented: [README.md](./README.md), upstream Grails 8 requirement)* +- Apache Groovy 4.0.x. *(documented: upstream Grails 8 requirement)* +- Spring Boot 4.0.x / Spring Framework 7.0.x. *(documented: upstream Grails 8 requirement)* +- Spring Security 7.0.x. *(documented: [README.md](./README.md))* +- Jakarta EE 10 servlet API. *(documented: upstream Grails 8 requirement)* + +**Operator-controlled environment**: + +- The application is deployed by a trusted operator on hardware and OS the operator controls. *(inferred)* +- The embedded servlet container is fronted by, or itself provides, transport security (TLS). *(inferred)* +- All credentials that cross network boundaries to external IdPs (LDAP manager DN/password, CAS service URL, OAuth2 client secret) are transmitted over TLS. *(inferred)* +- Redis / Memcached token-storage backends, when used, are network-isolated; the plugins assume no untrusted client can write directly to those stores. *(inferred)* +- The application classpath contains only artifacts the operator/developer chose - no untrusted JAR is loaded at runtime. *(inferred)* +- `application.yml`, `application.groovy`, and any `grails.config.locations` are sourced from operator-trusted storage. The Grails framework's threat model on these surfaces applies; see the framework's THREAT_MODEL.md §9 false-friend on `grails.config.locations`. *(inferred)* + +**Concurrency**: + +- The plugins assume a thread-per-request servlet model. The `SecurityContextHolder` is `ThreadLocal`-backed by Spring Security. *(documented: upstream Spring Security)* +- The reactive (`WebFlux`) variants of Spring Boot security auto-configuration are intentionally NOT excluded by `SecurityAutoConfigurationExcluder`; the plugins target servlet-stack deployments only. *(documented: [SecurityAutoConfigurationExcluder.groovy](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy))* + +### What the plugins do NOT do to their host + +These are **negative claims** about plugin behavior. By the rubric they are the lowest-confidence claims in the model and the highest-priority targets for maintainer confirmation (see §14). + +- The plugins do not bind sockets directly. All network listening is delegated to the Spring Boot embedded container. *(inferred)* +- The plugins do not spawn child processes from the runtime layer. *(inferred)* +- The plugins do not install JVM signal handlers. *(inferred)* +- The plugins do not read environment variables beyond `SPRING_SECURITY_*` and Spring Boot's standard set. *(inferred)* +- The plugins do not mutate global JVM state at runtime beyond the `SecurityContextHolder` ThreadLocal. *(inferred)* +- The plugins do not write to stdout or stderr at runtime beyond SLF4J-routed logging. The OAuth2 `debug` flag is the documented exception - when `oauth2.{provider}.debug = true`, raw token traffic is logged. *(documented: [OAuth2ProviderConfiguration.groovy](./plugin-oauth2/plugin/src/main/groovy/grails/plugin/springsecurity/oauth2/util/OAuth2ProviderConfiguration.groovy))* +- The plugins do not load classes from network locations at runtime. *(inferred)* +- The plugins do not deserialize attacker-controlled `ObjectInputStream` data **when configured per §10**; see §11a for the documented sinks and their preconditions. + +--- + +## §5a Build-time and configuration variants + +The plugins expose many configuration knobs whose values change which §8 properties hold. The defaults below come from `DefaultSecurityConfig.groovy` (plugin-core), `DefaultLdapSecurityConfig.groovy`, `DefaultCasSecurityConfig.groovy`, `DefaultSpringSecurityOAuth2Config.groovy`, `DefaultRestSecurityConfig.groovy`, and `DefaultUiSecurityConfig.groovy`. "Maintainer stance" is a §14 target. + +### Core plugin + +| Knob | Default | Effect on the model | Maintainer stance | +|---|---|---|---| +| `password.algorithm` | `bcrypt` *(documented: [hashing.adoc](./plugin-core/docs/src/docs/passwords/hashing.adoc))* | Default password encoder ID. Any string accepted by Spring Security's `DelegatingPasswordEncoder` is valid; the plugin enumerates `bcrypt`, `pbkdf2`, `scrypt`, `argon2`, `ldap`, `MD4`, `MD5`, `SHA-1`, `SHA-256`, `noop`. Setting to `noop` stores cleartext passwords. | *(maintainer: §14 Q6 resolution - `bcrypt` is the supported default; `noop` is `OUT-OF-MODEL: non-default-build`)* | +| `password.bcrypt.logrounds` | `10` *(documented: [hashing.adoc](./plugin-core/docs/src/docs/passwords/hashing.adoc))* | Bcrypt work factor. The docs note the default is "lower for testing speed" (which refers to the TEST-environment value of 4, set in `DefaultSecurityConfig.groovy` lines 193-207); the production default is 10, matching Spring Security's upstream `BCryptPasswordEncoder` default strength. | *(maintainer: §14 Q6 resolution - 10 is the supported production default; §10 #1 recommends ≥12 for hardening above the default)* | +| `password.hash.iterations` | `10000` *(documented: [hashing.adoc](./plugin-core/docs/src/docs/passwords/hashing.adoc))* | Iteration count for message-digest encoders (not bcrypt/pbkdf2). | Wave 2 - deferred; applies only to message-digest encoders which are themselves discouraged. | +| `useSessionFixationPrevention` | `true` *(documented: [sessionFixation.adoc](./plugin-core/docs/src/docs/sessionFixation.adoc))* | Wires `SessionFixationProtectionStrategy`. Setting to `false` substitutes `NullAuthenticatedSessionStrategy`, removing all session-fixation protection. The CAS plugin's `useSingleSignout: true` (default) forces this to `false` - see Q4. | *(maintainer: §14 Q4 resolution - default `true`; explicit `false` is `OUT-OF-MODEL: non-default-build` except via cas SLO which is `BY-DESIGN: property-disclaimed`)* | +| `rejectIfNoRule` | `true` *(documented: [requestMappings.adoc](./plugin-core/docs/src/docs/requestMappings.adoc))* | Pessimistic URL coverage: unmatched URLs are denied. Setting to `false` is `OUT-OF-MODEL: non-default-build` for missing-authz reports. | *(maintainer: default `true` confirmed as the supported posture)* | +| `fii.rejectPublicInvocations` | `true` *(documented: [requestMappings.adoc](./plugin-core/docs/src/docs/requestMappings.adoc))* | If both `rejectIfNoRule: false` AND `fii.rejectPublicInvocations: false`, `FilterSecurityInterceptor` passes uncovered URLs through with no check. | *(maintainer: default `true` confirmed as the supported posture)* | +| `excludeSpringSecurityAutoConfiguration` | `true` *(documented: [README.md](./README.md))* | When `true`, [`SecurityAutoConfigurationExcluder`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy) suppresses 11 conflicting Spring Boot auto-configurations. Setting to `false` is intentionally "a footgun"; both filter chains run with undefined precedence. *(documented: [README.md](./README.md))* | *(maintainer: default `true` is the supported posture; `false` is the documented footgun)* | +| `componentBased.autoMergeSecurityFilterChain` | `true` *(documented: [README.md](./README.md))* | User-defined `@Bean SecurityFilterChain` beans are **prepended** to the plugin's chain list (higher precedence). A user catch-all chain (`/**`) shadows all plugin rules. | Wave 2 - deferred. | +| `componentBased.autoMergeAuthenticationProviders` | `true` *(documented: [README.md](./README.md))* | User `@Bean AuthenticationProvider` beans appended after the GORM provider. | Wave 2 - deferred. | +| `componentBased.bridgeSpringSecurityUserProperties` | `true` *(documented: [README.md](./README.md))* | If `spring.security.user.name` is set, an `InMemoryUserDetailsManager` with `{noop}` password prefix is wired and chained. A developer-convenience property left in production creates a credential with no hashing. The default is hard-coded `true` in [`SpringSecurityCoreGrailsPlugin.groovy:781`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy); the `{noop}` wiring is in [`ComponentBasedConfigBlender.groovy` lines 193-208](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy). | *(maintainer: §14 Q7 resolution - intentional; `{noop}` mirrors Spring Boot's `UserDetailsServiceAutoConfiguration`; §11 misuse stands)* | +| `rememberMe.persistent` | `false` *(inferred)* | When `false`, `TokenBasedRememberMeServices` is used; remember-me cookies are signed with MD5-HMAC over `username:expiry:password`. When `true`, `PersistentTokenBasedRememberMeServices` + `GormPersistentTokenRepository` provides DB-side token-theft detection. MD5-HMAC is cryptographically weak relative to modern primitives. *(inferred)* | Wave 2 - deferred. | +| `rememberMe.key` | none (operator-set) *(inferred)* | HMAC key for non-persistent remember-me. Null or short keys make cookie-signature forgery practical. The plugin does not enforce a minimum length. | *(maintainer: no default; operator-trusted-input per §6; §10 #2 enforces ≥32 random bytes as downstream responsibility)* | +| `cacheUsers` | `false` *(documented: [locking.adoc](./plugin-core/docs/src/docs/passwords/locking.adoc))* | When `true`, account lock/disable changes are bypassed until the cache entry is manually evicted via `userCache.removeUserFromCache(username)`. The docs explicitly warn about this. | Wave 2 - deferred. | +| `secureChannel.useHeaderCheckChannelSecurity` | `false` *(documented: [channelSecurity.adoc](./plugin-core/docs/src/docs/channelSecurity.adoc))* | When `false`, channel decision uses `request.isSecure()` only; behind a TLS-terminating proxy this returns `false` even for HTTPS clients. When `true`, the plugin checks `X-Forwarded-Proto`. `PortResolverImpl` does NOT consult `X-Forwarded-Port` regardless of this flag - the redirect URL will carry the backend port. | *(maintainer: §14 Q9 resolution - asymmetry confirmed; channel decision is proxy-aware via `SecurityRequestHolderFilter` lines 83-113 but redirect URL construction in `PortResolverImpl` is not)* | +| `ipRestrictions` | empty *(inferred)* | When non-empty, [`IpAddressFilter`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/IpAddressFilter.groovy) enforces CIDR matching against `request.remoteAddr` only. **Does not consult `X-Forwarded-For`.** Behind a reverse proxy, IP restrictions are bypassed by every request unless the proxy preserves the source IP at the TCP layer. | *(maintainer: §14 Q8 resolution - BY-DESIGN; `IpAddressFilter.groovy:109,130` confirmed remoteAddr-only)* | +| `useSecurityEventListener` | `false` *(inferred)* | When `true`, configured Groovy `Closure` properties (`onAuthenticationSuccessEvent`, etc.) execute on each auth event. If `application.groovy` is sourced from attacker-writable storage, this is an arbitrary-code-execution sink. | Wave 2 - deferred. | +| `useRunAs` | `false` *(inferred)* | When `true`, [`RunAsManagerImpl`](./spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManagerImpl.groovy) substitutes elevated roles for the duration of secured-method calls. The substitution token is not HMAC-signed in the compat shim; the `key` field is declared in both `RunAsManagerImpl` and [`RunAsImplAuthenticationProvider`](./spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsImplAuthenticationProvider.groovy) but **never read** (the compat shim is even more permissive than upstream Spring Security 5.x, which performs a `keyHash` equality check). | *(maintainer: §14 Q15 resolution - KNOWN-NON-FINDING; opt-in default false; follow-up F8 considers porting the upstream hashCode guard)* | + +### ACL plugin + +| Knob | Default | Effect on the model | +|---|---|---| +| `acl.permissionClass` | `BasePermission` *(inferred)* | If supplied as a `String`, loaded via `classLoader.loadClass(name)`. A misconfigured class name silently changes the permission-mask semantics. | +| `acl.authority.changeOwnership` / `modifyAuditingDetails` / `changeAclDetails` | configured authority strings *(inferred)* | Required authorities to mutate ACL ownership / audit / DACL. **Granting/revoking individual ACEs has no built-in caller authorization beyond what the application places on its own service methods.** | + +### LDAP plugin + +| Knob | Default | Effect on the model | +|---|---|---| +| `ldap.context.server` | `ldap://localhost:389` *(documented: [`DefaultLdapSecurityConfig.groovy`](./plugin-ldap/plugin/grails-app/conf/DefaultLdapSecurityConfig.groovy))* | Default is plaintext LDAP. Manager DN + password and user credentials transit in cleartext unless changed to `ldaps://` or wrapped in StartTLS. The plugin does NOT wire StartTLS; operators needing it must supply a custom `authenticationStrategy` bean. | +| `ldap.authenticator.useBind` | `true` *(documented)* | Bind authentication (safer). Setting `false` selects `PasswordComparisonAuthenticator`, requiring the manager account to read `userPassword` from the directory. | +| `ldap.context.referral` | `null` *(documented)* | When set to `'follow'`, JNDI follows LDAP referrals to arbitrary servers, including attacker-controlled ones. | +| `ldap.authorities.groupSearchFilter` | `'uniquemember={0}'` *(documented)* | `{0}` is the user DN (Spring Security LDAP encodes it). Custom filter templates using `{1}` (raw username) are NOT escaped; an unsanitized username would produce LDAP filter injection. | +| `ldap.auth.hideUserNotFoundExceptions` | `true` *(documented)* | When `false`, distinguishable error messages enable username enumeration. | + +### CAS plugin + +| Knob | Default | Effect on the model | +|---|---|---| +| `cas.serverUrlPrefix` | `null` (required) *(documented: [`DefaultCasSecurityConfig.groovy`](./plugin-cas/plugin/grails-app/conf/DefaultCasSecurityConfig.groovy))* | Operator-supplied. MUST be HTTPS; the plugin provides no certificate-pinning configuration. JVM default trust store applies. | +| `cas.serviceUrl` | `null` (required) *(documented)* | Static absolute URL. Not derived from `Host` / `X-Forwarded-Host`, so direct open-redirect via service manipulation is not reachable from a single request. Misconfiguration to HTTP is the realistic risk. | +| `cas.useSingleSignout` | `true` *(documented)* | Wires `SingleSignOutFilter` and forces `useSessionFixationPrevention = false`. **Enabling SLO disables session fixation prevention globally.** This is a documented trade-off in the Apereo CAS client. | +| `cas.key` | `'grails-spring-security-cas'` *(documented)* | Default shared secret for `CasAuthenticationProvider`. **Must be changed in production** - the default is published in the source tree. | +| `cas.proxyCallbackUrl` | `null` *(documented)* | When non-null, the application exposes a Proxy-Granting-Ticket receptor endpoint. The CAS server callback is trusted on the basis of TLS only; no additional origin validation is performed by the plugin. | + +### OAuth2 plugin + +| Knob | Default | Effect on the model | +|---|---|---| +| `oauth2.providers.{name}.apiKey` | none (required) *(inferred)* | Client ID; stored in application config. | +| `oauth2.providers.{name}.apiSecret` | none (required) *(inferred)* | Client secret. Stored as a plain string in config; transmitted on every token exchange. | +| `oauth2.providers.{name}.callbackUrl` | none (required) *(inferred)* | Static URL passed to `ServiceBuilder.callback()`. No allow-list; only the single configured URL is registered. | +| `oauth2.providers.{name}.debug` | `false` *(inferred)* | When `true`, ScribeJava logs raw OAuth traffic - including access tokens and refresh tokens - to stdout. Production deployments with `debug: true` leak credentials to the application log. | +| `oauth2.registration.roleNames` | `['ROLE_USER']` *(inferred)* | Roles granted to every OAuth2-authenticated user, regardless of provider claims. Including a privileged role here grants it to every social-login user. | +| OAuth2 state parameter (not configurable) | `java.util.Random` over 1,000,000-value space *(inferred)* | `OAuth2AbstractProviderService` generates state as `providerID + "-secret-" + Random.nextInt(999_999)`. **Not cryptographically secure; state-CSRF against the callback is feasible.** | +| OAuth2 PKCE | not implemented *(inferred)* | `ServiceBuilder.withPkce()` is never invoked. Authorization-code interception is not mitigated by PKCE at the plugin level. | + +### REST / JWT plugin + +| Knob | Default | Effect on the model | +|---|---|---| +| `rest.login.endpointUrl` | `/api/login` *(documented: [`DefaultRestSecurityConfig.groovy`](./plugin-rest/spring-security-rest/src/main/resources/DefaultRestSecurityConfig.groovy))* | Credential-intake endpoint. | +| `rest.login.useRequestParamsCredentials` | `false` *(documented)* | When `true`, credentials are read from query params, exposing them in URLs and logs. | +| `rest.token.generation.useSecureRandom` | `true` *(documented)* | Opaque-token entropy via `SecureRandom`. If set to `false` AND `useJwt: false`, `UUIDTokenGenerator` is used. | +| `rest.token.storage.jwt.useSignedJwt` | `true` *(documented: [tokenStorage.adoc](./plugin-rest/docs/src/docs/tokenStorage.adoc))* | HMAC-signed JWT. The signing algorithm is `rest.token.storage.jwt.algorithm` (default `HS256`); the plugin enforces no algorithm allow-list. | +| `rest.token.storage.jwt.secret` | `null` (required for HMAC mode) *(documented)* | HMAC key. Null + Nimbus key-length enforcement causes boot failure - **but** if `useSignedJwt: false` AND `useEncryptedJwt: false` AND the secret is null AND no key provider is configured, `JwtService.parse()` accepts `PlainJWT` tokens (`alg=none`). | +| `rest.token.storage.jwt.useEncryptedJwt` | `false` *(documented)* | When `true`, JWE is used (RSA-OAEP + AES-256-GCM). | +| `rest.token.storage.jwt.privateKeyPath` / `publicKeyPath` | `null` *(documented)* | DER-encoded RSA keys. When both null, `DefaultRSAKeyProvider` generates an ephemeral 2048-bit pair at every JVM start; tokens are unusable across restarts and across pods in a horizontal-scaling deployment. | +| `rest.token.storage.jwt.expiration` | `3600` seconds *(documented)* | Access-token lifetime. With stateless JWT, this is the only revocation mechanism. | +| `rest.token.storage.jwt.refreshExpiration` | `null` *(documented)* | Refresh-token lifetime. **Default is no expiry** - a leaked refresh token is valid forever. | +| `rest.token.validation.active` | `true` *(documented: [tokenValidation.adoc](./plugin-rest/docs/src/docs/tokenValidation.adoc))* | When `false`, `RestTokenValidationFilter` is a no-op pass-through; tokens are not validated. | +| `rest.token.validation.useBearerToken` | `true` *(documented)* | RFC 6750 Bearer header. | +| `rest.token.storage.gorm.tokenDomainClassName` | none *(documented)* | Loaded via `grailsApplication.getClassForName(...)`. Config-driven class loading. | + +### UI plugin + +| Knob | Default | Effect on the model | +|---|---|---| +| `security.ui.encodePassword` | `false` *(documented: [`DefaultUiSecurityConfig.groovy`](./plugin-ui/plugin/grails-app/conf/DefaultUiSecurityConfig.groovy))* | When `false`, the UI service stores submitted passwords **without encoding** when creating/updating users via the UI. **Must be set to `true` for production.** | +| `security.ui.register.requireEmailValidation` | `'true'` *(documented)* | When `false`, accounts are activated immediately; the email-confirmation gate is removed. | +| `security.ui.forgotPassword.requireForgotPassEmailValidation` | `'true'` *(documented)* | When `false`, the reset link is rendered into the HTTP response body instead of emailed. Reset token leaks to logs / browser history. | +| `security.ui.register.defaultRoleNames` | `['ROLE_USER']` *(documented)* | Roles assigned automatically on self-registration. Including a privileged role here grants administration rights to every self-registered account. | +| `security.ui.password.maxLength` | `64` *(documented)* | Maximum password length accepted by registration/reset forms. Bcrypt operates on the first 72 bytes of the password regardless. | +| `security.ui.password.validationRegex` | requires digit, letter, special *(documented)* | Password-complexity rule. Can be weakened to `.*`. | + +There is **no compile-time `-D` define or build flag** that voids a §8 property; the model is invariant under build configuration. *(inferred)* + +--- + +## §6 Assumptions about inputs + +The plugins' public input boundary is the HTTP request, plus responses from external IdPs. Per-input trust is summarized below. + +### Per-input trust table + +| Entry point / surface | Parameter | Attacker-controllable? | Caller (developer/operator) must enforce | +|---|---|---|---| +| Form login (`authenticationProcessingFilter`) | `j_username`, `j_password` (form POST body) | **Yes** | Configure a strong `password.algorithm` and `password.bcrypt.logrounds`; never set `password.algorithm = noop`. *(documented: [hashing.adoc](./plugin-core/docs/src/docs/passwords/hashing.adoc))* | +| Basic / Digest auth | `Authorization` header | **Yes** | Use only over TLS. Digest auth uses MD5 internally and is inherently weak; prefer Basic over TLS. *(inferred)* | +| Remember-me cookie | `remember-me` (cookie value) | **Yes** | Set a strong `rememberMe.key` (non-persistent mode) or use `rememberMe.persistent: true` (persistent mode). *(inferred)* | +| REST login (`RestAuthenticationFilter`) | JSON body `{username, password}` | **Yes** | Same as form login. *(documented: [authentication.adoc](./plugin-rest/docs/src/docs/authentication.adoc))* | +| Bearer token (`RestTokenValidationFilter`) | `Authorization: Bearer ` | **Yes** | Configure non-null `jwtSecret` OR `useSignedJwt: false` + valid RSA key pair. **Never both null.** *(documented: [tokenStorage.adoc](./plugin-rest/docs/src/docs/tokenStorage.adoc))* | +| OAuth2 callback | `code`, `state`, `error`, `error_description` (query params); `callback` (query param to `/oauth2/authenticate`) | **Yes** | Validate `state` against the session-stored value; the plugin's pac4j integration handles this. The `callback` query parameter is stored in session and used as the post-login redirect target - the application MUST validate against an allow-list. *(inferred)* | +| CAS callback | `ticket` (query param) | **Yes** | Validated against the CAS server over HTTPS. Trust collapses to the CAS server's TLS certificate. *(documented: [`SpringSecurityCasGrailsPlugin.groovy`](./plugin-cas/plugin/src/main/groovy/grails/plugin/springsecurity/cas/SpringSecurityCasGrailsPlugin.groovy))* | +| LDAP bind | username, password (forwarded from form/REST login) | **Yes (the values)**; **No (the LDAP server)** | The LDAP server is operator-selected and trusted. Username is encoded by Spring Security LDAP before substitution into the search filter when the default filter template uses `{0}`. Custom filters using `{1}` are not safe. *(inferred)* | +| UI form: registration | `RegisterCommand` (username, email, password, password2) | **Yes** | Enable `requireEmailValidation: true`; review `defaultRoleNames`; add rate limiting / CAPTCHA at the application layer (the plugin provides neither). *(inferred)* | +| UI form: forgot password | username | **Yes** | Account-enumeration risk - the action returns a distinguishable response for unknown usernames. *(inferred)* | +| UI form: reset password | `t` (token query param), new password | **Yes** | Reset tokens have NO expiry by default. *(inferred)* | +| UI form: User CRUD | All form fields, including `ROLE_*=on` checkboxes | **Yes** | The save/update actions pass `params` directly to `setProperties(params, ...)` - **mass-assignment via the params map**. The application MUST protect `/user/save`, `/user/update` with Requestmap or `@Secured`. *(inferred)* | +| UI form: Role CRUD | All form fields | **Yes** | No default protection. *(inferred)* | +| UI form: Requestmap CRUD | `url`, `configAttribute`, `httpMethod` | **Yes** | **Writing a Requestmap row rewrites the application's authorization policy.** No default protection on `/requestmap/save`. *(inferred)* | +| UI form: ACL entry CRUD | `mask`, `granting`, `sid`, `aceOrder` | **Yes** | No default protection. *(inferred)* | +| `Requestmap` table | `url`, `configAttribute` | **No - trusted developer/operator input** | Direct DB writes by the application must be authorized at the application layer. *(inferred)* | +| `Role` table | `authority` | **No - trusted developer/operator input** | Same. A row with `authority = 'ROLE_ADMIN'` is granted by inclusion in `Person.roles`. *(inferred)* | +| `AclSid` / `AclClass` / `AclObjectIdentity` / `AclEntry` tables | All fields | **No - trusted developer/operator input** | `AclClass.className` is loaded via `Class.forName(...)` in `GormAclLookupStrategy`; write access to that column is equivalent to arbitrary classloading. *(inferred)* | +| `application.groovy` / `application.yml` contents | All keys | **No - trusted operator input** | Same posture as the framework's THREAT_MODEL.md. Configuration is part of the TCB. *(inferred)* | +| `application.groovy` Groovy closures (`securityConfig.onAuthenticationSuccessEvent`, `securityConfig.ajaxCheckClosure`, voter closures) | Closure body | **No - trusted developer input** | Evaluated as Groovy code at startup or on each event. *(inferred)* | +| LDAP manager DN/password | from operator config | **No - trusted operator input** | Stored in `application.yml`/`application.groovy`. Operator must protect the config file. *(inferred)* | +| CAS server response | XML body returned by `serverUrlPrefix/serviceValidate` | **No (if TLS)**; **Yes (if HTTP or compromised TLS)** | TLS trust = JVM default trust store. No certificate pinning option. *(inferred)* | +| OAuth2 provider response | JSON body returned by token endpoint | **No (if TLS)**; **Yes (otherwise)** | Same. *(inferred)* | + +### Size, shape, rate assumptions + +- Bearer-token length: bounded by Nimbus JOSE+JWT parser; no plugin-level cap. *(inferred)* +- Password length: capped by `security.ui.password.maxLength` (default 64) on UI flows. Form login does not cap; bcrypt internally hashes only the first 72 bytes. *(documented: [`DefaultUiSecurityConfig.groovy`](./plugin-ui/plugin/grails-app/conf/DefaultUiSecurityConfig.groovy))* +- Request rate: no built-in rate limiting on `/login`, `/register`, `/register/forgotPassword`, or `/api/login`. Operators must add this at a proxy or via `bucket4j` / Spring Security's own brute-force protection. *(inferred)* + +--- + +## §7 Adversary model + +### In-scope adversary A: the unauthenticated HTTP end user + +Capabilities: + +- Crafts arbitrary HTTP requests against any URL pattern the application exposes. +- Sends arbitrary headers, cookies, query parameters, form bodies, JSON bodies, and bearer tokens. +- May submit credentials repeatedly (no built-in rate limiting; see §9). +- May replay state/code parameters in OAuth2 callbacks (state-CSRF is in scope; see §11). +- May replay or modify CSRF tokens against forms not protected by `withForm` (see §9). +- May post a crafted Requestmap / Role / AclEntry / User to any UI controller that lacks authorization (see §11). + +### In-scope adversary B: the authenticated low-privilege user + +Capabilities (in addition to adversary A): + +- Holds a valid `Authentication` in the `SecurityContext` with limited authorities. +- May attempt vertical privilege escalation via: + - Mass-assignment on `/user/update` (POST a `ROLE_ADMIN=on` parameter). + - Direct POST to `/role/save` to create a role, then to `/user/update` to grant it. + - Direct POST to `/requestmap/save` with `configAttribute: permitAll` to wipe the policy. + - Direct POST to `/aclEntry/save` to grant ADMINISTRATION on any object identity. + - Self-registration when `defaultRoleNames` includes a privileged role. + +### In-scope adversary C: the compromised external identity provider + +Capabilities: + +- Returns crafted authentication responses (LDAP, CAS XML, OAuth2 JSON) to the plugin. +- LDAP referral target (if `referral: follow`). +- Issues unintended OAuth2 tokens / claims. + +What this adversary does **not** have: + +- Network position to MITM the TLS channel to the IdP - that is the operator's TLS posture. +- Read or write access to the application filesystem, classpath, env vars, or system properties. +- Co-location on the same JVM. + +### Documented adversary-model statements + +> **Authentication and authorization that the plugin enforces are only as trustworthy as the operator's `password.algorithm`, `jwtSecret`, `rememberMe.key`, `cas.key`, and OAuth2 `apiSecret` configuration values, and the TLS posture of the LDAP/CAS/OAuth2 connections.** A report that requires hostile control of any of those is `OUT-OF-MODEL: trusted-input` (§13). *(maintainer: §14 Q1 resolution)* + +> **Every HTTP endpoint shipped by the UI plugin is unprotected by default. Authorization is the operator's responsibility via Requestmap, `@Secured`, or `staticRules`.** A report that the UI controllers lack `@Secured` annotations is `BY-DESIGN: property-disclaimed` (§13), not a vulnerability. A report that the UI controllers are reachable in a deployment where the operator did configure protection IS in-model. *(maintainer: §14 Q2 resolution; zero `@Secured` annotations confirmed across all 13 UI controllers)* + +### Distributed-system adversary + +Not applicable - the plugins target single-application servlet deployments. Stateless JWT can be used across replicas with a shared HMAC secret; this is a deployment topology, not a consensus protocol. *(inferred)* + +### Out-of-scope adversaries + +- **Local attacker with shell access on the application host.** Such an attacker can modify config, JARs, env vars, and the JVM itself. The plugins cannot defend against them. *(inferred)* +- **Compromised plugin / JAR on the application classpath.** Plugins run with full privileges by design. *(inferred)* +- **Compromised build environment.** Out of model per §3. +- **Co-tenant attacker on the same JVM.** Not modeled; one application per JVM is assumed. *(inferred)* +- **Attacker who controls a Grails plugin or `grails` profile downloaded by a developer running the CLI.** Same posture as the Grails framework's threat model; see that document. *(inferred)* +- **Network attacker capable of MITM against the operator's LDAP/CAS/OAuth2 TLS channels.** Out of scope - TLS trust is the operator's responsibility. *(inferred)* +- **Side-channel observers** (timing, cache, micro-architectural). *(inferred)* + +--- + +## §8 Security properties the plugins provide + +Each property is stated with its conditions, the symptom of a violation, a severity tier, and a provenance tag. Properties are scoped to a specific plugin where applicable. + +| # | Property | Plugin | CWE | Conditions | Violation symptom | Severity | Provenance | +|---|---|---|---|---|---|---|---| +| P1 | **Passwords are stored as bcrypt hashes by default** via `DelegatingPasswordEncoder`. | core | [CWE-256](https://cwe.mitre.org/data/definitions/256.html) | `password.algorithm` is set to `bcrypt`, `pbkdf2`, `scrypt`, or `argon2`; not to `noop` or a message-digest algorithm. | Cleartext or unsalted-digest password storage. | **Security-critical (CVE-eligible)** | *(documented: [hashing.adoc](./plugin-core/docs/src/docs/passwords/hashing.adoc))* | +| P2 | **Session fixation is prevented by default**: a new HTTP session is created on successful authentication and the previous session's attributes are migrated. | core | [CWE-384](https://cwe.mitre.org/data/definitions/384.html) | `useSessionFixationPrevention: true` (default). | The authenticated user retains the pre-login session ID. | **Security-critical (CVE-eligible)** | *(documented: [sessionFixation.adoc](./plugin-core/docs/src/docs/sessionFixation.adoc))* | +| P3 | **Pessimistic URL coverage**: URLs without an explicit Requestmap / `@Secured` / staticRules rule are denied by default. | core | [CWE-862](https://cwe.mitre.org/data/definitions/862.html) | `rejectIfNoRule: true` (default). | An uncovered URL is reachable by an unauthenticated client. | **Security-critical (CVE-eligible)** | *(documented: [requestMappings.adoc](./plugin-core/docs/src/docs/requestMappings.adoc))* | +| P4 | **`FilterSecurityInterceptor` enforces `@Secured` and Requestmap rules at the HTTP-request boundary** before the controller action runs. | core + compat | [CWE-285](https://cwe.mitre.org/data/definitions/285.html) | The plugin's filter chain is registered (default) and the interceptor's `securityMetadataSource` is configured. | A secured URL handler executes for a caller missing the required authority. | **Security-critical (CVE-eligible)** | *(documented: [requestMappings.adoc](./plugin-core/docs/src/docs/requestMappings.adoc))* | +| P5 | **`MutableAclService` evaluates object-level permissions via `AclPermissionEvaluator`** for `@PreAuthorize("hasPermission(...)")` and `@PostFilter` annotations. | acl | [CWE-285](https://cwe.mitre.org/data/definitions/285.html) | The ACL plugin is installed; AclEntry/AclObjectIdentity rows are populated. | Object-level permission check returns GRANT for a principal without a matching ACE. | **Security-critical (CVE-eligible)** | *(inferred)* | +| P6 | **Persistent remember-me detects token theft**: a series-ID match with a non-matching token value invalidates all of that user's tokens. | core | [CWE-294](https://cwe.mitre.org/data/definitions/294.html) | `rememberMe.persistent: true`. | A stolen-and-reused token continues to authenticate after the legitimate user re-uses theirs. | **Security-critical (CVE-eligible)** | *(inferred)* | +| P7 | **Account-status accessors gate authentication**: `isAccountNonExpired`, `isAccountNonLocked`, `isCredentialsNonExpired`, `isEnabled` each throw a distinct exception. | core | [CWE-287](https://cwe.mitre.org/data/definitions/287.html) | `UserDetails` accessors are wired to GORM `User` fields (default). | A locked / expired / disabled account authenticates successfully. | **Security-critical (CVE-eligible)** | *(documented: [locking.adoc](./plugin-core/docs/src/docs/passwords/locking.adoc))* | +| P8 | **LDAP bind authentication forwards credentials to the LDAP server for verification** rather than fetching the password hash. | ldap | [CWE-522](https://cwe.mitre.org/data/definitions/522.html) | `ldap.authenticator.useBind: true` (default). | Authentication accepts credentials the LDAP server rejected. | **Security-critical (CVE-eligible)** | *(documented: [`DefaultLdapSecurityConfig.groovy`](./plugin-ldap/plugin/grails-app/conf/DefaultLdapSecurityConfig.groovy))* | +| P9 | **CAS ticket validation contacts the CAS server over HTTPS to verify a service ticket** before establishing the local `Authentication`. | cas | [CWE-294](https://cwe.mitre.org/data/definitions/294.html) | `cas.serverUrlPrefix` is HTTPS; JVM trust store accepts the CAS server's certificate. | A forged or attacker-supplied service ticket establishes an authenticated session. | **Security-critical (CVE-eligible)** | *(documented: [`SpringSecurityCasGrailsPlugin.groovy`](./plugin-cas/plugin/src/main/groovy/grails/plugin/springsecurity/cas/SpringSecurityCasGrailsPlugin.groovy))* | +| P10 | **JWT signature is verified before claims are trusted** (HMAC for signed JWT, RSA for encrypted JWT). | rest | [CWE-347](https://cwe.mitre.org/data/definitions/347.html) | `rest.token.storage.jwt.secret` is non-null (HMAC mode) OR a non-null `RSAKeyProvider` is wired (encrypted-JWT mode). **Both being null defeats this property** - `JwtService.parse()` accepts `PlainJWT` (`alg=none`) tokens when `useSignedJwt: false` AND `useEncryptedJwt: false` are explicitly set (the default for `useSignedJwt` is `true`, so reaching this state requires an operator to opt out). | An unsigned (`alg=none`) or invalidly-signed JWT establishes an authenticated session. | **Security-critical (CVE-eligible)** | *(maintainer: §14 Q3 resolution; [`JwtService.groovy` lines 71-76](./plugin-rest/spring-security-rest/grails-app/services/grails/plugin/springsecurity/rest/JwtService.groovy))* | +| P11 | **The REST validation filter checks the JWT `exp` claim against current time** before accepting a token. | rest | [CWE-613](https://cwe.mitre.org/data/definitions/613.html) | `JwtTokenStorageService.loadUserByToken` reaches the `expirationTime` comparison. | An expired JWT continues to authenticate. | **Security-critical (CVE-eligible)** | *(inferred)* | +| P12 | **Channel security redirects HTTP to HTTPS when `secureChannel.definition` marks a URL as `REQUIRES_SECURE_CHANNEL`**. | core + compat | [CWE-319](https://cwe.mitre.org/data/definitions/319.html) | `secureChannel.definition` is configured. **Behind a TLS-terminating proxy, also requires `useHeaderCheckChannelSecurity: true` AND a proxy that sets `X-Forwarded-Proto`**. | A request reaches a `REQUIRES_SECURE_CHANNEL` URL over HTTP without redirect. | **Security-critical (CVE-eligible)** | *(documented: [channelSecurity.adoc](./plugin-core/docs/src/docs/channelSecurity.adoc))* | +| P13 | **`withForm` blocks naive CSRF on UI plugin forms that opt in via ``**. | ui | [CWE-352](https://cwe.mitre.org/data/definitions/352.html) | Form rendered with `useToken="true"`; the controller's `withForm` block validates the token. | Token validation accepts a missing or attacker-supplied value. | **Security-critical (CVE-eligible)** | *(inferred)* | +| P14 | **Password comparison delegates to the configured `PasswordEncoder.matches()`**, which is constant-time in `BCryptPasswordEncoder`, `Pbkdf2PasswordEncoder`, `Argon2PasswordEncoder`, and `SCryptPasswordEncoder`. | core | [CWE-208](https://cwe.mitre.org/data/definitions/208.html) | `password.algorithm` is one of the listed encoders. The `noop` and message-digest encoders are **not** guaranteed constant-time. | Remote timing oracle distinguishes valid vs invalid credentials. | **Hardening / context-dependent** | *(inferred)* | +| P15 | **Username enumeration via authentication-exception type is suppressed by default** (`hideUserNotFoundExceptions: true`, plus `NoStackUsernameNotFoundException` mapped to `AuthenticationFailureBadCredentialsEvent`). | core | [CWE-204](https://cwe.mitre.org/data/definitions/204.html) | `hideUserNotFoundExceptions: true` (default). | Unknown-username and bad-password failures produce distinguishable responses (status code, error message, or event type). | **Security-critical (CVE-eligible)** | *(inferred)* | + +### Resource consumption line + +- Bcrypt with `logrounds <= 12` and `password.maxLength <= 72` keeps per-attempt CPU bounded. *(maintainer: §14 Q6 resolution - the production default of 10 falls under this bound)* +- DoS via large `logrounds` set by the operator is `OUT-OF-MODEL: non-default-build` per §3. *(inferred)* +- Super-linear behavior in user-supplied passwords below the configured `maxLength` is a bug. *(inferred)* + +--- + +## §9 Security properties the plugins do NOT provide + +These properties are **disclaimed by design**. A report that depends on one of them is a `BY-DESIGN: property-disclaimed` triage outcome (see §13). + +- **CSRF protection on REST/JWT endpoints.** Bearer tokens are the auth credential; there is no synchronizer-token mechanism on `/api/login`, `/api/validate`, or any application endpoint protected by `RestTokenValidationFilter`. The application MUST treat CORS configuration as the front-line CSRF defense for REST APIs. *(inferred)* +- **CSRF protection on forms not protected by ``.** The plugin ships `useToken` opt-in; the registration form (`register.gsp`) and the password-reset form (`resetPassword.gsp`) do NOT use it. *(inferred)* +- **Anti-bot or rate limiting on `/login`, `/register`, `/register/forgotPassword`, `/api/login`.** No CAPTCHA, no built-in throttling. Operators must add this at the proxy / via Spring Security's `LoginUrlAuthenticationFailureHandler` extensions / via `bucket4j`. *(inferred)* +- **Reset-token and registration-code expiry.** `RegistrationCode` records carry a `dateCreated` field but the `verifyRegistration` and `resetPassword` actions perform no expiry check. A reset token remains valid until consumed or manually deleted. *(inferred)* +- **Account-enumeration resistance on `/register/forgotPassword`.** The action returns a distinguishable response (a field-level error on `forgotPasswordCommand.errors`) for unknown usernames. *(inferred)* +- **Refresh-token rotation and replay detection.** `RestOauthController.accessToken` reuses the supplied refresh token verbatim on the new access-token response. A stolen refresh token can be used an unlimited number of times until its `refreshExpiration` elapses (default `null`, no expiry). *(inferred)* +- **Server-side revocation of stateless JWT.** `JwtTokenStorageService.removeToken()` throws `TokenNotFoundException` unconditionally; the logout endpoint returns HTTP 404 for JWT-backend deployments. **JWT logout is cosmetic.** *(inferred)* +- **JWT claim validation beyond `exp`.** `JwtService` and `JwtTokenStorageService` do NOT validate `iss`, `aud`, `nbf`, `iat`, or `kid`. No issuer allow-list, no audience binding. *(inferred)* +- **JWT algorithm allow-list.** `rest.token.storage.jwt.algorithm` accepts any Nimbus algorithm string. There is no constraint preventing operator configuration of `HS256` and accepting `RS256` or vice versa. *(inferred)* +- **Rejection of `alg=none` JWTs when both `jwtSecret` and `keyProvider` are null.** `JwtService.parse()` accepts `PlainJWT` in this configuration. The plugin will boot in this state when `useSignedJwt: false` AND `useEncryptedJwt: false` (the operator must explicitly disable both, since `useSignedJwt` defaults to `true`). *(maintainer: §14 Q3 resolution; follow-up F1 will add a startup-time rejection)* +- **PKCE for OAuth2 authorization code flow.** `ServiceBuilder.withPkce()` is not invoked. *(inferred)* +- **Cryptographically secure OAuth2 `state` parameter.** The plugin uses `java.util.Random` over a 1,000,000-value space. *(inferred)* +- **`X-Forwarded-For` awareness in `IpAddressFilter`.** Only `request.remoteAddr` is consulted. *(inferred)* +- **`X-Forwarded-Port` awareness in `PortResolverImpl`.** The channel redirect URL inherits `request.serverPort`, which is the backend port behind a TLS-terminating proxy. *(inferred)* +- **CAS server certificate pinning.** TLS trust collapses to the JVM default trust store. *(inferred)* +- **LDAP StartTLS.** The plugin does not wire StartTLS negotiation. *(inferred)* +- **Default TLS for LDAP.** `ldap.context.server` defaults to `ldap://`. *(inferred)* +- **Session fixation prevention when CAS single-logout is enabled.** Setting `cas.useSingleSignout: true` (default for the CAS plugin) **disables** `useSessionFixationPrevention` globally. *(inferred)* +- **Mass-assignment protection in UI domain bindings.** The UI plugin's services use `instanceOrClass.newInstance(data)` / `instance.properties = data` with the raw `params` map. No `bindable: false` is declared on any domain class shipped by the plugin. *(inferred)* +- **Default authorization on UI plugin endpoints.** No `@Secured` annotation on any UI controller; no default Requestmap row. *(inferred)* +- **Password encoding in UI `saveUser` / `updateUser` when `security.ui.encodePassword: false` (default).** The UI service stores submitted passwords without encoding in this default configuration. *(documented: [`DefaultUiSecurityConfig.groovy`](./plugin-ui/plugin/grails-app/conf/DefaultUiSecurityConfig.groovy))* +- **Java deserialization safety for Redis / Memcached token-storage backends.** `RedisTokenStorageService.deserialize` and `CustomSerializingTranscoder` read `ObjectInputStream` from the configured backend. If the backend is reachable by an attacker, this is a deserialization-RCE sink. *(inferred)* +- **Cross-tenant isolation.** One application per JVM is assumed. *(inferred)* +- **Transport security.** Provided by Spring Boot / the operator's proxy / the operator's LDAP/CAS/OAuth2 endpoints. *(inferred)* + +### False-friend properties (the highest-value section for integrators) + +Features that **look like** a security property but are not one. Reports that confuse a false friend for the real thing are `KNOWN-NON-FINDING` (§11a) when documented and `BY-DESIGN: property-disclaimed` (§13) when not. + +- **`@Secured("ROLE_ADMIN")` is not enforced by the annotation itself.** It populates `FilterSecurityInterceptor`'s metadata source; if the interceptor is not in the filter chain (e.g., user-defined `SecurityFilterChain` shadowed it via `ComponentBasedConfigBlender`), the annotation is inert. *(inferred)* +- **`bindable: false` on a UI domain class is not honored by the UI plugin's own save/update flows.** The plugin's `setProperties(params, instance, ...)` calls `instance.properties = params`, which respects `bindable: false` declared on the **domain class** but does NOT add one if the application has not. The plugin ships no `bindable` constraints on `User`, `Role`, `Requestmap`, `Person`, etc. The application must add them. *(inferred)* +- **`useToken="true"` on `` is CSRF protection only for the actions that wrap the body in `withForm { } invalidToken { }`.** Forms rendered with `useToken="true"` but POSTing to an action that does not call `withForm` are not protected. The registration form has no `useToken` attribute; the password-reset form has no `useToken` attribute. *(inferred)* +- **`hideUserNotFoundExceptions: true` does not hide enumeration on UI flows.** It only affects the authentication failure event/exception type. The `RegisterController.forgotPassword` action emits a distinguishable response for unknown usernames irrespective of this flag. *(inferred)* +- **`cas.useSingleSignout: true` is a security feature whose implementation requires disabling another security feature.** Operators who enable SLO and rely on session-fixation prevention have neither. *(inferred)* +- **`excludeSpringSecurityAutoConfiguration: false` does not "gracefully fall back" to Boot's defaults.** Both servlet security stacks register their own filter chains. The README documents this as "a footgun" with no precedence guarantee. *(documented: [README.md](./README.md))* +- **`useEncryptedJwt: true` with `DefaultRSAKeyProvider` is not production-ready** despite booting successfully. The provider generates a fresh RSA key pair on each JVM start; tokens are unusable across restarts and across pods. *(inferred)* +- **`rest.token.storage.jwt.algorithm = HS256` is not pinned to HS256-only verification.** Operators reading the doc as "we use HS256" should verify the runtime configuration of `useSignedJwt` / `useEncryptedJwt`; the validation path branches on the JWT type, not on this property. *(inferred)* +- **`ipRestrictions` looks like network-layer protection but operates on `request.remoteAddr`.** Behind a reverse proxy that does not preserve the source IP at the TCP layer, the filter sees only the proxy address. *(inferred)* +- **`secureChannel.useHeaderCheckChannelSecurity: true` makes the channel decision proxy-aware, but `PortResolverImpl` is not** - the redirect URL still uses `request.serverPort`. The asymmetry is real and code-confirmed: `SecurityRequestHolderFilter` lines 83-113 wrap the request so that `isSecure()`, `getScheme()`, and the wrapper's `getServerPort()` consult `X-Forwarded-Proto`; this drives the channel decision in `HeaderCheckSecureChannelProcessor`. But once a redirect fires, `RetryWithHttpsEntryPoint` / `RetryWithHttpEntryPoint` build the target URL via `portResolver.getServerPort(request)` (which returns the raw `request.serverPort` of the unwrapped request) and `request.serverName` (never overridden). The redirect URL therefore carries the backend hostname and port even when the proxy is sending `X-Forwarded-*` headers. Operators must rely on the proxy to rewrite the redirect Location header in this case. *(maintainer: §14 Q9 resolution)* +- **`RUN_AS_*` config attributes in the compat shim are not HMAC-protected when constructed.** `RunAsManagerImpl.buildRunAs` does not sign the substituted token; `RunAsImplAuthenticationProvider.authenticate()` is a pass-through (`return authentication`) and `supports()` returns `true` unconditionally - the `key` field is declared in both classes but never read. Upstream Spring Security 5.x performed a `keyHash` equality check against the configured key's `hashCode()`; the compat shim in this repo does NOT perform that check, making it *more* permissive than the class it replaces. A `RUN_AS_*` attribute injected into the metadata source elevates privilege for the duration of the call. The opt-in default (`useRunAs: false`) and the operator-controlled-config-attribute source mean this is a `KNOWN-NON-FINDING` unless both preconditions are violated. *(maintainer: §14 Q15 resolution; follow-up F8)* +- **`GroovyAwareAclVoter` grants `ACCESS_GRANTED` unconditionally for Groovy meta-methods** (`getMetaClass`, `setMetaClass`, `invokeMethod`, `getProperty`, `setProperty`, etc.) on secured objects. This is intentional - Groovy meta-method access must not be ACL-gated - but it means a low-privilege caller can invoke `setMetaClass` on a secured bean regardless of ACL state. *(inferred)* +- **`AffirmativeBased` first-grant-wins semantics mean a permissive voter anywhere in the list overrides every denial.** Operators adding custom voters must ensure they vote `ACCESS_ABSTAIN` rather than `ACCESS_GRANTED` for cases they do not authoritatively handle. *(inferred)* +- **`spring.security.user.name` / `password` / `roles` in production config silently creates a valid credential** with `{noop}` password hashing when `componentBased.bridgeSpringSecurityUserProperties: true` (default). A leftover development convenience becomes a production credential. *(inferred)* +- **`security.ui.encodePassword: false` (the default) stores submitted passwords without hashing** when the UI plugin creates or updates a user. The flag name reads as a behavior toggle but its default is the insecure value. *(inferred)* +- **`AntPathRequestMatcher` does not normalize `../` or URL-encoded path segments** before matching. A request like `/admin%2F..%2Fpublic` may match a `permitAll` rule for `/public/**` while the servlet routes the request to `/admin/...`. Path normalization is expected to have occurred upstream in the servlet container. *(inferred)* + +### Well-known attack classes against this category of project that the plugins do not defend against + +One sentence per class. + +- **Credential stuffing.** No rate-limiting, lockout-after-N-failures, or distributed-attack detection. *(inferred)* +- **Token theft via XSS.** When the application stores REST/JWT tokens in `localStorage`, an XSS in the application leaks the token. The plugins offer no defense - they neither set `HttpOnly` cookies for the token nor restrict its rendering. *(inferred)* +- **OAuth2 authorization-code interception.** PKCE is not used. *(inferred)* +- **OAuth2 state-CSRF.** State is generated from `java.util.Random` over 1M values. *(inferred)* +- **Session-store deserialization gadget chains.** When `SecurityContext` or `SynchronizerTokensHolder` is serialized to a Java-serialization-backed session store, classpath gadgets become reachable. *(inferred)* +- **LDAP referral chasing.** Setting `referral: follow` invites JNDI redirection to an attacker-controlled directory. *(inferred)* +- **CAS proxy ticket abuse.** PGT receptor endpoints trust the CAS server on TLS alone. *(inferred)* +- **Persistent-login table read.** The UI plugin's `PersistentLoginController.search` exposes live remember-me token values to anyone reaching the endpoint. *(inferred)* + +--- + +## §10 Downstream responsibilities + +For the assumptions in §5-§7 to hold, the **application developer / operator** must: + +1. Set `password.algorithm = bcrypt` (or `pbkdf2` / `argon2` / `scrypt`) and confirm `password.bcrypt.logrounds >= 12` for production. Never use `noop` or message-digest. *(documented: [hashing.adoc](./plugin-core/docs/src/docs/passwords/hashing.adoc))* +2. Set `rememberMe.key` to a strong random string (>=32 random bytes) when `rememberMe.persistent: false`. *(inferred)* +3. Set `cas.key` to a strong random string in production; the default `'grails-spring-security-cas'` is the value shipped in source. *(documented)* +4. For REST/JWT deployments, set `rest.token.storage.jwt.secret` to >=256 random bits, OR configure a non-`DefaultRSAKeyProvider` key source. Never deploy with both null. *(inferred)* +5. Set `rest.token.storage.jwt.refreshExpiration` to a finite value. *(inferred)* +6. Keep `rejectIfNoRule: true` (default) and ensure every URL the application exposes has a Requestmap / `@Secured` / `staticRules` entry. *(documented: [requestMappings.adoc](./plugin-core/docs/src/docs/requestMappings.adoc))* +7. Add explicit Requestmap or `@Secured` protection for every UI plugin endpoint that is installed: `/user/**`, `/role/**`, `/requestmap/**`, `/registrationCode/**`, `/persistentLogin/**`, `/aclClass/**`, `/aclEntry/**`, `/aclObjectIdentity/**`, `/aclSid/**`, `/securityInfo/**`. **The UI plugin ships none.** *(inferred)* +8. Set `security.ui.encodePassword: true` for production. *(documented: [`DefaultUiSecurityConfig.groovy`](./plugin-ui/plugin/grails-app/conf/DefaultUiSecurityConfig.groovy))* +9. Declare `bindable: false` on every privileged field of `User`, `Person`, or equivalent domain class (`accountLocked`, `accountExpired`, `enabled`, `passwordExpired`, `roles`, plus any role-association property). *(inferred)* +10. Validate `security.ui.register.defaultRoleNames` and `oauth2.registration.roleNames` against the set of roles that may be granted to a self-registered user. Never include `ROLE_ADMIN`. *(inferred)* +11. Use HTTPS (`ldaps://` or StartTLS via custom strategy) for LDAP; ensure JVM trust store accepts the CAS and OAuth2 provider certificates; never deploy with HTTP `cas.serverUrlPrefix`. *(inferred)* +12. When behind a TLS-terminating proxy, configure `secureChannel.useHeaderCheckChannelSecurity: true` AND ensure the proxy sets `X-Forwarded-Proto`. Note that channel redirects will still carry the backend port unless the proxy is configured to rewrite the response location. *(documented: [channelSecurity.adoc](./plugin-core/docs/src/docs/channelSecurity.adoc))* +13. When using `ipRestrictions` behind a proxy, configure the proxy to pass-through or rewrite source IPs at the TCP layer; the filter does not honor `X-Forwarded-For`. *(inferred)* +14. Set `oauth2.providers.{name}.debug: false` for production deployments. *(inferred)* +15. Validate `oauth2.frontendCallbackUrl` against an allow-list before redirecting; the plugin appends the access token to the URL as a query parameter. *(inferred)* +16. Add a CAPTCHA or rate-limiter on `/login`, `/api/login`, `/register`, `/register/forgotPassword`. *(inferred)* +17. Disable `rest.login.useRequestParamsCredentials` (the default `false`); never accept credentials in URL query strings. *(documented)* +18. Lock down DB write access to `Requestmap`, `Role`, `AclClass`, `AclEntry`, `AclObjectIdentity`, `AclSid`, and `PersistentLogin` tables. A `permitAll` row in `Requestmap` voids the entire authorization policy. *(inferred)* +19. For Redis / Memcached token-storage backends, place the store on an isolated network and enable AUTH. The token-storage transcoders deserialize Java-serialized payloads without sanitization. *(inferred)* +20. For applications that mount the CAS plugin, accept that session-fixation prevention is disabled while `cas.useSingleSignout: true`; mitigate by enforcing TLS, `HttpOnly` + `Secure` session cookies, and short session timeouts. *(inferred)* +21. Apply `bindable: false` or an explicit `include`/`exclude` on every UI form binding path that touches a privileged domain class. *(inferred)* +22. Never source `application.groovy` or `grails.config.locations` from attacker-writable storage; closures defined there are arbitrary code execution. *(inferred)* +23. Treat `RegistrationCode.token` as a sensitive value: rotate / delete after use, set a manual expiry mechanism (Quartz cron, `lastUsed` cleanup), do not log. *(inferred)* + +--- + +## §11 Known misuse patterns + +In-the-wild patterns the API permits but that violate the assumptions in §5-§7. + +- **Posting to `/user/save` or `/user/update` with `ROLE_ADMIN=on` as a form parameter.** The UI plugin's `roleNamesFromParams()` collects every key matching `ROLE_*` with value `on` and grants those roles. A low-privilege authenticated user (or unauthenticated client if `/user/save` is unprotected) escalates by setting role checkboxes the UI never rendered. *Fix*: enforce role-grant authorization at the controller layer; render the form server-side with only the roles the current admin is allowed to grant; reject unknown role-named keys. *(inferred)* +- **Posting to `/requestmap/save` with `url='/**', configAttribute='permitAll'`.** Wipes the entire application's authorization policy on the next `clearCachedRequestmaps()` call. *Fix*: protect `/requestmap/**` behind the strongest available authority and a separate change-control workflow; consider gating writes behind an out-of-band approval step. *(inferred)* +- **Posting to `/role/save` to create `ROLE_ADMIN`, then `/user/update` to grant it.** Two-step privilege escalation. *Fix*: same as above; also restrict the set of role names creatable via the UI. *(inferred)* +- **Posting to `/aclEntry/save` to grant ADMINISTRATION on an arbitrary object identity to the current SID.** Direct ACL takeover. *Fix*: protect `/aclEntry/**` with the authority configured for `acl.authority.changeAclDetails`. *(inferred)* +- **Self-registering when `security.ui.register.defaultRoleNames` contains a privileged role.** Every verified-email user becomes an administrator. *Fix*: keep `defaultRoleNames` at `['ROLE_USER']` or equivalent; require manual approval to grant additional roles. *(documented: [`DefaultUiSecurityConfig.groovy`](./plugin-ui/plugin/grails-app/conf/DefaultUiSecurityConfig.groovy))* +- **Deploying with `security.ui.encodePassword: false` (the documented default).** Submitted passwords are stored without encoding. *Fix*: explicitly set `security.ui.encodePassword: true` in production config. *(documented)* +- **Setting `cas.useSingleSignout: true` while expecting session-fixation prevention.** Both cannot be true; SLO disables fixation prevention globally. *Fix*: prefer per-request session-cookie hardening (`HttpOnly`, `Secure`, `SameSite=Lax`), short session timeouts, and authentication-required redirects rather than SLO; OR accept the trade-off and document the residual risk. *(inferred)* +- **Setting `excludeSpringSecurityAutoConfiguration: false` to "preserve Spring Boot's defaults".** Both stacks register filter chains; precedence is undefined. *Fix*: leave the default `true`. *(documented: [README.md](./README.md))* +- **Deploying with `useSignedJwt: false`, `useEncryptedJwt: false`, `jwtSecret: null`, no `RSAKeyProvider`.** `JwtService.parse()` accepts `PlainJWT` (`alg=none`) tokens. **Trivial authentication bypass.** *Fix*: explicitly set `useSignedJwt: true` AND a non-null `jwtSecret` of >=256 random bits; reject the configuration combination above at startup. *(maintainer: §14 Q3 resolution; tracked as follow-up F1)* +- **Deploying with `DefaultRSAKeyProvider` in production** (ephemeral keys generated per JVM start). Tokens are unusable across pods. *Fix*: configure `FileRSAKeyProvider` with operator-managed DER keys. *(inferred)* +- **Setting `oauth2.{provider}.debug: true` in production.** Raw OAuth traffic, including tokens, is logged. *Fix*: keep `debug: false` outside of development. *(inferred)* +- **Setting LDAP `referral: 'follow'`.** JNDI follows referrals to arbitrary servers. *Fix*: keep `referral: null` (default). *(inferred)* +- **Deploying with default `ldap://localhost:389` URL.** Manager credentials and user passwords transit in cleartext. *Fix*: change to `ldaps://`. *(documented: [`DefaultLdapSecurityConfig.groovy`](./plugin-ldap/plugin/grails-app/conf/DefaultLdapSecurityConfig.groovy))* +- **Reusing the published `cas.key` default `'grails-spring-security-cas'`.** Cross-deployment forgery becomes practical. *Fix*: regenerate per deployment. *(documented)* +- **Putting reset / registration URLs through a TLS-terminating proxy that injects an attacker-controlled `Host` header.** The reset email's link is built from `request.serverName`; an attacker who can manipulate `Host` may receive credential resets. *Fix*: set `grails.serverURL` in production config or have the proxy strip / validate the `Host` header. *(inferred)* +- **Exposing `/securityInfo/config` or `/securityInfo/filterChains` to any non-admin caller.** These endpoints dump the live security configuration including filter ordering, voter list, and bean wiring - a high-value reconnaissance prize. *Fix*: protect `/securityInfo/**` with the strongest available authority. *(inferred)* +- **Allowing the `paramName` query parameter on `AbstractS2UiDomainController.ajaxSearch` to drive a GORM `ilike` criterion property name without validation.** A crafted `paramName` may probe non-search fields or trigger errors that disclose schema; worse, it is also concatenated into a raw HQL `order by "..."` string at `doSearch()` line 171. *Fix*: validate against an allow-list before binding; constrain `params.order` to `['asc','desc']`. *(maintainer: §14 Q18 resolution; tracked as follow-up F10)* +- **Storing security questions or answers in plaintext.** The plugin compares answers via `passwordEncoder.matches(submittedAnswer, storedAnswer)`, expecting stored answers to be hash-encoded. If the operator stores answers in cleartext, comparison fails silently; if they pre-hash with a different encoder, the same. *Fix*: hash answers with the same `PasswordEncoder` used for passwords. *(inferred)* +- **Trusting that `useToken="true"` covers all UI plugin forms.** It does not cover `register.gsp` or `resetPassword.gsp`. *Fix*: add `useToken="true"` and wrap the controller body in `withForm` in any future UI plugin form additions; for the registration / reset paths, add per-request CAPTCHA. *(inferred)* + +--- + +## §11a Known non-findings (recurring false positives) + +The mirror of §11: patterns that scanners, fuzzers, AI analyzers, or human reviewers repeatedly flag against this project that **are not bugs given the model**. Feed this section to suppression configurations. + +- **"`Class.forName(name)` in `GormAclLookupStrategy` line 298"** ([`GormAclLookupStrategy.groovy`](./plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/jdbc/GormAclLookupStrategy.groovy)) - SAST flags reflective class loading. The `name` originates from the `AclClass.className` database column, written by the application's own ACL-administration code, not by HTTP request input. Discharged by §6 trust assumption on the ACL tables ("trusted developer/operator input"). → `OUT-OF-MODEL: trusted-input` provided the application's own ACL-administration endpoints are authorized. +- **"`Class.forName(clientClass)` in `RestOauthService` line 62"** ([`RestOauthService.groovy`](./plugin-rest/spring-security-rest/grails-app/services/grails/plugin/springsecurity/rest/RestOauthService.groovy)) and **"`grailsApplication.getClassForName(tokenClassName)` in `GormTokenStorageService`"** ([`GormTokenStorageService.groovy`](./plugin-rest/spring-security-rest-gorm/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GormTokenStorageService.groovy)) - SAST flags config-driven class loading. Class names come from `application.yml`/`application.groovy`, not from request data. Discharged by §6. → `OUT-OF-MODEL: trusted-input`. +- **"`GroovyClassLoader.loadClass(className)` in `SpringSecurityUtils.mergeConfig`"** - loads named configuration classes (e.g. `DefaultSecurityConfig`, secondary-plugin defaults). Class names are hardcoded in plugin descriptors. → `OUT-OF-MODEL: trusted-input`. +- **"Groovy `Closure` execution in `SecurityEventListener` / `ClosureVoter`"** - closures are defined in `application.groovy` at deploy time. Discharged by §6 trust assumption on `application.groovy`. → `OUT-OF-MODEL: trusted-input` (or `BY-DESIGN: property-disclaimed` if the assumption is violated). +- **"Dynamic property access `user.\"$propertyName\"` in `GormUserDetailsService`"** - property names come from `securityConfig.userLookup.*PropertyName`, a trusted config object. → `OUT-OF-MODEL: trusted-input`. +- **"`@PreAuthorize` / `@PostFilter` SpEL evaluation with `StandardEvaluationContext`"** ([`ExpressionBasedPreInvocationAdvice.groovy`](./spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedPreInvocationAdvice.groovy)) - SAST flags SpEL with `T(...)` type references reachable. Annotation text is compiled into the bytecode; it is not constructed from HTTP request input anywhere in the plugins. Discharged by §6 trust assumption on annotation source. → `OUT-OF-MODEL: trusted-input` provided no application code path feeds user input into the annotation string. +- **"`AffirmativeBased` first-grant-wins decision semantics"** - documented Spring Security 5.x behavior, vendored into `spring-security-compat`. → `KNOWN-NON-FINDING` (architectural decision, not a bug). +- **"`GroovyAwareAclVoter` grants `ACCESS_GRANTED` unconditionally for Groovy meta-methods"** ([`GroovyAwareAclVoter.groovy`](./plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/GroovyAwareAclVoter.groovy)) - intentional. Groovy meta-method access must not be ACL-gated; doing so would break method dispatch on every secured bean. → `KNOWN-NON-FINDING`. +- **"`AntPathRequestMatcher` does not normalize `../` or URL-encoded segments"** ([`AntPathRequestMatcher.groovy`](./spring-security-compat/src/main/groovy/org/springframework/security/web/util/matcher/AntPathRequestMatcher.groovy)) - documented Spring Security 5.x behavior. Normalization is the servlet container's responsibility. → `OUT-OF-MODEL: unsupported-component` for the container's normalization posture; the application must rely on Tomcat / Jetty defaults or add an explicit normalization filter. +- **"Java deserialization in `JwtService.deserialize`"** ([`JwtService.groovy`](./plugin-rest/spring-security-rest/grails-app/services/grails/plugin/springsecurity/rest/JwtService.groovy)) - the deserialized payload was serialized by the same application at token-generation time and is protected by the JWT signature. Discharged by §8 P10 (signature verified before claims are trusted). **In-model only if** the `alg=none` path is reachable (see §9 disclaimer and §11 misuse); otherwise → `KNOWN-NON-FINDING`. +- **"Java deserialization in `RedisTokenStorageService.deserialize` and `CustomSerializingTranscoder` (Memcached)"** - discharged by §5 environment assumption that the Redis / Memcached backend is operator-controlled and network-isolated. **In-model only if** the backend is reachable by an untrusted client (the operator's network posture); → `OUT-OF-MODEL: trusted-input` for the framework, escalating to `VALID` if the operator's network is not isolated. +- **"`Serializable` classes in plugin source"** (`PersistentLogin`, `RegistrationCode`, `User` / `Person` / `Role` domain classes) - the domain classes implement `Serializable` with a stable `serialVersionUID = 1L` for GORM's optimistic-locking / session-serialization needs. **No custom `readObject` / `writeObject` methods exist** in any of the 22 example domain class files under `plugin-*/examples/`. The plugin does not ship User/Person/Role as source files - they are generated from `s2-quickstart` templates. Discharged by §6 trust assumption on the session store. → `OUT-OF-MODEL: trusted-input`. *(maintainer: §14 Q17 resolution)* +- **"`@Secured("ROLE_X")` present but no in-method authz logic"** - identical to the framework's posture. The annotation is enforced by `FilterSecurityInterceptor`, not inline. → `KNOWN-NON-FINDING`. +- **"`MutableRoleHierarchy.setHierarchy(String)` mutates the role hierarchy at runtime"** ([`MutableRoleHierarchy.groovy`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy)) - SAST flags a privilege-escalation surface. The hierarchy string is loaded from `RoleHierarchyEntry` DB rows at startup. Discharged by §6 trust assumption on the role hierarchy table. → `OUT-OF-MODEL: trusted-input` for the framework; the application's authorization on writes to `RoleHierarchyEntry` is its own responsibility (see §10 #18). +- **"`InsecureChannelProcessor` actively redirects HTTPS to HTTP"** ([`InsecureChannelProcessor.groovy`](./spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/InsecureChannelProcessor.groovy)) - the processor is invoked only on URLs explicitly marked `REQUIRES_INSECURE_CHANNEL` in `secureChannel.definition`. Operator-opted-in by design. → `KNOWN-NON-FINDING` provided no `REQUIRES_INSECURE_CHANNEL` rule covers a sensitive URL. +- **"Cleartext Memcached / Redis protocol traffic"** - the plugin transmits serialized `UserDetails` over the cache protocol. Discharged by §5 environment assumption. → `OUT-OF-MODEL: trusted-input`. +- **"`MutableLogoutFilter` allows post-logout redirect via `targetUrlParameter` without origin validation"** ([`MutableLogoutFilter.groovy`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/logout/MutableLogoutFilter.groovy)) - by default `targetUrlParameter` is null and the redirect goes to `defaultTargetUrl: '/'`. Open-redirect only manifests when the operator sets `logout.targetUrlParameter` and does not validate the value at the application layer. → `OUT-OF-MODEL: non-default-build`. + +--- + +## §12 Conditions that would change this model + +Revise this document on: + +- Addition of a new HTTP endpoint family (e.g., a new admin controller in `plugin-ui`). +- Promotion of any UI controller to default-protected (e.g., shipping default Requestmap rows in plugin bootstrap). +- Migration of `spring-security-compat` off the vendored Spring Security 5.x classes onto Spring Security 6/7 native authorization API. +- Adding PKCE support to `plugin-oauth2`. +- Replacing `java.util.Random` with `SecureRandom` for the OAuth2 `state` parameter. +- Adding an algorithm allow-list to `JwtService.parse()` and rejecting `PlainJWT` (`alg=none`) unconditionally. *(§14 follow-up F1, F7)* +- Adding `X-Forwarded-For` / `X-Forwarded-Port` awareness to `IpAddressFilter` and `PortResolverImpl`. +- Adding a startup-time rejection of the `useSignedJwt: false` + `useEncryptedJwt: false` + null-secret + null-key-provider combination. *(§14 follow-up F1)* +- Adding an allow-list / package-prefix check in `GormAclLookupStrategy.lookupClass()` before `Class.forName`. *(§14 follow-up F9)* +- Adding allow-list validation on `AbstractS2UiDomainController.ajaxSearch` `paramName` and a closed-set check on `params.order`. *(§14 follow-up F10)* +- Porting the upstream Spring Security 5.x `keyHash` equality check into the compat shim's `RunAsImplAuthenticationProvider`. *(§14 follow-up F8)* +- Emitting a startup `WARN` from `SpringSecurityUiService.initialize()` when `encodePassword = false` and no `PasswordEncoderListener`-style hook is wired on the User class. *(§14 follow-up F3)* +- Changing any §5a default that affects a §8 property (notably `password.algorithm`, `useSessionFixationPrevention`, `rejectIfNoRule`, `security.ui.encodePassword`). +- Introduction of a built-in CSRF subsystem for REST/JWT (would move items from §9 into §8). +- A vulnerability report that **cannot** be cleanly routed to one of the §13 dispositions - that is a `MODEL-GAP` and indicates this document is incomplete. Revise rather than ad-hoc the call. + +--- + +## §13 Triage dispositions + +Every report against the plugins receives **exactly one** of the following dispositions. Each cites the section that licenses it. A finding that does not fit is `MODEL-GAP` and triggers a §12 revision, not an ad-hoc judgment. + +| Disposition | Meaning | Licensed by | +|---|---|---| +| `VALID` | Violates a property the plugins claim, via an in-scope adversary and input. | §8, §6, §7 | +| `VALID-HARDENING` | No §8 property is violated, but the API makes a §11 misuse easy enough that the project elects to harden it. Reported privately per [SECURITY.md](./SECURITY.md); fixed at maintainer discretion; typically no CVE. | §11 | +| `OUT-OF-MODEL: trusted-input` | Requires attacker control of an input the model marks trusted (classpath, `application.groovy`, `grails.config.locations`, the ACL / Requestmap / Role / RoleHierarchyEntry tables, LDAP manager credentials, JWT secret, CAS key, OAuth2 client secret, Redis / Memcached cache contents, AST transform inputs). | §6 | +| `OUT-OF-MODEL: adversary-not-in-scope` | Requires an attacker capability the model excludes (local shell, JVM co-tenant, side channel, compromised plugin, MITM on operator-trusted TLS channels). | §7 | +| `OUT-OF-MODEL: unsupported-component` | Lands in `plugin-*/examples/`, `spring-security-rest-testapp-profile`, or a third-party plugin. | §3 | +| `OUT-OF-MODEL: non-default-build` | Only manifests under a discouraged or non-default §5a configuration (e.g., `password.algorithm = noop`, `useSessionFixationPrevention: false`, `rejectIfNoRule: false`, `security.ui.encodePassword: false`, `oauth2.{provider}.debug: true`, `ldap://` URL, `ldap.referral: follow`, `cas.useSingleSignout: true`, `excludeSpringSecurityAutoConfiguration: false`, `useSignedJwt: false` AND `useEncryptedJwt: false` AND null secret). | §5a | +| `BY-DESIGN: property-disclaimed` | Concerns a property the plugins explicitly do not provide (CSRF on REST, anti-bot, reset-token expiry, refresh-token rotation, JWT revocation, PKCE, secure state, `X-Forwarded-*` awareness, default UI authorization). | §9 | +| `KNOWN-NON-FINDING` | Matches a documented recurring false positive. | §11a | +| `MODEL-GAP` | Cannot be cleanly routed to any of the above. | triggers §12 | + +--- + +## §14 Ratification log + +The model was published in draft form with 22 open questions grouped into three waves plus three meta items. This section records the resolution of each question with code-grounded evidence. Resolved questions promote their matching `*(inferred)*` tags in the body of this document to `*(maintainer)*`. Code-level follow-up items identified during ratification are listed at the end of this section and tracked as separate issues, not as blockers on this document. + +The investigation reading the cited line numbers ran in PR #1224. Where a citation reads `file.groovy:NN`, the line number is correct at the SHA on the head of `docs/threat-model-8.0.x`. + +### Wave 1 - scope and intended use (resolved) + +**Q1. Caller-role split.** *Resolution: adopt §2 as-is.* The five roles are the correct primitives. The anonymous-token posture (`GrailsAnonymousAuthenticationToken`, wired in [`DefaultSecurityConfig.groovy`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/DefaultSecurityConfig.groovy) and applied by `AnonymousAuthenticationFilter`) is a sub-state of the unauthenticated HTTP client role, not a separate principal - an attacker exploiting a misconfigured `IS_AUTHENTICATED_ANONYMOUSLY` grant is covered by adversary A in §7. Long-lived JWT machine-to-machine clients are a sub-state of the authenticated low-privilege role; they hold a valid token with limited authorities. No sixth row needed. + +**Q2. UI plugin endpoints ship unprotected by design.** *Resolution: confirmed.* Code evidence: zero `@Secured` annotations across all 13 controllers under `plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/`; zero Requestmap rows in [`DefaultUiSecurityConfig.groovy`](./plugin-ui/plugin/grails-app/conf/DefaultUiSecurityConfig.groovy); zero authorization wiring in [`SpringSecurityUiGrailsPlugin`](./plugin-ui/plugin/src/main/groovy/grails/plugin/springsecurity/ui/SpringSecurityUiGrailsPlugin.groovy) `doWithSpring` / `doWithApplicationContext`. Operator responsibility is total. Reports that UI controllers lack `@Secured` annotations are `BY-DESIGN: property-disclaimed` (§13); reports that they are reachable in a deployment where the operator *did* configure protection are in-model (§13 `VALID`). The highest-risk unprotected endpoint is [`SecurityInfoController`](./plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/SecurityInfoController.groovy) which dumps the live filter chain, voter list, authentication providers, and user cache - this is the first endpoint an operator must lock down. + +**Q3. `jwtSecret` null + `keyProvider` null + `useSignedJwt: false` + `useEncryptedJwt: false`.** *Resolution: confirmed reachable misconfiguration; not strictly `non-default-build` because only one knob (`useSignedJwt`, defaulted to `true`) needs to be flipped.* Code evidence: [`JwtService.groovy`](./plugin-rest/spring-security-rest/grails-app/services/grails/plugin/springsecurity/rest/JwtService.groovy) lines 71-76: + +```groovy +} else if (jwt instanceof PlainJWT) { + log.debug "Parsed a plain JWT" + if (jwtSecret || keyProvider) { + throw new JOSEException('Unsigned/unencrypted JWT not expected') + } +} +``` + +The guard at line 73 throws ONLY when `jwtSecret` is truthy OR `keyProvider` is non-null. When both are null/empty, the `PlainJWT` branch falls through silently and `parse()` returns the token at line 78. The `checkJwtSecret()` validation in [`SpringSecurityRestGrailsPlugin.groovy`](./plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGrailsPlugin.groovy) lines 326-333 is called only inside the `useSignedJwt = true` branch (line 263). No startup check rejects the all-null combination. The application boots into the PlainJWT-accepting state with HTTP-200 responses for forged tokens. Disposition: `OUT-OF-MODEL: non-default-build` for the unreachable combination of operator deliberately disabling all signing knobs; `MODEL-GAP` if a report demonstrates a configuration path that does not require the operator to explicitly set `useSignedJwt: false`. See follow-up item F1 below. + +**Q4. `cas.useSingleSignout: true` disables session-fixation prevention.** *Resolution: confirmed BY-DESIGN; documented in source comment, undocumented in per-plugin docs.* Code evidence: [`DefaultCasSecurityConfig.groovy`](./plugin-cas/plugin/grails-app/conf/DefaultCasSecurityConfig.groovy) line 33 sets `useSingleSignout = true` as the literal default. [`SpringSecurityCasGrailsPlugin.groovy`](./plugin-cas/plugin/src/main/groovy/grails/plugin/springsecurity/cas/SpringSecurityCasGrailsPlugin.groovy) lines 85-89: + +```groovy +if (conf.cas.useSingleSignout) { + // session fixation prevention breaks single signout because + // the service ticket is mapped to the session id which changes + conf.useSessionFixationPrevention = false +``` + +The trade-off is explicit and intentional in the bean wiring. Reports against this are `BY-DESIGN: property-disclaimed`. See follow-up F2: add a documentation note in [`plugin-cas/docs/src/docs/configuration.adoc`](./plugin-cas/docs/src/docs/configuration.adoc) linking the two knobs so operators do not discover the consequence only via the source code. + +**Q5. `security.ui.encodePassword: false` (the default).** *Resolution: keep `false` as the default; add a hardening note in §10 and a startup `WARN` as a follow-up.* Code evidence: [`DefaultUiSecurityConfig.groovy`](./plugin-ui/plugin/grails-app/conf/DefaultUiSecurityConfig.groovy) line 23 sets `encodePassword = false`; [`SpringSecurityUiService.groovy`](./plugin-ui/plugin/grails-app/services/grails/plugin/springsecurity/ui/SpringSecurityUiService.groovy) line 810 enforces the same fallback for non-Boolean values. The rationale is documented in [`customization.adoc`](./plugin-ui/docs/src/docs/customization.adoc) lines 275-284: the `s2-quickstart` script generates a `Person` domain class with a `PasswordEncoderListener` that hashes on `beforeInsert`/`beforeUpdate`. Flipping the default to `true` would double-hash passwords in every freshly generated application, breaking authentication silently. Operators using the older `Person.groovy.template` (no listener) must set `encodePassword: true` explicitly. See follow-up F3. + +**Q6. Default `password.bcrypt.logrounds: 10`.** *Resolution: confirmed as the production default; matches Spring Security upstream `BCryptPasswordEncoder` strength of 10.* Code evidence: [`DefaultSecurityConfig.groovy`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/DefaultSecurityConfig.groovy) lines 193-207 explicitly branch on `Environment.current == Environment.TEST`. The TEST environment uses `logrounds = 4` and `iterations = 1`; the `else` branch (DEVELOPMENT, PRODUCTION, and all other environments) uses `logrounds = 10` and `iterations = 10000`. The note in [`hashing.adoc`](./plugin-core/docs/src/docs/passwords/hashing.adoc) lines 71-72 about "lower for testing" refers to the test value of 4, not the production value of 10. The §10 #1 recommendation of `>= 12` stands as a hardening guideline for sensitive deployments above the supported default. + +**Q7. `spring.security.user.name` bridging.** *Resolution: confirmed intentional; the `{noop}` prefix mirrors Spring Boot's `UserDetailsServiceAutoConfiguration` semantics.* Code evidence: [`SpringSecurityCoreGrailsPlugin.groovy`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy) line 781 hard-codes the default of `true`. [`ComponentBasedConfigBlender.groovy`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy) lines 193-208 build the `InMemoryUserDetailsManager` with the unconditional `{noop}` prefix - the bridge replicates exactly what Spring Boot's auto-configuration would have produced. The §11 misuse pattern (leftover `spring.security.user.name` in production config) stands; operators must keep that property unset outside development. + +### Wave 2 - trust boundaries (resolved) + +**Q8. `IpAddressFilter` does not consult `X-Forwarded-For`.** *Resolution: confirmed BY-DESIGN.* Code evidence: [`IpAddressFilter.groovy`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/IpAddressFilter.groovy) line 109 reads `request.remoteAddr` for the loopback shortcut; the CIDR match at line 130 delegates to Spring Security's `IpAddressMatcher.matches(HttpServletRequest)`, which internally calls `request.getRemoteAddr()`. No call to `request.getHeader(...)` appears anywhere in the file. A global grep across the repo confirms zero `X-Forwarded-For` references outside comments and this document. Reports are `BY-DESIGN: property-disclaimed`. + +**Q9. `PortResolverImpl` does not consult `X-Forwarded-Port`.** *Resolution: confirmed BY-DESIGN with one cross-reference note.* Code evidence: [`PortResolverImpl.groovy`](./spring-security-compat/src/main/groovy/org/springframework/security/web/PortResolverImpl.groovy) lines 31-33: + +```groovy +int getServerPort(HttpServletRequest request) { + request.serverPort +} +``` + +Both [`RetryWithHttpsEntryPoint.groovy`](./spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/RetryWithHttpsEntryPoint.groovy) line 39 and [`RetryWithHttpEntryPoint.groovy`](./spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/RetryWithHttpEntryPoint.groovy) line 39 call `portResolver.getServerPort(request)` to build the redirect URL via `UrlUtils.buildFullRequestUrl`. Cross-reference note: [`SecurityRequestHolderFilter`](./plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/SecurityRequestHolderFilter.groovy) lines 83-113 DO wrap the request when `useHeaderCheckChannelSecurity: true` to make `isSecure()`/`getScheme()`/`getServerPort()` consult `X-Forwarded-Proto` - the *channel decision* is proxy-aware. The *redirect URL construction* is not; the wrapper's `getServerPort()` still derives from `request.serverPort`. This asymmetry is real and is now documented in the false-friend list in §9. + +**Q10. OAuth2 state generated by `java.util.Random`.** *Resolution: confirmed VALID-HARDENING.* Code evidence: [`OAuth2AbstractProviderService.groovy`](./plugin-oauth2/plugin/src/main/groovy/grails/plugin/springsecurity/oauth2/service/OAuth2AbstractProviderService.groovy) line 103: + +```groovy +final String secretState = getProviderID() + "-secret-" + new Random().nextInt(999_999) +``` + +Approximately 20 bits of entropy from a `java.util.Random` LCG seeded from `System.nanoTime()`. Zero `SecureRandom` references in the `plugin-oauth2` tree. Fix is a one-line change to `SecureRandom` plus widening the bound, or replacement with `UUID.randomUUID().toString()`. Targeted as follow-up F4. + +**Q11. PKCE not configured in `plugin-oauth2`.** *Resolution: confirmed VALID-HARDENING.* Code evidence: `OAuth2AbstractProviderService.buildScribeService` (lines 115-128) constructs the `ServiceBuilder` with `apiKey`, `apiSecret`, `callback`, `scope`, and `debug` only. Zero matches for `withPkce`, `pkce`, `PKCE`, `code_challenge`, or `code_verifier` anywhere in the plugin-oauth2 tree. Targeted as follow-up F5. + +**Q12. Refresh-token reuse (no rotation).** *Resolution: confirmed supported behavior; mitigated by short `refreshExpiration`.* Code evidence: [`RestOauthController.groovy`](./plugin-rest/spring-security-rest/grails-app/controllers/grails/plugin/springsecurity/rest/RestOauthController.groovy) lines 173-181 generate a new access token with `withRefreshToken=false` and reassign the caller-supplied `refreshToken` string verbatim back onto the new `AccessToken`. None of the four storage backends (JWT/GORM/Redis/Memcached) invalidate the presented refresh token on use. Reports are `BY-DESIGN: property-disclaimed`. Operators implementing RFC 6749 §10.4 rotation must do so at the application layer. See follow-up F6 for a doc-only fix. + +**Q13. JWT logout is a no-op for stateless JWT.** *Resolution: confirmed; operators requiring revocation must use a stateful backend.* Code evidence: [`JwtTokenStorageService.groovy`](./plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/jwt/JwtTokenStorageService.groovy) lines 124-128 throw `TokenNotFoundException` unconditionally; [`RestLogoutFilter.groovy`](./plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/RestLogoutFilter.groovy) lines 72-77 catch it and send HTTP 404. Every JWT logout returns 404. GORM/Redis/Memcached/GrailsCache token storage services implement real `removeToken` that deletes the record. Reports are `BY-DESIGN: property-disclaimed` for JWT-backend deployments and `VALID` only if a stateful backend is configured but the delete fails. + +**Q14. JwtService accepts any Nimbus-supported algorithm string.** *Resolution: confirmed VALID-HARDENING.* Code evidence: `JwtService.parse()` (lines 53-78) dispatches on the Java type returned by `Nimbus JWTParser.parse()` (`SignedJWT` / `EncryptedJWT` / `PlainJWT`), not on the value of the `alg` header. `MACVerifier` is instantiated unconditionally with `jwtSecret` and accepts HS256, HS384, or HS512 for any signed JWT. The generation-time `algorithm` config from `DefaultRestSecurityConfig.groovy` line 50 (`algorithm = 'HS256'`) is consumed only by `SignedJwtTokenGenerator` (line 270); it is never consulted during parsing. Targeted as follow-up F7. + +### Wave 3 - misuse and §11a curation (resolved) + +**Q15. `RUN_AS_*` in the compat shim is not HMAC-signed.** *Resolution: confirmed KNOWN-NON-FINDING, with one nuance the original draft missed.* Code evidence: [`RunAsManagerImpl.groovy`](./spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManagerImpl.groovy) `buildRunAs` (lines 37-64) builds a plain `UsernamePasswordAuthenticationToken`; the `key` field at line 32 is declared but never read. [`RunAsImplAuthenticationProvider.groovy`](./spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsImplAuthenticationProvider.groovy) lines 32-39: `authenticate()` is a pass-through, `supports()` returns `true` unconditionally. *Nuance*: upstream Spring Security 5.x performs a `keyHash != this.key.hashCode()` equality check (verified at SHA `4942459` of `spring-projects/spring-security`); the compat shim in this repo does NOT perform that check. The shim is therefore *more* permissive than the upstream class it replaces. This does not change the disposition - the entire RUN_AS surface is opt-in (`useRunAs: false` by default) and a `RUN_AS_*` config attribute is operator-controlled - but the §9 false-friend entry now records the shim's permissiveness explicitly. See follow-up F8 for considering an upstream-equivalent `hashCode()` guard in the shim. + +**Q16. `AclClass.className` is operator-trusted.** *Resolution: confirmed §6 trusted-input assumption.* Code evidence: [`GormAclLookupStrategy.groovy`](./plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/jdbc/GormAclLookupStrategy.groovy) lines 296-299 call `Class.forName(className, true, Thread.currentThread().contextClassLoader)`; the value originates from `AclClass.className` (line 258-260 call chain). The `true` argument initializes the class on load, which makes static initializers and `` blocks reachable. Severity is bounded by the classpath but the pattern is unambiguous: write access to the `AclClass` table is equivalent to arbitrary class initialization at the next ACL lookup. Reports are `OUT-OF-MODEL: trusted-input` provided the application's ACL-administration endpoints are authorized; **note that the UI plugin's `AclClassController` (Q2) ships without `@Secured`, so the write path is unprotected unless the operator adds a Requestmap rule**. See follow-up F9 for an allow-list or package-prefix guard. + +**Q17. GORM serialization of `User` / `Person` / `Role` for HTTP session.** *Resolution: confirmed KNOWN-NON-FINDING.* Code evidence: the plugin does not ship these domain classes as source files - they are generated by `s2-quickstart` from templates. All 22 example domain class files in `plugin-*/examples/` implement `Serializable` with `private static final long serialVersionUID = 1L`. No custom `readObject` / `writeObject` methods exist. Standard Java serialization with a stable UID and no plugin-introduced gadgets. + +**Q18. `AbstractS2UiDomainController.ajaxSearch` accepts `paramName` without validation.** *Resolution: confirmed VALID-HARDENING.* Code evidence: [`AbstractS2UiDomainController.groovy`](./plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AbstractS2UiDomainController.groovy) lines 189-210. `params.paramName` is taken directly from the HTTP request and used as: (a) the GORM criterion property name on `ilike`, (b) the projection property name on `distinct`, and (c) the `params.sort` value that is concatenated into a raw HQL `order by "' + propertyName + '"` string at `doSearch()` line 171. No allow-list. The ORDER BY string concatenation is the sharpest edge - the `propertyName` value is wrapped in double quotes but not otherwise sanitized. Targeted as follow-up F10. The fix is an allow-list keyed off `classMappings[clazz]` and a closed-set check on `params.order ∈ ['asc','desc']`. + +**Q19. The §13 disposition table is closed and complete.** *Resolution: confirmed.* The nine dispositions cover every reasoned outcome encountered while drafting; new outcomes trigger a §12 revision (`MODEL-GAP`). No plugin-specific disposition has been required. + +### Meta (resolved) + +**Q20. Document ownership.** *Resolution: confirmed.* This file lives at the repository root, is maintained by the Apache Grails PMC, and is revised on the §12 triggers. The next release branch (post-8.0.x) MUST fork this document with its own `Version binding` line in §1 - a report against version *N* is triaged against this document as it stood at *N*, not at HEAD. + +**Q21. Coexistence with `SECURITY.md`.** *Resolution: confirmed.* [`SECURITY.md`](./SECURITY.md) is the disclosure-process artifact (where to report, what to include, supported versions); this document is the model (what is in scope, what properties are claimed, how reports are triaged). `SECURITY.md` already cross-references this document throughout §3-§11a; no additional change required. + +**Q22. Coexistence with per-plugin `docs/src/docs/`.** *Resolution: confirmed.* The per-plugin AsciiDoc files remain end-user-facing prose; this document is triage-facing. Appendix A enumerates the back-map of every claim in the existing per-plugin documentation to its corresponding threat-model section. Where the per-plugin documentation and this document would conflict, the per-plugin documentation wins; the resolution is to raise a §14-equivalent open question on the next revision rather than silently amend this document. + +### Code-level follow-up items identified during ratification + +These follow-ups were identified while answering the questions above. They are tracked as separate work items (issues to be filed against this repository) and are NOT blockers on ratification of this document. Each is a §12-triggering change that would re-baseline a §9 disclaimer to a §8 property or strengthen an existing property's defense in depth. + +| ID | Question | Code location | Proposed action | +|---|---|---|---| +| F1 | Q3 | [`SpringSecurityRestGrailsPlugin.groovy`](./plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGrailsPlugin.groovy) bean wiring | Reject startup when `useSignedJwt = false` AND `useEncryptedJwt = false` AND `jwtSecret == null` AND no `keyProvider` bean is wired. Mirrors the existing `checkJwtSecret` pattern. | +| F2 | Q4 | [`plugin-cas/docs/src/docs/configuration.adoc`](./plugin-cas/docs/src/docs/configuration.adoc) `useSingleSignout` row | Add an explicit NOTE that `useSingleSignout: true` (the default) forces `useSessionFixationPrevention = false`; reference §9 of this document. | +| F3 | Q5 | [`SpringSecurityUiService.initialize()`](./plugin-ui/plugin/grails-app/services/grails/plugin/springsecurity/ui/SpringSecurityUiService.groovy) | Emit a startup `WARN` when `encodePassword = false` AND no `PasswordEncoderListener`-style bean / GORM listener is detected on the configured User class. Eliminates the silent-plaintext failure mode without breaking the double-hash case. | +| F4 | Q10 | [`OAuth2AbstractProviderService.groovy:103`](./plugin-oauth2/plugin/src/main/groovy/grails/plugin/springsecurity/oauth2/service/OAuth2AbstractProviderService.groovy) | Replace `new Random().nextInt(999_999)` with `SecureRandom`-backed full-int-range randomness, or use `UUID.randomUUID().toString()`. | +| F5 | Q11 | [`OAuth2AbstractProviderService.buildScribeService`](./plugin-oauth2/plugin/src/main/groovy/grails/plugin/springsecurity/oauth2/service/OAuth2AbstractProviderService.groovy) lines 115-128 | Invoke `ServiceBuilder.withPkce()`. Public-client providers should always use PKCE; confidential clients may opt out via a new per-provider `usePkce` config (default `true`). | +| F6 | Q12 | [`plugin-rest/docs/src/docs/tokenStorage.adoc`](./plugin-rest/docs/src/docs/tokenStorage.adoc) | Add a WARNING under the refresh-token section citing RFC 6749 §10.4 and pointing operators at `refreshExpiration` as the only built-in mitigation for refresh-token replay. | +| F7 | Q14 | [`JwtService.parse()`](./plugin-rest/spring-security-rest/grails-app/services/grails/plugin/springsecurity/rest/JwtService.groovy) lines 53-78 | After `JWTParser.parse()`, compare `jwt.header.algorithm` against the configured `rest.token.storage.jwt.algorithm` value (and the matching encrypted-algorithm pair) and throw `JOSEException` on mismatch before entering the type-dispatch branches. | +| F8 | Q15 | [`RunAsImplAuthenticationProvider.groovy`](./spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsImplAuthenticationProvider.groovy) lines 32-39 | Consider porting the upstream Spring Security 5.x `keyHash` equality check so the compat shim is no more permissive than the class it replaces. Low priority - opt-in (`useRunAs: false` default) and operator-controlled config attributes are upstream of this check. | +| F9 | Q16 | [`GormAclLookupStrategy.lookupClass()`](./plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/jdbc/GormAclLookupStrategy.groovy) lines 296-299 | Add an allow-list or package-prefix check on `className` before `Class.forName`. Pair with a §10 downstream-responsibility item requiring operators to protect `AclClassController.save/update` (already required by Q2 resolution). | +| F10 | Q18 | [`AbstractS2UiDomainController.ajaxSearch`](./plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AbstractS2UiDomainController.groovy) lines 189-210 | Validate `params.paramName` against `classMappings[clazz]` keys before passing to `doSearch`. Validate `params.order ∈ ['asc', 'desc']` as a closed set. The ORDER BY string concatenation at `doSearch()` line 171 should also adopt a property-name escaper. | + +--- + +## §15 Machine-readable companion + +A YAML sidecar at [`threat-model.yaml`](./threat-model.yaml) carries the triage-relevant facts in structured form, regenerated whenever this prose document changes. The prose document is canonical; the YAML is a derived index for automated triage tooling. + +--- + +## Appendix A - back-map: existing documentation → threat-model section + +This back-map proves coverage. Every threat-model-shaped claim already in the repository's own documentation is reflected somewhere in this document. + +| Existing claim (file:line) | Original wording (paraphrase) | This document | +|---|---|---| +| [`plugin-core/docs/src/docs/passwords/hashing.adoc`](./plugin-core/docs/src/docs/passwords/hashing.adoc) | "By default the plugin uses the bcrypt algorithm to hash passwords." | §8 P1, §5a `password.*` knobs, §10 #1 | +| [`plugin-core/docs/src/docs/passwords/salt.adoc`](./plugin-core/docs/src/docs/passwords/salt.adoc) | "If you use bcrypt or pbkdf2, do not configure a salt - these algorithms use their own internally." | §8 P1, §11 misuse | +| [`plugin-core/docs/src/docs/passwords/locking.adoc`](./plugin-core/docs/src/docs/passwords/locking.adoc) | "`isAccountNonExpired`, `isAccountNonLocked`, `isCredentialsNonExpired`, `isEnabled` accessors gate authentication." | §8 P7 | +| [`plugin-core/docs/src/docs/sessionFixation.adoc`](./plugin-core/docs/src/docs/sessionFixation.adoc) | "Set `useSessionFixationPrevention` to `true` to prevent session-fixation attacks." | §8 P2, §5a, §11 | +| [`plugin-core/docs/src/docs/channelSecurity.adoc`](./plugin-core/docs/src/docs/channelSecurity.adoc) | "`secureChannel.definition` map of URL pattern to channel rule." | §8 P12, §5a, §9 false-friend on `useHeaderCheckChannelSecurity`, §10 #12 | +| [`plugin-core/docs/src/docs/requestMappings.adoc`](./plugin-core/docs/src/docs/requestMappings.adoc) | "Pessimistic lockdown is the default - `rejectIfNoRule: true`." | §8 P3, §5a, §10 #6 | +| [`plugin-core/docs/src/docs/voters.adoc`](./plugin-core/docs/src/docs/voters.adoc) | "Default voters: `authenticatedVoter`, `roleVoter`, `webExpressionVoter`, `closureVoter`." | §4 flow A, §8 P4, §9 false-friend on `AffirmativeBased` | +| [`plugin-rest/docs/src/docs/tokenStorage.adoc`](./plugin-rest/docs/src/docs/tokenStorage.adoc) | "Default JWT signing is HMAC SHA-256 with a shared secret." | §8 P10, §5a, §9 disclaimer on `alg=none` | +| [`plugin-rest/docs/src/docs/tokenValidation.adoc`](./plugin-rest/docs/src/docs/tokenValidation.adoc) | "RFC 6750 Bearer token; default validation looks for the token in `Authorization`." | §6 inputs, §8 P10, §8 P11 | +| [`plugin-rest/docs/src/docs/tokenStorage.adoc`](./plugin-rest/docs/src/docs/tokenStorage.adoc) | "Refresh tokens never expire by default - section 10.4 of RFC 6749 reminds you to store them securely." | §9 disclaimer, §10 #5 | +| [`plugin-cas/docs/src/docs/configuration.adoc`](./plugin-cas/docs/src/docs/configuration.adoc) | CAS configuration properties, including `useSingleSignout`. | §5a, §9 false-friend | +| [`plugin-ldap/docs/src/docs/configuration.adoc`](./plugin-ldap/docs/src/docs/configuration.adoc) | LDAP configuration, including default `ldap://`. | §5a, §9 disclaimer | +| [`plugin-oauth2/docs/src/docs/configuration.adoc`](./plugin-oauth2/docs/src/docs/configuration.adoc) | OAuth2 configuration properties. | §5a, §9 disclaimer | +| [`plugin-ui/docs/src/docs/`](./plugin-ui/docs/src/docs/) | UI plugin scripts and forms. | §2 (UI as the only HTTP-active plugin), §11, §10 #7 | +| [`README.md`](./README.md) | "The plugin automatically excludes Spring Boot's servlet security auto-configuration." | §5a `excludeSpringSecurityAutoConfiguration`, §9 false-friend | +| [`README.md`](./README.md) | Component-based config blending: `autoMergeSecurityFilterChain`, `autoMergeAuthenticationProviders`, `autoChainUserDetailsServices`, `bridgeSpringSecurityUserProperties`. | §5a core knobs, §11 misuse | + +No claim in the existing documentation is dropped, weakened, or contradicted by this document. Where the existing documentation and this document would conflict, the documentation wins; raise a §14 question rather than silently editing. diff --git a/threat-model.yaml b/threat-model.yaml new file mode 100644 index 000000000..9fc1c28e4 --- /dev/null +++ b/threat-model.yaml @@ -0,0 +1,799 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed 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. + +# Machine-readable companion to THREAT_MODEL.md (§15). +# THREAT_MODEL.md is canonical; regenerate this file whenever the prose +# document changes. + +project: + name: apache/grails-spring-security + version_binding: "8.0.x" + threat_model: THREAT_MODEL.md + status: draft_awaiting_pmc_review + date: "2026-01" + disclosure: SECURITY.md + ratification_pr: "https://github.com/apache/grails-spring-security/pull/1224" + +# §14 Ratification log - status of the 22 open questions originally collected in +# the draft. Each entry summarises the resolution; the canonical version with +# full evidence and code citations is in THREAT_MODEL.md §14. +ratification_log: + wave_1: + - id: Q1 + topic: caller_role_split + resolution: confirmed_adopt_as_is + summary: "Five roles in §2 are the correct primitives. Anonymous-token posture is a sub-state of unauthenticated; long-lived JWT M2M clients are a sub-state of authenticated low-privilege." + - id: Q2 + topic: ui_plugin_endpoints_unprotected + resolution: confirmed + disposition_for_reports: "BY-DESIGN: property-disclaimed (when operator did not add a rule); VALID (when operator did configure protection)" + evidence: "zero @Secured annotations across all 13 UI controllers; zero Requestmap rows in DefaultUiSecurityConfig.groovy; zero authz wiring in SpringSecurityUiGrailsPlugin" + - id: Q3 + topic: jwt_alg_none_acceptance + resolution: confirmed_reachable_misconfiguration + disposition_for_reports: "OUT-OF-MODEL: non-default-build (requires explicit useSignedJwt:false); MODEL-GAP if a reachable configuration path is found that does not require operator opt-out" + evidence: "JwtService.groovy:71-76 - PlainJWT branch only throws when jwtSecret OR keyProvider is truthy; checkJwtSecret guard is inside the useSignedJwt:true branch only" + followup: F1 + - id: Q4 + topic: cas_useSingleSignout_session_fixation + resolution: confirmed_by_design + disposition_for_reports: "BY-DESIGN: property-disclaimed" + evidence: "DefaultCasSecurityConfig.groovy:33 - useSingleSignout=true default; SpringSecurityCasGrailsPlugin.groovy:85-89 forces useSessionFixationPrevention=false with explicit source comment" + followup: F2 + - id: Q5 + topic: ui_encodePassword_default_false + resolution: keep_default_false + reasoning: "s2-quickstart generates Person with PasswordEncoderListener that hashes on beforeInsert/beforeUpdate; flipping default would double-hash and break authentication silently" + followup: F3 + - id: Q6 + topic: bcrypt_logrounds_production_default + resolution: confirmed_10_is_production_default + evidence: "DefaultSecurityConfig.groovy:193-207 - TEST=4, all other environments=10; matches Spring Security upstream BCryptPasswordEncoder default strength of 10" + hardening_recommendation: "§10 #1 recommends >=12 above the default for sensitive deployments" + - id: Q7 + topic: spring_security_user_bridge + resolution: confirmed_intentional + evidence: "SpringSecurityCoreGrailsPlugin.groovy:781 hard-codes default true; ComponentBasedConfigBlender.groovy:193-208 wires {noop}-prefixed InMemoryUserDetailsManager mirroring Spring Boot's UserDetailsServiceAutoConfiguration" + wave_2: + - id: Q8 + topic: ipAddressFilter_x_forwarded_for + resolution: confirmed_by_design + disposition_for_reports: "BY-DESIGN: property-disclaimed" + evidence: "IpAddressFilter.groovy:109,130 uses request.remoteAddr only; IpAddressMatcher.matches(HttpServletRequest) internally calls request.getRemoteAddr(); zero X-Forwarded-For references in repo" + - id: Q9 + topic: portResolverImpl_x_forwarded_port + resolution: confirmed_by_design_with_asymmetry_note + disposition_for_reports: "BY-DESIGN: property-disclaimed" + evidence: "PortResolverImpl.groovy:31-33 returns request.serverPort; RetryWithHttps/HttpEntryPoint:39 build redirect URL via portResolver and request.serverName; SecurityRequestHolderFilter:83-113 makes channel DECISION proxy-aware but redirect URL CONSTRUCTION is not" + - id: Q10 + topic: oauth2_state_javautilRandom + resolution: valid_hardening + evidence: "OAuth2AbstractProviderService.groovy:103 - new Random().nextInt(999_999); zero SecureRandom usage in plugin-oauth2 tree" + followup: F4 + - id: Q11 + topic: oauth2_pkce_absent + resolution: valid_hardening + evidence: "OAuth2AbstractProviderService.buildScribeService (115-128) never invokes ServiceBuilder.withPkce(); zero withPkce/pkce/code_challenge/code_verifier matches" + followup: F5 + - id: Q12 + topic: refresh_token_no_rotation + resolution: confirmed_by_design + disposition_for_reports: "BY-DESIGN: property-disclaimed" + evidence: "RestOauthController.groovy:173-181 reassigns supplied refreshToken back onto new AccessToken; none of JWT/GORM/Redis/Memcached storage backends invalidate presented refresh token on use" + followup: F6 + - id: Q13 + topic: jwt_logout_no_op + resolution: confirmed_by_design + disposition_for_reports: "BY-DESIGN: property-disclaimed (JWT backend); VALID only if stateful backend delete fails" + evidence: "JwtTokenStorageService.groovy:124-128 throws TokenNotFoundException unconditionally; RestLogoutFilter.groovy:72-77 catches and sends HTTP 404; GORM/Redis/Memcached implement real removeToken" + - id: Q14 + topic: jwt_algorithm_no_allow_list + resolution: valid_hardening + evidence: "JwtService.parse() (53-78) dispatches on Nimbus Java type (SignedJWT/EncryptedJWT/PlainJWT), never compares alg header to configured rest.token.storage.jwt.algorithm" + followup: F7 + wave_3: + - id: Q15 + topic: run_as_compat_shim_no_hmac + resolution: known_non_finding_with_nuance + disposition_for_reports: "KNOWN-NON-FINDING (when useRunAs=false default OR RUN_AS_* attribute is not injectable)" + evidence: "RunAsManagerImpl.buildRunAs builds plain UsernamePasswordAuthenticationToken; key field never read in either RunAsManagerImpl or RunAsImplAuthenticationProvider" + nuance: "Compat shim is MORE permissive than upstream Spring Security 5.x, which performs a keyHash equality check via hashCode(). Verified at upstream SHA 4942459 of spring-projects/spring-security." + followup: F8 + - id: Q16 + topic: aclClass_className_operator_trusted + resolution: confirmed_section6_trusted_input + disposition_for_reports: "OUT-OF-MODEL: trusted-input (provided ACL administration endpoints are authorized)" + evidence: "GormAclLookupStrategy.groovy:296-299 - Class.forName(className, true, contextClassLoader); className from AclClass.className DB column at line 259" + nuance: "AclClassController (Q2) ships without @Secured, so the write path is unprotected unless operator adds Requestmap rule" + followup: F9 + - id: Q17 + topic: gorm_serialization_user_person_role + resolution: known_non_finding + evidence: "Plugin does not ship User/Person/Role as source - generated from s2-quickstart templates. All 22 example domain classes implement Serializable with serialVersionUID=1L; no custom readObject/writeObject" + - id: Q18 + topic: ajaxSearch_paramName_unvalidated + resolution: valid_hardening + evidence: "AbstractS2UiDomainController.groovy:189-210 - params.paramName drives GORM criterion property name AND is concatenated into raw HQL order-by string at doSearch:171 with only double-quote wrapping" + followup: F10 + - id: Q19 + topic: section_13_dispositions_complete + resolution: confirmed + meta: + - id: Q20 + topic: document_ownership + resolution: confirmed + summary: "Repo-root file, maintained by PMC, revised on §12 triggers; release branches fork with own version binding" + - id: Q21 + topic: security_md_coexistence + resolution: confirmed + summary: "SECURITY.md is disclosure-process artifact; this document is the model. SECURITY.md already cross-references §3-§11a; no further change needed" + - id: Q22 + topic: per_plugin_docs_coexistence + resolution: confirmed + summary: "Per-plugin AsciiDoc remains end-user-facing; threat model is triage-facing; Appendix A back-map enumerates the citations" + +# Follow-up items identified during ratification - tracked as separate issues, +# not blocking on this document's ratification. +followup_items: + - id: F1 + question: Q3 + location: SpringSecurityRestGrailsPlugin.groovy bean wiring + action: "Reject startup when useSignedJwt=false AND useEncryptedJwt=false AND jwtSecret=null AND no keyProvider bean" + severity: high + - id: F2 + question: Q4 + location: plugin-cas/docs/src/docs/configuration.adoc + action: "Add doc note linking useSingleSignout=true to forced useSessionFixationPrevention=false" + severity: low_doc_only + - id: F3 + question: Q5 + location: SpringSecurityUiService.initialize() + action: "Emit startup WARN when encodePassword=false AND no PasswordEncoderListener-style hook detected on User class" + severity: medium + - id: F4 + question: Q10 + location: OAuth2AbstractProviderService.groovy:103 + action: "Replace new Random().nextInt(999_999) with SecureRandom full-int-range or UUID.randomUUID()" + severity: medium + - id: F5 + question: Q11 + location: OAuth2AbstractProviderService.buildScribeService (115-128) + action: "Invoke ServiceBuilder.withPkce(); add per-provider usePkce config (default true)" + severity: medium + - id: F6 + question: Q12 + location: plugin-rest/docs/src/docs/tokenStorage.adoc + action: "Add WARNING citing RFC 6749 §10.4 on refresh-token replay; point operators at refreshExpiration mitigation" + severity: low_doc_only + - id: F7 + question: Q14 + location: JwtService.parse() (53-78) + action: "After JWTParser.parse(), compare jwt.header.algorithm to configured rest.token.storage.jwt.algorithm and throw JOSEException on mismatch" + severity: medium + - id: F8 + question: Q15 + location: RunAsImplAuthenticationProvider.groovy:32-39 + action: "Port upstream Spring Security 5.x keyHash equality check" + severity: low + - id: F9 + question: Q16 + location: GormAclLookupStrategy.lookupClass() (296-299) + action: "Add allow-list or package-prefix check on className before Class.forName" + severity: medium + - id: F10 + question: Q18 + location: AbstractS2UiDomainController.ajaxSearch (189-210) + action: "Validate paramName against classMappings[clazz] keys; validate params.order in ['asc','desc']" + severity: medium + +# §2 - component families and their in/out-of-model disposition. +components: + - name: core_authentication_authorization + in_model: true + representative_files: + - plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy + - plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityUtils.groovy + - plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/AbstractFilterInvocationDefinition.groovy + - name: gorm_user_store + in_model: true + representative_files: + - plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GormUserDetailsService.groovy + - plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/rememberme/GormPersistentTokenRepository.groovy + - name: spring_boot_autoconfig_exclusion + in_model: true + representative_files: + - plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy + - plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy + - name: acl_object_level_permissions + in_model: true + representative_files: + - plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/SpringSecurityAclGrailsPlugin.groovy + - plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/jdbc/GormAclLookupStrategy.groovy + - name: spring_security_compat_shim + in_model: true + notes: "Vendored copies of Spring Security 5.x classes removed in 6/7." + representative_files: + - spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.groovy + - spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AffirmativeBased.groovy + - spring-security-compat/src/main/groovy/org/springframework/security/web/util/matcher/AntPathRequestMatcher.groovy + - spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManagerImpl.groovy + - name: ldap_authentication + in_model: true + representative_files: + - plugin-ldap/plugin/src/main/groovy/grails/plugin/springsecurity/ldap/SpringSecurityLdapGrailsPlugin.groovy + - plugin-ldap/plugin/grails-app/conf/DefaultLdapSecurityConfig.groovy + - name: cas_single_sign_on + in_model: true + representative_files: + - plugin-cas/plugin/src/main/groovy/grails/plugin/springsecurity/cas/SpringSecurityCasGrailsPlugin.groovy + - plugin-cas/plugin/grails-app/conf/DefaultCasSecurityConfig.groovy + - name: oauth2_client + in_model: true + representative_files: + - plugin-oauth2/plugin/src/main/groovy/grails/plugin/springsecurity/oauth2/service/OAuth2AbstractProviderService.groovy + - plugin-oauth2/plugin/src/main/groovy/grails/plugin/springsecurity/oauth2/util/OAuth2ProviderConfiguration.groovy + - name: rest_jwt_authentication + in_model: true + representative_files: + - plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFilter.groovy + - plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/RestTokenValidationFilter.groovy + - plugin-rest/spring-security-rest/grails-app/services/grails/plugin/springsecurity/rest/JwtService.groovy + - name: rest_token_storage_backends + in_model: true + representative_files: + - plugin-rest/spring-security-rest-gorm/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GormTokenStorageService.groovy + - plugin-rest/spring-security-rest-redis/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/RedisTokenStorageService.groovy + - plugin-rest/spring-security-rest-memcached/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/memcached/MemcachedTokenStorageService.groovy + - plugin-rest/spring-security-rest-grailscache/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GrailsCacheTokenStorageService.groovy + - name: ui_plugin_controllers + in_model: true + notes: "The ONLY plugin that ships live HTTP endpoints." + representative_files: + - plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/UserController.groovy + - plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RoleController.groovy + - plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RequestmapController.groovy + - plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RegisterController.groovy + - plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclEntryController.groovy + - plugin-ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/SecurityInfoController.groovy + - name: examples_and_test_apps + in_model: false + out_of_model_reason: "Not shipped in plugin distributions (§3)." + representative_files: + - plugin-acl/examples/ + - plugin-cas/examples/ + - plugin-core/examples/ + - plugin-ldap/examples/ + - plugin-rest/spring-security-rest-testapp-profile/ + - plugin-ui/examples/ + +# §5a - configuration knobs that change the security envelope. +config_knobs: + - name: password.algorithm + default: bcrypt + security_relevant: true + if_changed_to_noop_or_message_digest: OUT-OF-MODEL_non_default_build + section: "§5a, §8 P1" + - name: password.bcrypt.logrounds + default: 10 + security_relevant: true + maintainer_stance: "Resolved per §14 Q6: 10 is the supported production default; matches Spring Security upstream BCryptPasswordEncoder. Test environment uses 4 (DefaultSecurityConfig.groovy:193-207). §10 #1 recommends >=12 for hardening above the default." + section: "§5a, §10 #1" + - name: useSessionFixationPrevention + default: true + security_relevant: true + if_disabled: OUT-OF-MODEL_non_default_build + section: "§5a, §8 P2" + - name: rejectIfNoRule + default: true + security_relevant: true + if_disabled: OUT-OF-MODEL_non_default_build + section: "§5a, §8 P3" + - name: fii.rejectPublicInvocations + default: true + security_relevant: true + notes: "Both rejectIfNoRule:false AND fii.rejectPublicInvocations:false silently passes uncovered URLs." + section: "§5a" + - name: excludeSpringSecurityAutoConfiguration + default: true + security_relevant: true + if_disabled: OUT-OF-MODEL_non_default_build + notes: "Documented as a footgun in README.md." + section: "§5a, §11" + - name: componentBased.bridgeSpringSecurityUserProperties + default: true + security_relevant: true + maintainer_stance: "Resolved per §14 Q7: intentional; {noop} prefix mirrors Spring Boot's UserDetailsServiceAutoConfiguration. Default hard-coded true in SpringSecurityCoreGrailsPlugin.groovy:781. §11 misuse pattern (leftover dev credential in production) stands." + notes: "Leftover spring.security.user.* config creates {noop}-prefixed credential in production." + section: "§5a, §9 false-friend" + - name: rememberMe.persistent + default: false + security_relevant: true + notes: "When false, MD5-HMAC TokenBasedRememberMeServices is used." + section: "§5a" + - name: cacheUsers + default: false + security_relevant: true + notes: "Account lock/disable changes bypassed until cache eviction; documented." + section: "§5a" + - name: secureChannel.useHeaderCheckChannelSecurity + default: false + security_relevant: true + maintainer_stance: "Resolved per §14 Q9: asymmetry confirmed. SecurityRequestHolderFilter:83-113 makes the channel DECISION proxy-aware via X-Forwarded-Proto; PortResolverImpl:31-33 returns raw request.serverPort so redirect URL CONSTRUCTION is not proxy-aware. Operators must rely on the proxy to rewrite the Location header when a redirect fires." + notes: "Even when true, PortResolverImpl does not consult X-Forwarded-Port." + section: "§5a, §9" + - name: ipRestrictions + default: empty + security_relevant: true + maintainer_stance: "Resolved per §14 Q8: BY-DESIGN. IpAddressFilter.groovy:109,130 uses request.remoteAddr only; reports of proxy bypass are BY-DESIGN: property-disclaimed." + notes: "IpAddressFilter uses request.remoteAddr only; no X-Forwarded-For." + section: "§5a, §9" + - name: useSecurityEventListener + default: false + security_relevant: true + notes: "Groovy Closure execution from application.groovy." + section: "§5a" + - name: useRunAs + default: false + security_relevant: true + maintainer_stance: "Resolved per §14 Q15: KNOWN-NON-FINDING. Compat shim's RunAsImplAuthenticationProvider is MORE permissive than upstream Spring Security 5.x - the key field is declared in both RunAsManagerImpl and RunAsImplAuthenticationProvider but never read (upstream performs a keyHash equality check; the shim does not). Default useRunAs:false keeps this opt-in. Follow-up F8 considers porting the upstream hashCode guard." + notes: "RunAsImplAuthenticationProvider.authenticate() is pass-through; supports() returns true unconditionally." + section: "§5a, §9 false-friend, §11a" + - name: ldap.context.server + default: ldap://localhost:389 + security_relevant: true + notes: "Plaintext by default; must change to ldaps:// in production." + section: "§5a, §9" + - name: ldap.authenticator.useBind + default: true + security_relevant: true + section: "§5a, §8 P8" + - name: ldap.context.referral + default: null + security_relevant: true + notes: "'follow' chases referrals to arbitrary servers." + section: "§5a, §11" + - name: cas.useSingleSignout + default: true + security_relevant: true + maintainer_stance: "Resolved per §14 Q4: BY-DESIGN. DefaultCasSecurityConfig.groovy:33 default is true; SpringSecurityCasGrailsPlugin.groovy:85-89 forces useSessionFixationPrevention=false with explicit source comment. Reports against this trade-off are BY-DESIGN: property-disclaimed. Follow-up F2 adds the doc note to plugin-cas configuration.adoc." + notes: "Forces useSessionFixationPrevention = false globally." + section: "§5a, §9 false-friend, §11" + - name: cas.key + default: grails-spring-security-cas + security_relevant: true + notes: "Default value is shipped in source; MUST change for production." + section: "§5a, §10 #3" + - name: cas.proxyCallbackUrl + default: null + security_relevant: true + notes: "When set, exposes PGT receptor endpoint." + section: "§5a" + - name: oauth2.providers.{name}.debug + default: false + security_relevant: true + notes: "When true, raw OAuth traffic including tokens is logged." + section: "§5a, §11" + - name: oauth2.registration.roleNames + default: ["ROLE_USER"] + security_relevant: true + notes: "Granted to every OAuth2-authenticated user regardless of provider claims." + section: "§5a, §10 #10" + - name: oauth2_state_generation + default: "java.util.Random over 999_999 space" + security_relevant: true + maintainer_stance: "Resolved per §14 Q10: VALID-HARDENING. OAuth2AbstractProviderService.groovy:103 confirms `new Random().nextInt(999_999)`; zero SecureRandom usage in plugin-oauth2 tree. Follow-up F4 migrates to SecureRandom." + notes: "Not cryptographically secure; state-CSRF feasible." + section: "§5a, §9" + - name: oauth2_pkce + default: not_implemented + security_relevant: true + maintainer_stance: "Resolved per §14 Q11: VALID-HARDENING. OAuth2AbstractProviderService.buildScribeService (lines 115-128) does not invoke ServiceBuilder.withPkce(). Zero matches for withPkce/pkce/code_challenge/code_verifier in the plugin-oauth2 tree. Follow-up F5 adds PKCE." + section: "§5a, §9" + - name: rest.token.storage.jwt.secret + default: null + security_relevant: true + maintainer_stance: "Resolved per §14 Q3: reachable misconfiguration when operator sets useSignedJwt:false (default is true) AND useEncryptedJwt:false (default) AND secret:null AND no key provider. JwtService.groovy:71-76 confirms PlainJWT acceptance in this state. Follow-up F1 adds startup-time rejection." + notes: "Null + null key provider + useSignedJwt:false + useEncryptedJwt:false enables PlainJWT (alg=none) acceptance." + section: "§5a, §8 P10, §9, §11" + - name: rest.token.storage.jwt.algorithm + default: HS256 + security_relevant: true + maintainer_stance: "Resolved per §14 Q14: VALID-HARDENING. JwtService.parse() (lines 53-78) dispatches on Nimbus JWT Java type, never compares alg header to configured algorithm. Follow-up F7 adds algorithm allow-list pinning." + notes: "No algorithm allow-list; any Nimbus-supported algorithm accepted." + section: "§5a, §9" + - name: rest.token.storage.jwt.refreshExpiration + default: null + security_relevant: true + maintainer_stance: "Resolved per §14 Q12: BY-DESIGN; refresh token reused verbatim. RestOauthController.groovy:173-181 reassigns supplied refreshToken back onto the new AccessToken; none of the four storage backends (JWT/GORM/Redis/Memcached) invalidate the presented token on use. Operators implement RFC 6749 §10.4 rotation at the application layer. Follow-up F6 adds doc warning." + notes: "Refresh tokens never expire by default." + section: "§5a, §9, §10 #5" + - name: rest.token.validation.active + default: true + security_relevant: true + notes: "When false, validation filter is no-op." + section: "§5a" + - name: rest.login.useRequestParamsCredentials + default: false + security_relevant: true + notes: "When true, credentials appear in URLs and logs." + section: "§5a, §10 #17" + - name: security.ui.encodePassword + default: false + security_relevant: true + maintainer_stance: "Resolved per §14 Q5: keep false as default. DefaultUiSecurityConfig.groovy:23 sets default false; SpringSecurityUiService.groovy:810 confirms fallback. Flipping to true would double-hash passwords in s2-quickstart-generated applications (Person template has PasswordEncoderListener). Follow-up F3 adds startup WARN when encodePassword:false and no listener detected." + notes: "When false, UI service stores submitted passwords without encoding. Operators using older Person template (no listener) MUST set true for production." + section: "§5a, §10 #8, §11" + - name: security.ui.register.requireEmailValidation + default: "true" + security_relevant: true + notes: "When false, accounts activated immediately." + section: "§5a" + - name: security.ui.register.defaultRoleNames + default: ["ROLE_USER"] + security_relevant: true + notes: "Roles granted on self-registration." + section: "§5a, §10 #10" + +# §6 - per-input trust. +entry_points: + - surface: form_login + parameter: j_username_j_password + attacker_controllable: true + - surface: basic_or_digest_auth + parameter: Authorization_header + attacker_controllable: true + - surface: remember_me_cookie + parameter: cookie_value + attacker_controllable: true + - surface: rest_login + parameter: json_body + attacker_controllable: true + - surface: bearer_token + parameter: Authorization_Bearer + attacker_controllable: true + - surface: oauth2_callback + parameter: code_state_error + attacker_controllable: true + - surface: cas_callback + parameter: ticket + attacker_controllable: true + - surface: ui_user_crud + parameter: form_fields_and_ROLE_checkboxes + attacker_controllable: true + notes: "Mass-assignment via params; role checkbox names drive role grants." + - surface: ui_role_crud + parameter: authority_string + attacker_controllable: true + - surface: ui_requestmap_crud + parameter: url_configAttribute_httpMethod + attacker_controllable: true + notes: "Writes the authorization policy." + - surface: ui_acl_crud + parameter: mask_granting_sid_aceOrder + attacker_controllable: true + - surface: ui_register + parameter: RegisterCommand + attacker_controllable: true + - surface: ui_forgot_password + parameter: username + attacker_controllable: true + notes: "Account-enumeration via response differentiation." + - surface: ui_reset_password + parameter: t_token_and_new_password + attacker_controllable: true + notes: "No token expiry." + - surface: requestmap_table + parameter: url_configAttribute + attacker_controllable: false + trusted_role: developer + - surface: acl_class_table + parameter: className + attacker_controllable: false + trusted_role: developer + notes: "Loaded via Class.forName in GormAclLookupStrategy." + - surface: ldap_manager_credentials + parameter: managerDn_managerPassword + attacker_controllable: false + trusted_role: operator + - surface: cas_server_response + parameter: xml_body + attacker_controllable: false_if_TLS + trusted_role: operator + - surface: oauth2_provider_response + parameter: json_body + attacker_controllable: false_if_TLS + trusted_role: operator + - surface: application_groovy_closures + parameter: closure_body + attacker_controllable: false + trusted_role: developer + +# §7 - adversary model summary. +adversaries: + in_scope: + - id: unauthenticated_http_end_user + - id: authenticated_low_privilege_user + notes: "Vertical/horizontal escalation via mass-assignment, role creation, requestmap writes, ACL grants." + - id: compromised_external_idp + notes: "LDAP, CAS, OAuth2 - returns crafted authn responses." + out_of_scope: + - id: local_shell_attacker + - id: classpath_compromise + - id: malicious_grails_plugin + - id: build_environment_compromise + - id: jvm_co_tenant + - id: malicious_profile_via_MavenProfileRepository + - id: tls_mitm_on_operator_trusted_channels + - id: side_channel_observer + +# §8 - claimed security properties. +properties_provided: + - id: P1 + description: "Passwords stored as bcrypt hashes by default via DelegatingPasswordEncoder." + cwe: CWE-256 + plugin: core + severity: security_critical + provenance: documented + source: plugin-core/docs/src/docs/passwords/hashing.adoc + - id: P2 + description: "Session fixation prevented by default." + cwe: CWE-384 + plugin: core + severity: security_critical + provenance: documented + source: plugin-core/docs/src/docs/sessionFixation.adoc + - id: P3 + description: "Pessimistic URL coverage: rejectIfNoRule denies uncovered URLs by default." + cwe: CWE-862 + plugin: core + severity: security_critical + provenance: documented + source: plugin-core/docs/src/docs/requestMappings.adoc + - id: P4 + description: "FilterSecurityInterceptor enforces @Secured/Requestmap rules at HTTP boundary." + cwe: CWE-285 + plugin: core+compat + severity: security_critical + provenance: documented + - id: P5 + description: "AclPermissionEvaluator gates object-level permissions for @PreAuthorize(hasPermission)." + cwe: CWE-285 + plugin: acl + severity: security_critical + provenance: inferred + - id: P6 + description: "Persistent remember-me detects token theft via series/token rotation." + cwe: CWE-294 + plugin: core + severity: security_critical + provenance: inferred + - id: P7 + description: "Account-status accessors gate authentication." + cwe: CWE-287 + plugin: core + severity: security_critical + provenance: documented + source: plugin-core/docs/src/docs/passwords/locking.adoc + - id: P8 + description: "LDAP bind authentication forwards credentials to LDAP server." + cwe: CWE-522 + plugin: ldap + severity: security_critical + provenance: documented + - id: P9 + description: "CAS ticket validation contacts CAS server over HTTPS." + cwe: CWE-294 + plugin: cas + severity: security_critical + provenance: documented + - id: P10 + description: "JWT signature verified before claims trusted (HMAC for signed, RSA for encrypted). Defeated when useSignedJwt:false AND useEncryptedJwt:false AND secret:null AND no keyProvider - PlainJWT (alg=none) is accepted per JwtService.groovy:71-76. The operator must explicitly disable useSignedJwt (default true) to reach this state." + cwe: CWE-347 + plugin: rest + severity: security_critical + provenance: maintainer + maintainer_stance: "Resolved per §14 Q3; follow-up F1 adds startup-time rejection of the all-null configuration." + - id: P11 + description: "REST validation filter checks JWT exp claim." + cwe: CWE-613 + plugin: rest + severity: security_critical + provenance: inferred + - id: P12 + description: "Channel security redirects HTTP to HTTPS when REQUIRES_SECURE_CHANNEL configured." + cwe: CWE-319 + plugin: core+compat + severity: security_critical + provenance: documented + source: plugin-core/docs/src/docs/channelSecurity.adoc + - id: P13 + description: "withForm blocks naive CSRF on UI forms opting in via ." + cwe: CWE-352 + plugin: ui + severity: security_critical + provenance: inferred + - id: P14 + description: "Password comparison delegates to PasswordEncoder.matches() (constant-time for bcrypt/pbkdf2/argon2/scrypt)." + cwe: CWE-208 + plugin: core + severity: hardening + provenance: inferred + - id: P15 + description: "Username enumeration via authentication-exception type suppressed by default." + cwe: CWE-204 + plugin: core + severity: security_critical + provenance: inferred + +# §9 - properties the plugins do NOT provide. +properties_disclaimed: + - id: csrf_on_rest_endpoints + - id: csrf_on_forms_without_useToken + - id: anti_bot_rate_limiting + - id: reset_token_expiry + - id: account_enumeration_resistance_on_forgot_password + - id: refresh_token_rotation + - id: stateless_jwt_revocation + - id: jwt_claim_validation_beyond_exp + - id: jwt_algorithm_allowlist + - id: alg_none_rejection_when_both_secret_and_keyprovider_null + - id: oauth2_pkce + - id: oauth2_secure_state_parameter + - id: x_forwarded_for_in_ip_address_filter + - id: x_forwarded_port_in_port_resolver + - id: cas_certificate_pinning + - id: ldap_starttls + - id: ldap_tls_default + - id: session_fixation_when_cas_slo_enabled + - id: mass_assignment_protection_in_ui_bindings + - id: default_authorization_on_ui_endpoints + - id: password_encoding_when_security_ui_encodePassword_false + - id: redis_memcached_deserialization_safety_when_backend_reachable + - id: cross_tenant_isolation + - id: transport_security + +# False-friend properties. +false_friends: + - id: secured_annotation_enforcement + looks_like: "Method-level access control." + actually_is: "Populates FilterSecurityInterceptor metadata; inert without the interceptor in the filter chain." + - id: bindable_false_in_ui_domains + looks_like: "Field-level access control." + actually_is: "Plugin ships no bindable:false constraints; application must declare them." + - id: useToken_on_s2ui_form + looks_like: "CSRF protection on all UI forms." + actually_is: "Only effective when action wraps body in withForm; register.gsp and resetPassword.gsp do not." + - id: hideUserNotFoundExceptions + looks_like: "Account-enumeration protection across the board." + actually_is: "Only affects authentication exception type; /register/forgotPassword still enumerates." + - id: cas_useSingleSignout + looks_like: "Security feature." + actually_is: "Security feature whose implementation disables session-fixation prevention globally." + - id: excludeSpringSecurityAutoConfiguration_false + looks_like: "Graceful fallback to Spring Boot defaults." + actually_is: "Both stacks register with undefined precedence; documented as a footgun." + - id: encrypted_jwt_with_default_rsa_key_provider + looks_like: "Production-ready JWE." + actually_is: "Ephemeral keys per JVM start; tokens unusable across restarts and pods." + - id: rest_token_storage_jwt_algorithm_HS256 + looks_like: "Algorithm pin." + actually_is: "Default for signing only; validation path branches on JWT type, not on this property." + - id: ip_restrictions_behind_proxy + looks_like: "Network-layer protection." + actually_is: "Uses request.remoteAddr only; behind a proxy sees only the proxy address." + - id: secureChannel_useHeaderCheckChannelSecurity + looks_like: "Proxy-aware channel decision." + actually_is: "Channel decision becomes proxy-aware, but PortResolverImpl still uses request.serverPort." + - id: run_as_in_compat_shim + looks_like: "HMAC-protected privilege substitution." + actually_is: "Substituted token unsigned; key validated only on return path." + - id: groovyAwareAclVoter + looks_like: "ACL voter." + actually_is: "Unconditionally grants access for Groovy meta-methods; intentional." + - id: affirmativeBased_first_grant_wins + looks_like: "Permissive-by-default authorization." + actually_is: "Documented Spring Security 5.x semantics; one permissive voter overrides all denials." + - id: spring_security_user_name_in_config + looks_like: "Spring Boot convenience property." + actually_is: "Creates {noop}-prefixed credential in production when componentBased.bridgeSpringSecurityUserProperties:true." + - id: security_ui_encodePassword_false + looks_like: "Behavior toggle." + actually_is: "Default value stores cleartext passwords from UI flows." + - id: AntPathRequestMatcher_no_normalization + looks_like: "URL pattern matching." + actually_is: "No `../` or URL-encoded normalization; relies on servlet container." + +# §11a - recurring false positives that automated triage should suppress. +known_non_findings: + - pattern: "Class.forName(name) in GormAclLookupStrategy line 298 driven by AclClass.className" + discharged_by: "§6 trust assumption on ACL tables" + disposition: "OUT-OF-MODEL: trusted-input" + - pattern: "Class.forName(clientClass) in RestOauthService line 62 / grailsApplication.getClassForName(tokenClassName) in GormTokenStorageService" + discharged_by: "§6 trust assumption on application.yml" + disposition: "OUT-OF-MODEL: trusted-input" + - pattern: "GroovyClassLoader.loadClass(className) in SpringSecurityUtils.mergeConfig" + discharged_by: "§6 trust assumption on classpath config classes" + disposition: "OUT-OF-MODEL: trusted-input" + - pattern: "Groovy Closure execution in SecurityEventListener / ClosureVoter" + discharged_by: "§6 trust assumption on application.groovy" + disposition: "OUT-OF-MODEL: trusted-input" + related: "BY-DESIGN: property-disclaimed if assumption violated" + - pattern: "Dynamic property access user.\"$propertyName\" in GormUserDetailsService" + discharged_by: "§6 trust assumption on securityConfig" + disposition: "OUT-OF-MODEL: trusted-input" + - pattern: "@PreAuthorize/@PostFilter SpEL evaluation with StandardEvaluationContext" + discharged_by: "§6 trust assumption on annotation source" + disposition: "OUT-OF-MODEL: trusted-input" + - pattern: "AffirmativeBased first-grant-wins decision semantics" + discharged_by: "§9 false-friend" + disposition: "KNOWN-NON-FINDING" + - pattern: "GroovyAwareAclVoter unconditional grant for Groovy meta-methods" + discharged_by: "§9 false-friend" + disposition: "KNOWN-NON-FINDING" + - pattern: "AntPathRequestMatcher does not normalize ../ or URL-encoded segments" + discharged_by: "§9 false-friend; container responsibility" + disposition: "OUT-OF-MODEL: unsupported-component" + - pattern: "Java deserialization in JwtService.deserialize" + discharged_by: "§8 P10 signature verification; in-model only if alg=none reachable" + disposition: "KNOWN-NON-FINDING" + - pattern: "Java deserialization in RedisTokenStorageService / CustomSerializingTranscoder" + discharged_by: "§5 environment assumption (network-isolated backend)" + disposition: "OUT-OF-MODEL: trusted-input" + - pattern: "Serializable plugin domain classes (PersistentLogin, RegistrationCode, User, Person, Role)" + discharged_by: "§6 trust assumption on session store" + disposition: "OUT-OF-MODEL: trusted-input" + - pattern: "@Secured present but no in-method authz logic" + discharged_by: "§4 (enforced by FilterSecurityInterceptor, not inline)" + disposition: "KNOWN-NON-FINDING" + - pattern: "MutableRoleHierarchy.setHierarchy(String) mutates at runtime" + discharged_by: "§6 trust assumption on RoleHierarchyEntry table" + disposition: "OUT-OF-MODEL: trusted-input" + - pattern: "InsecureChannelProcessor actively redirects HTTPS to HTTP" + discharged_by: "Operator-opted-in via REQUIRES_INSECURE_CHANNEL" + disposition: "KNOWN-NON-FINDING" + - pattern: "Cleartext Memcached/Redis protocol traffic" + discharged_by: "§5 environment assumption (network-isolated backend)" + disposition: "OUT-OF-MODEL: trusted-input" + - pattern: "MutableLogoutFilter allows post-logout redirect via targetUrlParameter without origin validation" + discharged_by: "Default targetUrlParameter is null" + disposition: "OUT-OF-MODEL: non-default-build" + +# §13 - closed set of triage dispositions. +dispositions: + - label: VALID + licensed_by: ["§8", "§6", "§7"] + - label: VALID-HARDENING + licensed_by: ["§11"] + - label: "OUT-OF-MODEL: trusted-input" + licensed_by: ["§6"] + - label: "OUT-OF-MODEL: adversary-not-in-scope" + licensed_by: ["§7"] + - label: "OUT-OF-MODEL: unsupported-component" + licensed_by: ["§3"] + - label: "OUT-OF-MODEL: non-default-build" + licensed_by: ["§5a"] + - label: "BY-DESIGN: property-disclaimed" + licensed_by: ["§9"] + - label: KNOWN-NON-FINDING + licensed_by: ["§11a"] + - label: MODEL-GAP + licensed_by: ["§12"] + triggers_revision: true + +# Provenance counts (approximate; track THREAT_MODEL.md §1 draft confidence). +provenance_counts: + documented: 22 + maintainer: 18 + inferred: 57 + notes: "PR #1224 ratification log resolved 22 §14 open questions. 18 inline tag promotions in this PR; the remaining inferred claims are general environment assumptions, deferred wave-2 config knobs, and framework-level negative claims that are not tied to the 22 explicit questions. The §14 ratification log carries the canonical resolution record."