Skip to content

Commit 18a6b86

Browse files
committed
Added ErrorResponse
Signed-off-by: munishchouhan <hrma017@gmail.com>
1 parent b1446d4 commit 18a6b86

3 files changed

Lines changed: 276 additions & 1 deletion

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Wave, containers provisioning service
3+
* Copyright (c) 2023-2024, Seqera Labs
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
package io.seqera.wave.api
20+
21+
import java.time.Instant
22+
23+
import groovy.transform.CompileStatic
24+
import groovy.transform.ToString
25+
import io.micronaut.core.annotation.Introspected
26+
import com.fasterxml.jackson.annotation.JsonInclude
27+
28+
/**
29+
* Sanitized error response model that prevents exposure of internal implementation details
30+
*
31+
* @author Munish Chouhan <munish.chouhan@seqera.io>
32+
*/
33+
@ToString(includeNames = true, includePackage = false)
34+
@CompileStatic
35+
@Introspected
36+
@JsonInclude(JsonInclude.Include.NON_NULL)
37+
class ErrorResponse {
38+
String timestamp
39+
Integer status
40+
String error
41+
String message
42+
String requestId
43+
String path
44+
45+
ErrorResponse(String message, String error, Integer status, String path, String requestId) {
46+
this.timestamp = Instant.now().toString()
47+
this.status = status
48+
this.error = error
49+
this.message = message
50+
this.requestId = requestId
51+
this.path = path
52+
}
53+
}

src/main/groovy/io/seqera/wave/controller/ErrorController.groovy

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,20 @@ import groovy.transform.CompileStatic
2222
import groovy.util.logging.Slf4j
2323
import io.micronaut.http.HttpRequest
2424
import io.micronaut.http.HttpResponse
25+
import io.micronaut.http.HttpStatus
2526
import io.micronaut.http.annotation.Controller
2627
import io.micronaut.http.annotation.Error
2728
import io.micronaut.http.hateoas.JsonError
2829
import io.micronaut.scheduling.TaskExecutors
2930
import io.micronaut.scheduling.annotation.ExecuteOn
3031
import io.seqera.wave.ErrorHandler
32+
import io.seqera.wave.api.ErrorResponse
33+
import io.seqera.random.LongRndKey
34+
import com.fasterxml.jackson.core.JsonProcessingException
35+
import com.fasterxml.jackson.databind.JsonMappingException
36+
import io.micronaut.json.JsonSyntaxException
37+
import io.micronaut.core.convert.exceptions.ConversionErrorException
38+
import jakarta.validation.ConstraintViolationException
3139
import jakarta.inject.Inject
3240
/**
3341
* Handle application errors
@@ -43,9 +51,108 @@ class ErrorController {
4351
@Inject
4452
private ErrorHandler handler
4553

54+
/**
55+
* Create a sanitized error response with logging
56+
*
57+
* @param request The HTTP request
58+
* @param exception The exception that occurred
59+
* @param errorType Description of error type for logging
60+
* @param message User-friendly error message
61+
* @param status HTTP status to return
62+
* @return Sanitized error response
63+
*/
64+
private static HttpResponse<ErrorResponse> createSanitizedErrorResponse(
65+
HttpRequest request,
66+
Throwable exception,
67+
String errorType,
68+
String message,
69+
HttpStatus status = HttpStatus.BAD_REQUEST) {
70+
71+
final errId = LongRndKey.rndHex()
72+
final path = request?.path ?: "unknown"
73+
74+
log.error("${errorType} - Request ID: ${errId} - Path: ${path}", exception)
75+
76+
return HttpResponse.status(status).body(
77+
new ErrorResponse(
78+
message,
79+
status.reason,
80+
status.code,
81+
path,
82+
errId
83+
)
84+
)
85+
}
86+
87+
/**
88+
* Handle JSON processing/parsing errors with sanitized response
89+
*/
90+
@Error(global = true, exception = JsonProcessingException.class)
91+
static HttpResponse<ErrorResponse> handleJsonError(HttpRequest request, JsonProcessingException e) {
92+
return createSanitizedErrorResponse(
93+
request,
94+
e,
95+
"JSON processing error",
96+
"Invalid request format. Please check your input and try again."
97+
)
98+
}
99+
100+
/**
101+
* Handle JSON mapping/deserialization errors with sanitized response
102+
*/
103+
@Error(global = true, exception = JsonMappingException.class)
104+
static HttpResponse<ErrorResponse> handleJsonMappingError(HttpRequest request, JsonMappingException e) {
105+
return createSanitizedErrorResponse(
106+
request,
107+
e,
108+
"JSON mapping error",
109+
"Invalid request format. Please check your input and try again."
110+
)
111+
}
112+
113+
/**
114+
* Handle JSON syntax errors (Micronaut-specific) with sanitized response
115+
*/
116+
@Error(global = true, exception = JsonSyntaxException.class)
117+
static HttpResponse<ErrorResponse> handleJsonSyntaxError(HttpRequest request, JsonSyntaxException e) {
118+
return createSanitizedErrorResponse(
119+
request,
120+
e,
121+
"JSON syntax error",
122+
"Invalid request format. Please check your input and try again."
123+
)
124+
}
125+
126+
/**
127+
* Handle argument conversion errors (includes enum deserialization) with sanitized response
128+
* This catches errors where request parameters can't be converted to expected types
129+
*/
130+
@Error(global = true, exception = ConversionErrorException.class)
131+
static HttpResponse<ErrorResponse> handleConversionError(HttpRequest request, ConversionErrorException e) {
132+
return createSanitizedErrorResponse(
133+
request,
134+
e,
135+
"Conversion error",
136+
"Invalid request format. Please check your input and try again."
137+
)
138+
}
139+
140+
/**
141+
* Handle bean validation errors with sanitized response
142+
*/
143+
@Error(global = true, exception = ConstraintViolationException.class)
144+
static HttpResponse<ErrorResponse> handleValidationError(HttpRequest request, ConstraintViolationException e) {
145+
return createSanitizedErrorResponse(
146+
request,
147+
e,
148+
"Validation error",
149+
"Invalid input parameters"
150+
)
151+
}
152+
46153
@Error(global = true)
47154
HttpResponse<JsonError> handleException(HttpRequest request, Throwable exception) {
48155
handler.handle(request, exception, (String message, String code)-> new JsonError(message))
49156
}
50-
157+
51158
}

src/test/groovy/io/seqera/wave/controller/ErrorHandlingTest.groovy

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,119 @@ class ErrorHandlingTest extends Specification {
5454
RegistryErrorResponse error = exception.response.getBody(RegistryErrorResponse).get()
5555
error.errors.get(0).message == "repository 'quay.io/hello-world:latest' not found"
5656
}
57+
58+
void 'should NEVER expose stack traces in error responses'() {
59+
given: 'various types of invalid requests'
60+
def requests = [
61+
HttpRequest.POST("/v1alpha2/container", '{"invalid json').contentType("application/json"),
62+
HttpRequest.POST("/v1alpha2/container", '[]').contentType("application/json"),
63+
HttpRequest.POST("/v1alpha2/container", '{"test": invalid}').contentType("application/json"),
64+
HttpRequest.POST("/v1alpha2/container", '{"packages":{"type":"INVALID"}}').contentType("application/json"),
65+
]
66+
67+
expect: 'no stack traces in any error response'
68+
requests.each { request ->
69+
try {
70+
client.toBlocking().exchange(request, String)
71+
assert false, "Request should have failed"
72+
} catch (HttpClientResponseException ex) {
73+
def body = ex.response.getBody(String).orElse("")
74+
75+
// CRITICAL: No stack traces
76+
assert !body.contains(' at io.'), "Stack trace with 'at io.' found"
77+
assert !body.contains(' at com.'), "Stack trace with 'at com.' found"
78+
assert !body.contains(' at java.'), "Stack trace with 'at java.' found"
79+
assert !body.contains('.java:'), "Stack trace with file:line found"
80+
assert !body.contains('.groovy:'), "Stack trace with groovy file found"
81+
assert !body.contains('Caused by:'), "Stack trace with 'Caused by' found"
82+
assert !body.contains('StackTrace'), "StackTrace term found"
83+
assert !body.contains('Exception in thread'), "Exception stack found"
84+
}
85+
}
86+
}
87+
88+
void 'should NOT expose internal package names'() {
89+
given: 'various types of invalid requests'
90+
def requests = [
91+
HttpRequest.POST("/v1alpha2/container", '{"bad"}').contentType("application/json"),
92+
HttpRequest.POST("/v1alpha2/container", '{"containerFile": 999}').contentType("application/json"),
93+
]
94+
95+
expect: 'no internal package names exposed'
96+
requests.each { request ->
97+
try {
98+
client.toBlocking().exchange(request, String)
99+
assert false, "Request should have failed"
100+
} catch (HttpClientResponseException ex) {
101+
def body = ex.response.getBody(String).orElse("")
102+
103+
// No internal package structures
104+
assert !body.contains('io.seqera.wave'), "Internal package io.seqera.wave exposed"
105+
assert !body.contains('com.fasterxml.jackson'), "Jackson package exposed"
106+
assert !body.contains('jakarta.validation'), "Jakarta validation package exposed"
107+
}
108+
}
109+
}
110+
111+
void 'should NOT expose enum values'() {
112+
given: 'request with invalid enum value'
113+
def request = HttpRequest.POST("/v1alpha2/container", '''
114+
{
115+
"containerImage": "ubuntu:latest",
116+
"packages": {
117+
"type": "INVALID_ENUM_VALUE",
118+
"entries": []
119+
}
120+
}
121+
''').contentType("application/json")
122+
123+
when: 'the request is sent'
124+
client.toBlocking().exchange(request, String)
125+
126+
then: 'exception is thrown'
127+
def ex = thrown(HttpClientResponseException)
128+
129+
and: 'enum values are not exposed'
130+
def body = ex.response.getBody(String).get()
131+
!body.contains('[CONDA, SPACK') // Enum value list
132+
!body.contains('BIOCONDA')
133+
!body.contains('PackageType') // Class name
134+
}
135+
136+
void 'should NOT expose Java exception class names'() {
137+
given: 'invalid JSON request'
138+
def request = HttpRequest.POST("/v1alpha2/container", '{"test"')
139+
.contentType("application/json")
140+
141+
when: 'the request is sent'
142+
client.toBlocking().exchange(request, String)
143+
144+
then: 'exception is thrown'
145+
def ex = thrown(HttpClientResponseException)
146+
147+
and: 'exception class names are not exposed'
148+
def body = ex.response.getBody(String).get()
149+
!body.contains('JsonProcessingException')
150+
!body.contains('JsonParseException')
151+
!body.contains('JsonMappingException')
152+
!body.contains('ConstraintViolationException')
153+
!body.contains('IllegalArgumentException')
154+
}
155+
156+
void 'error responses should include correlation ID'() {
157+
given: 'invalid JSON request'
158+
def request = HttpRequest.POST("/v1alpha2/container", '{"x"')
159+
.contentType("application/json")
160+
161+
when: 'the request is sent'
162+
client.toBlocking().exchange(request, String)
163+
164+
then: 'exception is thrown'
165+
def ex = thrown(HttpClientResponseException)
166+
167+
and: 'response contains a correlation ID (request ID or error ID)'
168+
def body = ex.response.getBody(String).get()
169+
body.contains('requestId') || body.contains('Error ID')
170+
body ==~ /.*[a-f0-9]{10,}.*/ // Contains hex ID for correlation
171+
}
57172
}

0 commit comments

Comments
 (0)