fix: security hardening — remove dead routes, add JWT audience validation
- Remove broken observe/dashboard Traefik routes (server accessed via /server only) - Remove unused acme volume - Add JWT audience claim validation (https://api.cameleer.local) in SecurityConfig - Secure bootstrap output file with chmod 600 - Add dev-only comments on TLS_SKIP_VERIFY and credential logging Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ Separate Vite+React SPA replacing Logto's default sign-in page. Visually matches
|
|||||||
- 13 OAuth2 scopes on the Logto API resource (`https://api.cameleer.local`): 10 platform scopes + 3 server scopes (`server:admin`, `server:operator`, `server:viewer`), served to the frontend from `GET /platform/api/config`
|
- 13 OAuth2 scopes on the Logto API resource (`https://api.cameleer.local`): 10 platform scopes + 3 server scopes (`server:admin`, `server:operator`, `server:viewer`), served to the frontend from `GET /platform/api/config`
|
||||||
- Server scopes map to server RBAC roles via JWT `scope` claim (server reads `rolesClaim: "scope"`)
|
- Server scopes map to server RBAC roles via JWT `scope` claim (server reads `rolesClaim: "scope"`)
|
||||||
- Org role `admin` gets `server:admin`, org role `member` gets `server:viewer`
|
- Org role `admin` gets `server:admin`, org role `member` gets `server:viewer`
|
||||||
- Custom `JwtDecoder` in `SecurityConfig.java` — ES384 algorithm, `at+jwt` token type, split issuer-uri (string validation) / jwk-set-uri (Docker-internal fetch)
|
- Custom `JwtDecoder` in `SecurityConfig.java` — ES384 algorithm, `at+jwt` token type, split issuer-uri (string validation) / jwk-set-uri (Docker-internal fetch), audience validation (`https://api.cameleer.local`)
|
||||||
|
|
||||||
### Server integration (cameleer3-server env vars)
|
### Server integration (cameleer3-server env vars)
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ Separate Vite+React SPA replacing Logto's default sign-in page. Visually matches
|
|||||||
|---------|-------|---------|
|
|---------|-------|---------|
|
||||||
| `CAMELEER_OIDC_ISSUER_URI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | Token issuer claim validation |
|
| `CAMELEER_OIDC_ISSUER_URI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | Token issuer claim validation |
|
||||||
| `CAMELEER_OIDC_JWK_SET_URI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK fetch |
|
| `CAMELEER_OIDC_JWK_SET_URI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK fetch |
|
||||||
| `CAMELEER_OIDC_TLS_SKIP_VERIFY` | `true` | Skip cert verify for OIDC discovery (dev) |
|
| `CAMELEER_OIDC_TLS_SKIP_VERIFY` | `true` | Skip cert verify for OIDC discovery (dev only — disable in production) |
|
||||||
| `CAMELEER_CORS_ALLOWED_ORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | Allow browser requests through Traefik |
|
| `CAMELEER_CORS_ALLOWED_ORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | Allow browser requests through Traefik |
|
||||||
| `BASE_PATH` (server-ui) | `/server` | React Router basename + `<base>` tag |
|
| `BASE_PATH` (server-ui) | `/server` | React Router basename + `<base>` tag |
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./traefik.yml:/etc/traefik/traefik.yml:ro
|
- ./traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
- ./docker/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
- ./docker/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
- acme:/etc/traefik/acme
|
|
||||||
- certs:/etc/traefik/certs:ro
|
- certs:/etc/traefik/certs:ro
|
||||||
networks:
|
networks:
|
||||||
- cameleer
|
- cameleer
|
||||||
@@ -169,7 +168,7 @@ services:
|
|||||||
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
||||||
CAMELEER_OIDC_ISSUER_URI: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}/oidc
|
CAMELEER_OIDC_ISSUER_URI: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}/oidc
|
||||||
CAMELEER_OIDC_JWK_SET_URI: ${LOGTO_ENDPOINT:-http://logto:3001}/oidc/jwks
|
CAMELEER_OIDC_JWK_SET_URI: ${LOGTO_ENDPOINT:-http://logto:3001}/oidc/jwks
|
||||||
CAMELEER_OIDC_TLS_SKIP_VERIFY: "true"
|
CAMELEER_OIDC_TLS_SKIP_VERIFY: "true" # dev only — disable in production with real certs
|
||||||
CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
|
CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
|
||||||
CAMELEER_CORS_ALLOWED_ORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
CAMELEER_CORS_ALLOWED_ORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -179,15 +178,7 @@ services:
|
|||||||
retries: 30
|
retries: 30
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=false
|
||||||
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
|
|
||||||
- traefik.http.routers.observe.service=observe
|
|
||||||
- traefik.http.services.observe.loadbalancer.server.port=8080
|
|
||||||
- traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`)
|
|
||||||
- traefik.http.routers.dashboard.service=dashboard
|
|
||||||
- traefik.http.routers.dashboard.middlewares=dashboard-strip
|
|
||||||
- traefik.http.middlewares.dashboard-strip.stripprefix.prefixes=/dashboard
|
|
||||||
- traefik.http.services.dashboard.loadbalancer.server.port=8080
|
|
||||||
networks:
|
networks:
|
||||||
- cameleer
|
- cameleer
|
||||||
|
|
||||||
@@ -234,7 +225,6 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
chdata:
|
chdata:
|
||||||
acme:
|
|
||||||
certs:
|
certs:
|
||||||
jardata:
|
jardata:
|
||||||
bootstrapdata:
|
bootstrapdata:
|
||||||
|
|||||||
@@ -541,9 +541,11 @@ cat > "$BOOTSTRAP_FILE" <<EOF
|
|||||||
"oidcAudience": "$API_RESOURCE_INDICATOR"
|
"oidcAudience": "$API_RESOURCE_INDICATOR"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
chmod 600 "$BOOTSTRAP_FILE"
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "=== Bootstrap complete! ==="
|
log "=== Bootstrap complete! ==="
|
||||||
|
# dev only — remove credential logging in production
|
||||||
log " SaaS Owner: $SAAS_ADMIN_USER / $SAAS_ADMIN_PASS"
|
log " SaaS Owner: $SAAS_ADMIN_USER / $SAAS_ADMIN_PASS"
|
||||||
log " Tenant Admin: $TENANT_ADMIN_USER / $TENANT_ADMIN_PASS"
|
log " Tenant Admin: $TENANT_ADMIN_USER / $TENANT_ADMIN_PASS"
|
||||||
log " Tenant: $TENANT_NAME (slug: $TENANT_SLUG)"
|
log " Tenant: $TENANT_NAME (slug: $TENANT_SLUG)"
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
|
|||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
@@ -71,7 +76,8 @@ public class SecurityConfig {
|
|||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
public JwtDecoder jwtDecoder(
|
public JwtDecoder jwtDecoder(
|
||||||
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
|
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
|
||||||
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri) throws Exception {
|
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri,
|
||||||
|
@Value("${cameleer.identity.audience:}") String audience) throws Exception {
|
||||||
var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build();
|
var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build();
|
||||||
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
|
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
|
||||||
JWSAlgorithm.ES384, jwkSource);
|
JWSAlgorithm.ES384, jwkSource);
|
||||||
@@ -81,9 +87,15 @@ public class SecurityConfig {
|
|||||||
processor.setJWSTypeVerifier((type, context) -> { /* accept JWT and at+jwt */ });
|
processor.setJWSTypeVerifier((type, context) -> { /* accept JWT and at+jwt */ });
|
||||||
|
|
||||||
var decoder = new NimbusJwtDecoder(processor);
|
var decoder = new NimbusJwtDecoder(processor);
|
||||||
|
var validators = new ArrayList<OAuth2TokenValidator<Jwt>>();
|
||||||
|
validators.add(new JwtTimestampValidator());
|
||||||
if (issuerUri != null && !issuerUri.isEmpty()) {
|
if (issuerUri != null && !issuerUri.isEmpty()) {
|
||||||
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri));
|
validators.add(new JwtIssuerValidator(issuerUri));
|
||||||
}
|
}
|
||||||
|
if (audience != null && !audience.isEmpty()) {
|
||||||
|
validators.add(new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(audience)));
|
||||||
|
}
|
||||||
|
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
|
||||||
return decoder;
|
return decoder;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ cameleer:
|
|||||||
m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
|
m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
|
||||||
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}
|
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}
|
||||||
spa-client-id: ${LOGTO_SPA_CLIENT_ID:}
|
spa-client-id: ${LOGTO_SPA_CLIENT_ID:}
|
||||||
|
audience: ${CAMELEER_OIDC_AUDIENCE:https://api.cameleer.local}
|
||||||
runtime:
|
runtime:
|
||||||
max-jar-size: 209715200
|
max-jar-size: 209715200
|
||||||
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
|
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
|
||||||
|
|||||||
Reference in New Issue
Block a user