mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
* fix(skills): move version into metadata and normalize to semver 29 skills declared `version` at the top level of their frontmatter. The schema reads it from `metadata`, so tooling that follows the schema either misses it or has to special-case the top level. Three motion skills also declared `version: 1.0`, which is not a valid semantic version; normalized to `1.0.0`. No behavioral change — frontmatter metadata only. * fix(skills): state activation triggers in skill descriptions 148 skills described what they cover but never named the situation that should trigger them. Since the description is what Claude matches against to decide whether to load a skill, a description without a trigger makes activation guesswork — the skill is either missed or loaded at the wrong time. Added a "Use when ..." clause to each, derived from the skill's own body (most already stated the trigger under "## When to Use" or in the opening line; that intent is now reflected in the frontmatter where it is actually read from). Descriptions were only appended to; no existing wording was removed. * fix(skills): sync activation triggers into the Codex skill mirror 10 of the skills whose descriptions changed are also mirrored under `.agents/skills/`, where the description was previously a verbatim copy. Left alone, the two surfaces would disagree about when the skill applies. Only the description line is synced; the Codex copies keep their reduced frontmatter, since that validator accepts only name, description, metadata, license, and allowed-tools. * fix(skills): correct three activation clauses from review - autonomous-loops: the clause pulled new loop work into a skill that its own body marks as a compatibility shim retained for one release. It now points at the canonical continuous-agent-loop instead. - continuous-learning: the description carried the v1 routing directive twice; collapsed to one. - homelab-pihole-dns: the clause fired on any broken home DNS. Narrowed to tasks that actually involve Pi-hole. * chore: retain current main lockfile --------- Co-authored-by: Çağrı Solakoğlu <cagri.solakoglu@vtcenerji.com> Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
469 lines
12 KiB
Markdown
469 lines
12 KiB
Markdown
---
|
|
name: quarkus-security
|
|
description: Quarkus Security best practices for authentication, authorization, JWT/OIDC, RBAC, input validation, CSRF, secrets management, and dependency security. Use when reviewing Quarkus authn/authz, JWT or OIDC, RBAC, validation, or secrets.
|
|
metadata:
|
|
origin: ECC
|
|
---
|
|
|
|
# Quarkus Security Review
|
|
|
|
Best practices for securing Quarkus applications with authentication, authorization, and input validation.
|
|
|
|
## When to Activate
|
|
|
|
- Adding authentication (JWT, OIDC, Basic Auth)
|
|
- Implementing authorization with @RolesAllowed or SecurityIdentity
|
|
- Validating user input (Bean Validation, custom validators)
|
|
- Configuring CORS or security headers
|
|
- Managing secrets (Vault, environment variables, config sources)
|
|
- Adding rate limiting or brute-force protection
|
|
- Scanning dependencies for CVEs
|
|
- Working with MicroProfile JWT or SmallRye JWT
|
|
|
|
## Authentication
|
|
|
|
### JWT Authentication
|
|
|
|
```java
|
|
// Resource protected with JWT
|
|
@Path("/api/protected")
|
|
@Authenticated
|
|
public class ProtectedResource {
|
|
|
|
@Inject
|
|
JsonWebToken jwt;
|
|
|
|
@Inject
|
|
SecurityIdentity securityIdentity;
|
|
|
|
@GET
|
|
public Response getData() {
|
|
String username = jwt.getName();
|
|
Set<String> roles = jwt.getGroups();
|
|
return Response.ok(Map.of(
|
|
"username", username,
|
|
"roles", roles,
|
|
"principal", securityIdentity.getPrincipal().getName()
|
|
)).build();
|
|
}
|
|
}
|
|
```
|
|
|
|
Configuration (application.properties):
|
|
```properties
|
|
mp.jwt.verify.publickey.location=publicKey.pem
|
|
mp.jwt.verify.issuer=https://auth.example.com
|
|
|
|
# OIDC
|
|
quarkus.oidc.auth-server-url=https://auth.example.com/realms/myrealm
|
|
quarkus.oidc.client-id=backend-service
|
|
quarkus.oidc.credentials.secret=${OIDC_SECRET}
|
|
```
|
|
|
|
### Custom Authentication Filter
|
|
|
|
```java
|
|
@Provider
|
|
@Priority(Priorities.AUTHENTICATION)
|
|
public class CustomAuthFilter implements ContainerRequestFilter {
|
|
|
|
@Inject
|
|
SecurityIdentity identity;
|
|
|
|
@Override
|
|
public void filter(ContainerRequestContext requestContext) {
|
|
String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
|
|
|
|
// Reject immediately if header is absent or malformed
|
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
|
requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
|
|
return;
|
|
}
|
|
|
|
String token = authHeader.substring(7);
|
|
if (!validateToken(token)) {
|
|
requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
|
|
}
|
|
}
|
|
|
|
private boolean validateToken(String token) {
|
|
// Token validation logic
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Authorization
|
|
|
|
### Role-Based Access Control
|
|
|
|
```java
|
|
@Path("/api/admin")
|
|
@RolesAllowed("ADMIN")
|
|
public class AdminResource {
|
|
|
|
@GET
|
|
@Path("/users")
|
|
public List<UserDto> listUsers() {
|
|
return userService.findAll();
|
|
}
|
|
|
|
@DELETE
|
|
@Path("/users/{id}")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
|
|
public Response deleteUser(@PathParam("id") Long id) {
|
|
userService.delete(id);
|
|
return Response.noContent().build();
|
|
}
|
|
}
|
|
|
|
@Path("/api/users")
|
|
public class UserResource {
|
|
|
|
@Inject
|
|
SecurityIdentity securityIdentity;
|
|
|
|
@GET
|
|
@Path("/{id}")
|
|
@RolesAllowed("USER")
|
|
public Response getUser(@PathParam("id") Long id) {
|
|
// Check ownership
|
|
if (!securityIdentity.hasRole("ADMIN") &&
|
|
!isOwner(id, securityIdentity.getPrincipal().getName())) {
|
|
return Response.status(Response.Status.FORBIDDEN).build();
|
|
}
|
|
return Response.ok(userService.findById(id)).build();
|
|
}
|
|
|
|
private boolean isOwner(Long userId, String username) {
|
|
return userService.isOwner(userId, username);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Programmatic Security
|
|
|
|
```java
|
|
@ApplicationScoped
|
|
public class SecurityService {
|
|
|
|
@Inject
|
|
SecurityIdentity securityIdentity;
|
|
|
|
public boolean canAccessResource(Long resourceId) {
|
|
if (securityIdentity.isAnonymous()) {
|
|
return false;
|
|
}
|
|
|
|
if (securityIdentity.hasRole("ADMIN")) {
|
|
return true;
|
|
}
|
|
|
|
String userId = securityIdentity.getPrincipal().getName();
|
|
return resourceRepository.isOwner(resourceId, userId);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Input Validation
|
|
|
|
### Bean Validation
|
|
|
|
```java
|
|
// BAD: No validation
|
|
@POST
|
|
public Response createUser(UserDto dto) {
|
|
return Response.ok(userService.create(dto)).build();
|
|
}
|
|
|
|
// GOOD: Validated DTO
|
|
public record CreateUserDto(
|
|
@NotBlank @Size(max = 100) String name,
|
|
@NotBlank @Email String email,
|
|
@NotNull @Min(18) @Max(150) Integer age,
|
|
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String phone
|
|
) {}
|
|
|
|
@POST
|
|
@Path("/users")
|
|
public Response createUser(@Valid CreateUserDto dto) {
|
|
User user = userService.create(dto);
|
|
return Response.status(Response.Status.CREATED).entity(user).build();
|
|
}
|
|
```
|
|
|
|
### Custom Validators
|
|
|
|
```java
|
|
@Target({ElementType.FIELD, ElementType.PARAMETER})
|
|
@Retention(RetentionPolicy.RUNTIME)
|
|
@Constraint(validatedBy = UsernameValidator.class)
|
|
public @interface ValidUsername {
|
|
String message() default "Invalid username format";
|
|
Class<?>[] groups() default {};
|
|
Class<? extends Payload>[] payload() default {};
|
|
}
|
|
|
|
public class UsernameValidator implements ConstraintValidator<ValidUsername, String> {
|
|
@Override
|
|
public boolean isValid(String value, ConstraintValidatorContext context) {
|
|
if (value == null) return false;
|
|
return value.matches("^[a-zA-Z0-9_-]{3,20}$");
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
public record CreateUserDto(
|
|
@ValidUsername String username,
|
|
@NotBlank @Email String email
|
|
) {}
|
|
```
|
|
|
|
## SQL Injection Prevention
|
|
|
|
### Panache Active Record (Safe by Default)
|
|
|
|
```java
|
|
// GOOD: Parameterized queries with Panache
|
|
List<User> users = User.list("email = ?1 and active = ?2", email, true);
|
|
|
|
Optional<User> user = User.find("username", username).firstResultOptional();
|
|
|
|
// GOOD: Named parameters
|
|
List<User> users = User.list("email = :email and age > :minAge",
|
|
Parameters.with("email", email).and("minAge", 18));
|
|
```
|
|
|
|
### Native Queries (Use Parameters)
|
|
|
|
```java
|
|
// BAD: String concatenation
|
|
@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true)
|
|
|
|
// GOOD: Parameterized native query
|
|
@Entity
|
|
public class User extends PanacheEntity {
|
|
public static List<User> findByEmailNative(String email) {
|
|
return getEntityManager()
|
|
.createNativeQuery("SELECT * FROM users WHERE email = :email", User.class)
|
|
.setParameter("email", email)
|
|
.getResultList();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Password Hashing
|
|
|
|
```java
|
|
@ApplicationScoped
|
|
public class PasswordService {
|
|
|
|
public String hash(String plainPassword) {
|
|
return BcryptUtil.bcryptHash(plainPassword);
|
|
}
|
|
|
|
public boolean verify(String plainPassword, String hashedPassword) {
|
|
return BcryptUtil.matches(plainPassword, hashedPassword);
|
|
}
|
|
}
|
|
|
|
// In service
|
|
@ApplicationScoped
|
|
public class UserService {
|
|
@Inject
|
|
PasswordService passwordService;
|
|
|
|
@Transactional
|
|
public User register(CreateUserDto dto) {
|
|
String hashedPassword = passwordService.hash(dto.password());
|
|
User user = new User();
|
|
user.email = dto.email();
|
|
user.password = hashedPassword;
|
|
user.persist();
|
|
return user;
|
|
}
|
|
|
|
public boolean authenticate(String email, String password) {
|
|
return User.find("email", email)
|
|
.firstResultOptional()
|
|
.map(u -> passwordService.verify(password, u.password))
|
|
.orElse(false);
|
|
}
|
|
}
|
|
```
|
|
|
|
## CORS Configuration
|
|
|
|
```properties
|
|
# application.properties
|
|
quarkus.http.cors=true
|
|
quarkus.http.cors.origins=https://app.example.com,https://admin.example.com
|
|
quarkus.http.cors.methods=GET,POST,PUT,DELETE
|
|
quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with
|
|
quarkus.http.cors.exposed-headers=Content-Disposition
|
|
quarkus.http.cors.access-control-max-age=24H
|
|
quarkus.http.cors.access-control-allow-credentials=true
|
|
```
|
|
|
|
## Secrets Management
|
|
|
|
```properties
|
|
# application.properties - NO SECRETS HERE
|
|
|
|
# Use environment variables
|
|
quarkus.datasource.username=${DB_USER}
|
|
quarkus.datasource.password=${DB_PASSWORD}
|
|
quarkus.oidc.credentials.secret=${OIDC_CLIENT_SECRET}
|
|
|
|
# Or use Vault
|
|
quarkus.vault.url=https://vault.example.com
|
|
quarkus.vault.authentication.kubernetes.role=my-role
|
|
```
|
|
|
|
### HashiCorp Vault Integration
|
|
|
|
```java
|
|
@ApplicationScoped
|
|
public class SecretService {
|
|
|
|
@ConfigProperty(name = "api-key")
|
|
String apiKey; // Fetched from Vault
|
|
|
|
public String getSecret(String key) {
|
|
return ConfigProvider.getConfig().getValue(key, String.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Rate Limiting
|
|
|
|
**Security Note**: Never use `X-Forwarded-For` directly — clients can spoof it.
|
|
Use the actual remote address from the servlet request, or an authenticated
|
|
identity (API key, JWT subject) when available.
|
|
|
|
```java
|
|
@ApplicationScoped
|
|
public class RateLimitFilter implements ContainerRequestFilter {
|
|
private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();
|
|
|
|
@Inject
|
|
HttpServletRequest servletRequest;
|
|
|
|
@Override
|
|
public void filter(ContainerRequestContext requestContext) {
|
|
String clientId = getClientIdentifier();
|
|
RateLimiter limiter = limiters.computeIfAbsent(clientId,
|
|
k -> RateLimiter.create(100.0)); // 100 requests per second
|
|
|
|
if (!limiter.tryAcquire()) {
|
|
requestContext.abortWith(
|
|
Response.status(429)
|
|
.entity(Map.of("error", "Too many requests"))
|
|
.build()
|
|
);
|
|
}
|
|
}
|
|
|
|
private String getClientIdentifier() {
|
|
// Use the container-provided remote address (not X-Forwarded-For).
|
|
// If behind a trusted proxy, configure quarkus.http.proxy.proxy-address-forwarding=true
|
|
// so getRemoteAddr() returns the real client IP.
|
|
return servletRequest.getRemoteAddr();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Security Headers
|
|
|
|
```java
|
|
@Provider
|
|
public class SecurityHeadersFilter implements ContainerResponseFilter {
|
|
|
|
@Override
|
|
public void filter(ContainerRequestContext request, ContainerResponseContext response) {
|
|
MultivaluedMap<String, Object> headers = response.getHeaders();
|
|
|
|
// Prevent clickjacking
|
|
headers.putSingle("X-Frame-Options", "DENY");
|
|
|
|
// XSS protection
|
|
headers.putSingle("X-Content-Type-Options", "nosniff");
|
|
headers.putSingle("X-XSS-Protection", "1; mode=block");
|
|
|
|
// HSTS
|
|
headers.putSingle("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
|
|
// CSP — avoid 'unsafe-inline' for script-src as it negates XSS protection;
|
|
// use nonces or hashes instead. 'unsafe-inline' for style-src is acceptable
|
|
// when CSS frameworks require it, but prefer nonces where possible.
|
|
headers.putSingle("Content-Security-Policy",
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
|
|
}
|
|
}
|
|
```
|
|
|
|
## Audit Logging
|
|
|
|
```java
|
|
@ApplicationScoped
|
|
public class AuditService {
|
|
private static final Logger LOG = Logger.getLogger(AuditService.class);
|
|
|
|
@Inject
|
|
SecurityIdentity securityIdentity;
|
|
|
|
public void logAccess(String resource, String action) {
|
|
String user = securityIdentity.isAnonymous()
|
|
? "anonymous"
|
|
: securityIdentity.getPrincipal().getName();
|
|
|
|
LOG.infof("AUDIT: user=%s action=%s resource=%s timestamp=%s",
|
|
user, action, resource, Instant.now());
|
|
}
|
|
}
|
|
|
|
// Usage in resource
|
|
@Path("/api/sensitive")
|
|
public class SensitiveResource {
|
|
@Inject
|
|
AuditService auditService;
|
|
|
|
@GET
|
|
@RolesAllowed("ADMIN")
|
|
public Response getData() {
|
|
auditService.logAccess("sensitive-data", "READ");
|
|
return Response.ok(data).build();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Dependency Security Scanning
|
|
|
|
```bash
|
|
# Maven
|
|
mvn org.owasp:dependency-check-maven:check
|
|
|
|
# Gradle
|
|
./gradlew dependencyCheckAnalyze
|
|
|
|
# Check Quarkus extensions
|
|
quarkus extension list --installable
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
- Always use HTTPS in production
|
|
- Enable JWT or OIDC for stateless authentication
|
|
- Use `@RolesAllowed` for declarative authorization
|
|
- Validate all input with Bean Validation
|
|
- Hash passwords with BCrypt (never plaintext)
|
|
- Store secrets in Vault or environment variables
|
|
- Use parameterized queries to prevent SQL injection
|
|
- Add security headers to all responses
|
|
- Implement rate limiting for public endpoints
|
|
- Audit sensitive operations
|
|
- Keep dependencies updated and scan for CVEs
|
|
- Use SecurityIdentity for programmatic checks
|
|
- Set appropriate CORS policies
|
|
- Test authentication and authorization paths
|