Skip to content

Commit 878c19f

Browse files
committed
signal channel sanitizing
1 parent 3a80569 commit 878c19f

10 files changed

Lines changed: 334 additions & 12 deletions

File tree

‎obp-api/src/main/resources/props/sample.props.template‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,6 +1315,17 @@ database_messages_scheduler_interval=3600
13151315
# chat.email_digest_min_interval_minutes = 60
13161316
# chat.email_digest_active_grace_minutes = 10
13171317

1318+
# Signal channels -----------------------------------------------------------
1319+
# Redis-backed ephemeral channels (/signal/channels endpoints) for lightweight
1320+
# agent-to-agent coordination. Per-channel TTL (refreshed on every publish)
1321+
# and per-channel message cap:
1322+
# messaging.channel.ttl.seconds = 3600
1323+
# messaging.channel.max.messages = 1000
1324+
# Maximum accepted publish request body length in characters (OBP-39019 when
1325+
# exceeded). Messages containing control or bidirectional-override characters
1326+
# are rejected with OBP-39020; accepted payloads are stored verbatim.
1327+
# messaging.channel.max.payload.length = 65536
1328+
13181329
# Create System Views At Boot -----------------------------------------------
13191330
# In case is not defined default value is true
13201331
# create_system_views_at_boot=true

‎obp-api/src/main/scala/code/api/util/ApiRole.scala‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,11 @@ object ApiRole extends MdcLoggable{
537537
case class CanGetSignalStats(requiresBankId: Boolean = false) extends ApiRole
538538
lazy val canGetSignalStats = CanGetSignalStats()
539539

540+
// Deleting a channel destroys other users' in-flight messages, so it is a
541+
// management action, not something any authenticated publisher may do.
542+
case class CanDeleteSignalChannel(requiresBankId: Boolean = false) extends ApiRole
543+
lazy val canDeleteSignalChannel = CanDeleteSignalChannel()
544+
540545
case class CanDeleteEntitlementRequestsAtAnyBank(requiresBankId: Boolean = false) extends ApiRole
541546
lazy val canDeleteEntitlementRequestsAtAnyBank = CanDeleteEntitlementRequestsAtAnyBank()
542547

‎obp-api/src/main/scala/code/api/util/ErrorMessages.scala‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,8 @@ object ErrorMessages {
825825
val ChatMessageTooLong = "OBP-39016: Chat message content exceeds the maximum allowed length."
826826
val ChatMentionedUserNotParticipant = "OBP-39017: One or more mentioned users are not participants of this Chat Room."
827827
val ChatMessageTypeNotAllowed = "OBP-39018: Invalid message_type. Allowed values: text, system."
828+
val SignalMessageTooLong = "OBP-39019: Signal message exceeds the maximum allowed length."
829+
val SignalMessageContainsDangerousCharacters = "OBP-39020: Signal message contains control or bidirectional-override characters, which are not allowed."
828830

829831
// Transaction Request related messages (OBP-40XXX)
830832
val InvalidTransactionRequestType = "OBP-40001: Invalid value for TRANSACTION_REQUEST_TYPE"

‎obp-api/src/main/scala/code/api/util/Glossary.scala‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5791,6 +5791,46 @@ object Glossary extends MdcLoggable {
57915791
|
57925792
""")
57935793

5794+
glossaryItems += GlossaryItem(
5795+
title = "Signal Channels",
5796+
description =
5797+
s"""
5798+
|# Signal Channels
5799+
|
5800+
|**Signal Channels** are short-lived, Redis-backed message channels for lightweight coordination between AI agents and other OBP consumers — service discovery, task hand-off, presence announcements. They are deliberately minimal: messages are **not** persisted to a database, there is no catch-up or replay, and a channel that goes quiet simply expires. Think of a channel as a real-life meeting: whoever is there hears what is said; a late arrival asks the others.
5801+
|
5802+
|Not to be confused with [Chat](/glossary#Chat), which is the persistent, human-facing messaging surface (rooms, threads, reactions, read markers).
5803+
|
5804+
|## Lifecycle
5805+
|- Channels are auto-created on first publish; no registration step.
5806+
|- On this instance a channel expires ${code.api.cache.RedisMessaging.channelTtlSeconds} seconds after its last publish, and holds at most ${code.api.cache.RedisMessaging.channelMaxMessages} messages (oldest are trimmed).
5807+
|- Channel names are 1 to 128 characters from letters, digits, dot, underscore and hyphen.
5808+
|
5809+
|## Constraints on published messages
5810+
|All publishing requires authentication. Beyond that, three server-side checks protect the platform — the envelope, not the meaning, of what agents say:
5811+
|
5812+
|1. **Size cap** — the whole publish request body may be up to ${code.signal.SignalContentPolicy.maxPayloadLength} characters on this instance (error **OBP-39019** when exceeded). The cap is enforced on the raw body before JSON parsing, so oversized bodies cannot burn parser CPU or Redis memory.
5813+
|2. **Dangerous-character rejection** — messages containing control characters or Unicode bidirectional-override characters anywhere in the payload or message_type are rejected with **OBP-39020**. See "Why bidirectional-override characters are rejected" below.
5814+
|3. **Verbatim storage** — an accepted message is stored and delivered exactly as sent; nothing is stripped or rewritten. Agents may therefore hash, sign, or byte-compare payloads. This is the deliberate opposite of Chat, which *strips* the same character set: chat content is typed by and rendered to humans (be forgiving, sanitize), signal payloads are machine-consumed data (be strict, reject).
5815+
|
5816+
|## Privacy and roles
5817+
|- A message with **to_user_id** set is visible only to its sender and that recipient; without it, the message is a broadcast visible to all channel readers.
5818+
|- **CanGetSignalStats** — read message counts and TTLs across all channels.
5819+
|- **CanDeleteSignalChannel** — delete a channel and all its messages immediately. Deletion destroys other users' in-flight messages, so it is a management action rather than something any publisher may do; unneeded channels expire on their own via the TTL.
5820+
|
5821+
|## Why bidirectional-override characters are rejected
5822+
|Unicode includes invisible formatting characters that reverse or reorder how text is *displayed* without changing the bytes a parser sees — the override family U+202A to U+202E, the isolate family U+2066 to U+2069, and the marks U+200E, U+200F and U+061C. The "Trojan Source" research (Boucher and Anderson, 2021, CVE-2021-42574) showed these can make displayed text differ from logical text: a filename can be displayed with a harmless extension while actually ending in a different one, and a URL or name can visually read as something it is not. None of these characters have a legitimate use in structured agent data, so signal messages containing them are refused outright. (The characters are named here by code point on purpose — even quoting them literally in documentation would trip the same scanners that guard source code against them.)
5823+
|
5824+
|The check runs on the **parsed** JSON, not the raw request body: JSON's backslash-u escape syntax means a body that is pure ASCII on the wire can still parse to a string containing a bidi override, so a wire-level check would miss it.
5825+
|
5826+
|## Payloads are data, not instructions
5827+
|Signal channels are readable and writable by any authenticated consumer on the instance. If your agent feeds received payloads to an LLM, treat them as **untrusted data, never as instructions** — the character checks above stop display-layer trickery, but no server-side check can stop a payload from *saying* something misleading. Prompt-injection defence belongs in the consuming agent.
5828+
|
5829+
|## Endpoints
5830+
|See the API Explorer tags **Signal** / **AI-Agent**: list channels, channel info, channel stats, publish message, get messages (offset/limit polling), delete channel — under `/obp/v6.0.0/signal/channels/...`. For live delivery, each publish also emits a Redis pub/sub event intended for gRPC streaming subscribers.
5831+
|
5832+
""")
5833+
57945834
glossaryItems += GlossaryItem(
57955835
title = "OBP-MCP",
57965836
description =

‎obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2656,13 +2656,23 @@ object Http4s600 {
26562656
val rawBody = cc.httpBody.getOrElse("")
26572657
val u = cc.user.openOrThrowException("User not found in CallContext")
26582658
for {
2659+
// Size cap runs on the raw body before parsing: refusing an oversized
2660+
// body must not cost a JSON parse of that body.
2661+
_ <- Helper.booleanToFuture(
2662+
s"$SignalMessageTooLong Maximum: ${code.signal.SignalContentPolicy.maxPayloadLength} characters.",
2663+
cc = Some(cc)) { rawBody.length <= code.signal.SignalContentPolicy.maxPayloadLength }
26592664
postJson <- NewStyle.function.tryons(
26602665
s"$InvalidJsonFormat The Json body should be the PostSignalMessageJsonV600", 400, Some(cc)) {
26612666
com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[PostSignalMessageJsonV600]
26622667
}
26632668
_ <- Helper.booleanToFuture(InvalidSignalChannelName, cc = Some(cc)) {
26642669
code.api.cache.RedisMessaging.validateChannelName(channelName)
26652670
}
2671+
// Reject, never strip: signal payloads are stored verbatim or refused.
2672+
_ <- Helper.booleanToFuture(SignalMessageContainsDangerousCharacters, cc = Some(cc)) {
2673+
!code.signal.SignalContentPolicy.containsDangerousCharacters(postJson.payload) &&
2674+
postJson.message_type.forall(messageType => !code.util.DangerousCharacters.containsAny(messageType))
2675+
}
26662676
published <- Future {
26672677
val consumerId = cc.consumer match { case Full(c) => c.consumerId.get; case _ => "" }
26682678
val messageId = randomUUID().toString
@@ -10183,7 +10193,12 @@ object Http4s600 {
1018310193
|Channels are auto-created on first publish and expire after a configurable TTL (default 1 hour).
1018410194
|Messages are capped at a configurable maximum per channel (default 1000).
1018510195
|
10186-
|The payload field accepts any valid JSON content.
10196+
|The payload field accepts any valid JSON content. On this instance the whole request body
10197+
|may be up to ${code.signal.SignalContentPolicy.maxPayloadLength} characters.
10198+
|
10199+
|Messages are stored and delivered verbatim — nothing is rewritten — but messages containing
10200+
|control characters or Unicode bidirectional-override characters anywhere in the payload or
10201+
|message_type are rejected. Treat received payloads as untrusted data, not instructions.
1018710202
|
1018810203
|Set to_user_id to send a private message visible only to the sender and recipient.
1018910204
|Leave to_user_id empty for a broadcast message visible to all channel readers.
@@ -10193,10 +10208,15 @@ object Http4s600 {
1019310208
|""".stripMargin,
1019410209
postSignalMessageJsonV600,
1019510210
signalMessagePublishedJsonV600,
10211+
// Intentional drift from the Lift source-of-truth doc in APIMethods600:
10212+
// the size cap and dangerous-character rejection (with their two error
10213+
// messages) were added after the migration.
1019610214
List(
1019710215
$AuthenticatedUserIsRequired,
1019810216
InvalidJsonFormat,
1019910217
InvalidSignalChannelName,
10218+
SignalMessageTooLong,
10219+
SignalMessageContainsDangerousCharacters,
1020010220
UnknownError
1020110221
),
1020210222
apiTagAiAgent :: apiTagSignal :: apiTagSignalling :: apiTagChannel :: Nil,
@@ -10240,16 +10260,21 @@ object Http4s600 {
1024010260
s"""Signal channels provide short-lived, Redis-backed messaging designed for AI agent discovery and coordination, but usable by any authenticated OBP consumer.
1024110261
|Messages are ephemeral and will expire after the configured TTL (default 1 hour).
1024210262
|
10243-
|This endpoint deletes a signal channel and all its messages immediately.
10263+
|This endpoint deletes a signal channel and all its messages immediately — including other
10264+
|users' in-flight messages, which is why it requires the CanDeleteSignalChannel role rather
10265+
|than being open to every publisher. (Channels also expire on their own via the TTL.)
1024410266
|
1024510267
|Authentication is Required.
1024610268
|
1024710269
|""".stripMargin,
1024810270
EmptyBody,
1024910271
signalChannelDeletedJsonV600,
10250-
List($AuthenticatedUserIsRequired, InvalidSignalChannelName, UnknownError),
10272+
// Intentional drift from the Lift source-of-truth doc in APIMethods600:
10273+
// the CanDeleteSignalChannel role gate was added after the migration —
10274+
// an ungated delete let any authenticated user destroy any channel.
10275+
List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidSignalChannelName, UnknownError),
1025110276
apiTagAiAgent :: apiTagSignal :: apiTagSignalling :: apiTagChannel :: Nil,
10252-
None,
10277+
Some(canDeleteSignalChannel :: Nil),
1025310278
http4sPartialFunction = Some(deleteSignalChannel)
1025410279
)
1025510280
resourceDocs += ResourceDoc(

‎obp-api/src/main/scala/code/chat/ChatContentPolicy.scala‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,9 @@ object ChatContentPolicy {
1717
.flatMap(v => Try(v.trim.toInt).toOption.filter(_ > 0))
1818
.getOrElse(10000)
1919

20-
// C0 controls except \t \n \r, DEL + C1 controls, and the Unicode bidi
21-
// override/isolate/mark characters ("Trojan Source" family): none have a
22-
// legitimate use in chat, and the bidi ones can visually reverse text to
23-
// disguise what a URL or name says.
24-
private val DangerousCharacters =
25-
"[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]"
26-
20+
// Character class shared with SignalContentPolicy — see
21+
// code.util.DangerousCharacters for the rationale and the strip-vs-reject
22+
// asymmetry between chat and signal.
2723
def stripDangerousCharacters(content: String): String =
28-
content.replaceAll(DangerousCharacters, "")
24+
code.util.DangerousCharacters.strip(content)
2925
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package code.signal
2+
3+
import code.api.util.APIUtil
4+
import code.util.DangerousCharacters
5+
import org.json4s.JsonAST._
6+
7+
/**
8+
* Content policy for signal channel messages (Redis-backed agent-to-agent
9+
* coordination — see RedisMessaging and the /signal/channels endpoints).
10+
*
11+
* Signal payloads are machine-consumed data, so the policy differs from chat
12+
* on purpose: nothing is ever rewritten (agents may hash, sign, or
13+
* byte-compare payloads) — a message either passes verbatim or is rejected.
14+
* The dangerous-character check runs on the PARSED JSON, not the raw body:
15+
* a raw body carrying a bidi override as a JSON backslash-u escape is pure
16+
* ASCII on the wire but still parses to a string containing the override,
17+
* so a wire-level check would miss it.
18+
*/
19+
object SignalContentPolicy {
20+
21+
/**
22+
* Maximum accepted publish request body length in characters
23+
* (prop messaging.channel.max.payload.length). Checked against the raw
24+
* body BEFORE JSON parsing, so an oversized body is refused without
25+
* paying the parse cost — the cap protects Redis memory and parser CPU.
26+
*/
27+
def maxPayloadLength: Int =
28+
APIUtil.getPropsAsIntValue("messaging.channel.max.payload.length", 65536)
29+
30+
/**
31+
* True when any string value or field name anywhere in `json` contains a
32+
* character from the shared dangerous set (control characters and the
33+
* Unicode bidi override family — see code.util.DangerousCharacters).
34+
*/
35+
def containsDangerousCharacters(json: JValue): Boolean = json match {
36+
case JString(value) => DangerousCharacters.containsAny(value)
37+
case JObject(fields) => fields.exists { case (name, value) =>
38+
DangerousCharacters.containsAny(name) || containsDangerousCharacters(value)
39+
}
40+
case JArray(items) => items.exists(containsDangerousCharacters)
41+
case _ => false
42+
}
43+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package code.util
2+
3+
/**
4+
* The character class both chat and signal content policies are built on:
5+
* C0 controls except \t \n \r, DEL + C1 controls, and the Unicode bidi
6+
* override/isolate/mark characters ("Trojan Source" family). None have a
7+
* legitimate use in user- or agent-supplied text, and the bidi ones can
8+
* visually reverse text to disguise what a URL or name says.
9+
*
10+
* Chat STRIPS these at ingest (humans typing — be forgiving, content is
11+
* rendered); signal REJECTS messages containing them (machines publishing —
12+
* payloads must be stored verbatim or refused, never silently rewritten).
13+
* See ChatContentPolicy and SignalContentPolicy for the two applications.
14+
*/
15+
object DangerousCharacters {
16+
17+
val Pattern: String =
18+
"[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]"
19+
20+
private val compiledPattern = java.util.regex.Pattern.compile(Pattern)
21+
22+
def strip(content: String): String = content.replaceAll(Pattern, "")
23+
24+
def containsAny(content: String): Boolean = compiledPattern.matcher(content).find()
25+
}

0 commit comments

Comments
 (0)