Skip to content

Commit ff3ccb0

Browse files
committed
fix: harden scene routing and chat auth
Fail closed when configured scenes are missing and ensure the chat auth filter covers the exact /api/chat endpoint.
1 parent 9f036b1 commit ff3ccb0

7 files changed

Lines changed: 72 additions & 20 deletions

File tree

application/src/main/java/io/github/huskyagent/application/channel/binding/ChannelSceneRouter.java

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public class ChannelSceneRouter {
2121

2222
public EffectiveChannelRoute resolve(InboundMessage inbound) {
2323
if (inbound != null && !isBlank(inbound.getSceneId()) && bindingResolver.allowsExplicitSceneOverride(inbound.getChannelIdentity())) {
24+
requireScene(inbound.getSceneId(), "explicit scene override");
2425
return new EffectiveChannelRoute(inbound.getSceneId(), null, EffectiveChannelRoute.Source.EXPLICIT);
2526
}
2627

@@ -30,32 +31,37 @@ public EffectiveChannelRoute resolve(InboundMessage inbound) {
3031
Optional<ChannelInstanceBinding> binding = configuredBinding.filter(ChannelInstanceBinding::enabled);
3132
if (binding.isPresent()) {
3233
ChannelInstanceBinding value = binding.get();
33-
if (sceneExists(value.sceneId())) {
34-
return new EffectiveChannelRoute(value.sceneId(), value.bindingId(), EffectiveChannelRoute.Source.BINDING);
35-
}
36-
log.warn("Ignoring channel binding with unknown scene: bindingId={}, sceneId={}", value.bindingId(), value.sceneId());
34+
requireScene(value.sceneId(), "channel binding " + value.bindingId());
35+
return new EffectiveChannelRoute(value.sceneId(), value.bindingId(), EffectiveChannelRoute.Source.BINDING);
3736
}
3837

3938
String legacyScene = legacyDefaultScene(inbound, configuredBinding.isEmpty());
4039
if (!isBlank(legacyScene)) {
40+
requireScene(legacyScene, "legacy channel default");
4141
return new EffectiveChannelRoute(legacyScene, null, EffectiveChannelRoute.Source.CHANNEL_LEGACY_DEFAULT);
4242
}
4343

4444
Optional<String> globalDefault = bindingResolver.defaultScene();
45-
if (globalDefault.isPresent() && sceneExists(globalDefault.get())) {
45+
if (globalDefault.isPresent()) {
46+
requireScene(globalDefault.get(), "channel binding global default");
4647
return new EffectiveChannelRoute(globalDefault.get(), null, EffectiveChannelRoute.Source.GLOBAL_DEFAULT);
4748
}
48-
globalDefault.ifPresent(sceneId -> log.warn("Ignoring channel binding global default with unknown scene: sceneId={}", sceneId));
4949

5050
return new EffectiveChannelRoute(sceneResolver.resolveDefault().getSceneId(), null, EffectiveChannelRoute.Source.SCENE_DEFAULT);
5151
}
5252

53-
private boolean sceneExists(String sceneId) {
53+
private void requireScene(String sceneId, String source) {
5454
if (isBlank(sceneId)) {
55-
return false;
55+
throw new IllegalArgumentException("Missing scene for " + source);
56+
}
57+
try {
58+
SceneConfig resolved = sceneResolver.resolve(sceneId);
59+
if (resolved == null || !sceneId.equals(resolved.getSceneId())) {
60+
throw new IllegalArgumentException("Unknown scene for " + source + ": " + sceneId);
61+
}
62+
} catch (IllegalArgumentException e) {
63+
throw new IllegalArgumentException("Unknown scene for " + source + ": " + sceneId, e);
5664
}
57-
SceneConfig resolved = sceneResolver.resolve(sceneId);
58-
return resolved != null && sceneId.equals(resolved.getSceneId());
5965
}
6066

6167
private String legacyDefaultScene(InboundMessage inbound, boolean noConfiguredBinding) {

application/src/main/java/io/github/huskyagent/application/scene/ConfigSceneResolver.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ public class ConfigSceneResolver implements SceneResolver {
2424
private final ConcurrentHashMap<String, SceneConfig> resolved = new ConcurrentHashMap<>();
2525

2626
public SceneConfig resolve(String sceneId) {
27-
String effectiveSceneId = sceneId != null && configs.containsKey(sceneId) ? sceneId : defaultScene;
27+
String effectiveSceneId = sceneId != null && !sceneId.isBlank() ? sceneId : defaultScene;
28+
if (effectiveSceneId == null || effectiveSceneId.isBlank() || !configs.containsKey(effectiveSceneId)) {
29+
throw new IllegalArgumentException("Unknown scene: " + effectiveSceneId);
30+
}
2831
return resolved.computeIfAbsent(effectiveSceneId, this::buildSceneConfig);
2932
}
3033

application/src/test/java/io/github/huskyagent/application/channel/binding/ChannelSceneRouterTest.java

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,13 @@ void globalDefaultAppliesBeforeSceneDefault() {
115115
}
116116

117117
@Test
118-
void unknownBindingSceneFallsBackToLegacyDefault() {
118+
void unknownBindingSceneFailsClosed() {
119119
ChannelSceneRouter router = router(binding("bad-binding", "missing-scene"), Optional.empty(), "scene-default");
120120
InboundMessage inbound = inbound(ChannelType.FEISHU, "cli_assistant", "feishu-qa");
121121

122-
EffectiveChannelRoute route = router.resolve(inbound);
122+
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> router.resolve(inbound));
123123

124-
assertEquals("feishu-qa", route.sceneId());
125-
assertEquals(EffectiveChannelRoute.Source.CHANNEL_LEGACY_DEFAULT, route.source());
124+
assertTrue(error.getMessage().contains("missing-scene"));
126125
}
127126

128127
private ChannelSceneRouter router(Optional<ChannelInstanceBinding> binding,
@@ -159,12 +158,11 @@ public boolean allowsExplicitSceneOverride(ChannelIdentity identity) {
159158
SceneResolver sceneResolver = new SceneResolver() {
160159
@Override
161160
public SceneConfig resolve(String sceneId) {
162-
SceneConfig config = new SceneConfig();
163161
if (sceneId == null || "missing-scene".equals(sceneId)) {
164-
config.setSceneId(sceneDefault);
165-
} else {
166-
config.setSceneId(sceneId);
162+
throw new IllegalArgumentException("Unknown scene: " + sceneId);
167163
}
164+
SceneConfig config = new SceneConfig();
165+
config.setSceneId(sceneId);
168166
return config;
169167
}
170168

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package io.github.huskyagent.application.scene;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.util.LinkedHashMap;
6+
7+
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
import static org.junit.jupiter.api.Assertions.assertThrows;
9+
10+
class ConfigSceneResolverTest {
11+
12+
@Test
13+
void unknownSceneFailsClosed() {
14+
ConfigSceneResolver resolver = new ConfigSceneResolver();
15+
LinkedHashMap<String, ConfigSceneResolver.SceneProperties> configs = new LinkedHashMap<>();
16+
configs.put("assistant", new ConfigSceneResolver.SceneProperties());
17+
resolver.setConfigs(configs);
18+
19+
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> resolver.resolve("missing"));
20+
21+
assertEquals("Unknown scene: missing", error.getMessage());
22+
}
23+
24+
@Test
25+
void blankSceneUsesDefaultScene() {
26+
ConfigSceneResolver resolver = new ConfigSceneResolver();
27+
LinkedHashMap<String, ConfigSceneResolver.SceneProperties> configs = new LinkedHashMap<>();
28+
configs.put("assistant", new ConfigSceneResolver.SceneProperties());
29+
resolver.setConfigs(configs);
30+
31+
assertEquals("assistant", resolver.resolve(" ").getSceneId());
32+
}
33+
}

service/src/main/java/io/github/huskyagent/service/auth/ApiKeyAuthFilter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ public static FilterRegistrationBean<ApiKeyAuthFilter> registrationBean(AuthConf
121121
OpenAiCompatibleProperties openAiProperties) {
122122
FilterRegistrationBean<ApiKeyAuthFilter> registration = new FilterRegistrationBean<>();
123123
registration.setFilter(new ApiKeyAuthFilter(authConfig, openAiProperties));
124-
registration.addUrlPatterns("/api/chat/*", "/v1/*");
124+
registration.addUrlPatterns("/api/chat", "/api/chat/*", "/v1/*");
125125
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
126126
registration.setName("apiKeyAuthFilter");
127127
return registration;

service/src/main/resources/application.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ chatbot:
286286
enabled: true
287287

288288
# Scene configuration
289+
# Scene rate-limit fields are parsed into RuntimePolicy but are not enforced yet.
289290
scenes:
290291
default-scene: assistant
291292
configs:

service/src/test/java/io/github/huskyagent/service/auth/ApiKeyAuthFilterTest.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ void openAiPathReturnsOpenAiErrorShapeForInvalidKey() throws Exception {
7777
assertTrue(response.getContentAsString().contains("authentication_error"));
7878
}
7979

80+
@Test
81+
void registrationCoversApiChatEndpointWithoutTrailingSlash() {
82+
AuthConfig authConfig = new AuthConfig();
83+
authConfig.setEnabled(true);
84+
OpenAiCompatibleProperties properties = new OpenAiCompatibleProperties();
85+
86+
var registration = ApiKeyAuthFilter.registrationBean(authConfig, properties);
87+
88+
assertTrue(registration.getUrlPatterns().contains("/api/chat"));
89+
}
90+
8091
private ApiKeyAuthFilter newFilter() {
8192
AuthConfig authConfig = new AuthConfig();
8293
authConfig.setEnabled(true);

0 commit comments

Comments
 (0)