Link previews the recipient never fetches (§6.12)
🔴 **The obvious implementation is the one that must not be built.** Having each recipient fetch the URL to draw a card tells the site the reader's IP address, that they received a link, and roughly when they opened it — once per member on a group message. A messenger that hides the message and then has every reader announce its contents to a third party has given away what it was protecting. So the SENDER fetches, and the preview travels inside the ciphertext. The receiver renders from those bytes and contacts nobody. ⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is about to share that URL, from this address. The setting exists for that, and the copy says who learns what rather than implying it is free. 🔴 **The host is shown separately and derived from the URL**, because title and description come from a page the sender did not write — a card claiming one destination while the link points at another is a phishing primitive. Both fields are bounded on the way IN as well as line-limited at render, so the cap does not depend on a future renderer remembering one. ⚠️ `Content.text` still returns the raw body byte-for-byte when there is no preview, so an ordinary message is unchanged on the wire and older clients see exactly what they saw before. ⚠️ The fetch is short-timeout and best-effort, and runs on the IO context. A preview is a decoration and must never be able to delay a send — every failure path ends as a message with a plain URL in it. engine 168, client 96, app 40. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7f4fa1bf2b
commit
dc1be35816
@@ -51,6 +51,14 @@ data class Content(
|
||||
val call: CallSignal? = null,
|
||||
/** Set iff this is a sticker: which pack, and which one in it. */
|
||||
val sticker: Sticker? = null,
|
||||
/**
|
||||
* A preview of a link in this message, generated by the SENDER (§6.12).
|
||||
*
|
||||
* ⚠️ Rendered from these bytes and nothing else. The receiver never fetches
|
||||
* the URL — that would tell the site who read the link and when, once per
|
||||
* member on a group message.
|
||||
*/
|
||||
val link: LinkPreview? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -157,6 +165,37 @@ data class Content(
|
||||
val needsNewerClient: Boolean
|
||||
get() = kind !in Kind.KNOWN && !isControl
|
||||
|
||||
/**
|
||||
* A link preview the sender generated (§6.12).
|
||||
*
|
||||
* ⚠️ [title] and [desc] come from a page the sender did not write. Bounded
|
||||
* here rather than at render, so the cap does not depend on every future
|
||||
* renderer remembering one, and shown beside [host] — a card claiming one
|
||||
* destination while [url] points at another is a phishing primitive.
|
||||
*/
|
||||
data class LinkPreview(val url: String, val title: String, val desc: String) {
|
||||
val host: String
|
||||
get() = runCatching { java.net.URI(url).host }.getOrNull() ?: url
|
||||
|
||||
companion object {
|
||||
const val MAX_TITLE = 120
|
||||
const val MAX_DESC = 200
|
||||
|
||||
fun of(url: String, title: String, desc: String) =
|
||||
LinkPreview(url, title.take(MAX_TITLE), desc.take(MAX_DESC))
|
||||
|
||||
fun fromJson(o: JSONObject): LinkPreview? {
|
||||
val url = o.optString("url").takeIf { it.isNotBlank() } ?: return null
|
||||
val title = o.optString("title")
|
||||
if (title.isBlank()) return null
|
||||
return of(url, title, o.optString("desc"))
|
||||
}
|
||||
}
|
||||
|
||||
fun toJson(): JSONObject = JSONObject()
|
||||
.put("url", url).put("title", title).put("desc", desc)
|
||||
}
|
||||
|
||||
object Kind {
|
||||
const val TEXT = "text"
|
||||
const val REACT = "react"
|
||||
@@ -234,9 +273,15 @@ data class Content(
|
||||
* an envelope as literal JSON, so the safe default for the simplest
|
||||
* message is the format every client has always understood.
|
||||
*/
|
||||
fun text(body: String, messageId: String? = null, about: Reference? = null): String =
|
||||
if (messageId == null && about == null) body
|
||||
else assemble("text", caption = body, messageId = messageId, about = about)
|
||||
fun text(
|
||||
body: String,
|
||||
messageId: String? = null,
|
||||
about: Reference? = null,
|
||||
link: LinkPreview? = null,
|
||||
): String =
|
||||
if (messageId == null && about == null && link == null) body
|
||||
else assemble("text", caption = body, messageId = messageId, about = about,
|
||||
link = link)
|
||||
|
||||
fun groupText(
|
||||
body: String,
|
||||
@@ -300,6 +345,7 @@ data class Content(
|
||||
attachment: Attachment.Ref? = null,
|
||||
messageId: String? = null,
|
||||
about: Reference? = null,
|
||||
link: LinkPreview? = null,
|
||||
): String {
|
||||
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
||||
messageId?.let { parts += "\"mid\":${quote(it)}" }
|
||||
@@ -308,6 +354,7 @@ data class Content(
|
||||
expires?.let { parts += "\"exp\":$it" }
|
||||
groupId?.let { parts += "\"gid\":${quote(it)}" }
|
||||
attachment?.let { parts += "\"att\":${it.toJson()}" }
|
||||
link?.let { parts += "\"lnk\":${it.toJson()}" }
|
||||
return parts.joinToString(",", prefix = "{", postfix = "}")
|
||||
}
|
||||
|
||||
@@ -398,7 +445,8 @@ data class Content(
|
||||
}
|
||||
|
||||
if (kind == Kind.TEXT) {
|
||||
return Content(caption, kind = kind, raw = json, groupId = groupId,
|
||||
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
|
||||
return Content(caption, kind = kind, raw = json, groupId = groupId, link = link,
|
||||
messageId = messageId, about = about)
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,57 @@ class ContentTest {
|
||||
assertNull(Content.disappearSeconds(Content.parse(Content.text("hello"))))
|
||||
}
|
||||
|
||||
/**
|
||||
* Link previews (§6.12).
|
||||
*
|
||||
* 🔴 The SENDER generates these and they ride inside the ciphertext. A card
|
||||
* each recipient fetched would tell the site the reader's IP, that they
|
||||
* received a link, and roughly when they opened it — once per member on a
|
||||
* group message.
|
||||
*/
|
||||
@Test
|
||||
fun `a link preview round-trips`() {
|
||||
val preview = Content.LinkPreview.of("https://example.com/x", "A title", "A desc")
|
||||
val parsed = Content.parse(Content.text("look https://example.com/x", link = preview))
|
||||
assertEquals("https://example.com/x", parsed.link?.url)
|
||||
assertEquals("A title", parsed.link?.title)
|
||||
assertEquals("look https://example.com/x", parsed.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The host comes from the URL, never the page. A card whose title claims
|
||||
* one destination while the link points at another is a phishing primitive.
|
||||
*/
|
||||
@Test
|
||||
fun `the host comes from the url not the title`() {
|
||||
val preview = Content.LinkPreview.of(
|
||||
"https://evil.example/login", "Sign in — yourbank.com", "")
|
||||
assertEquals("evil.example", preview.host)
|
||||
}
|
||||
|
||||
/** ⚠️ Bounded on the way in, so no renderer has to remember a cap. */
|
||||
@Test
|
||||
fun `title and description are bounded`() {
|
||||
val preview = Content.LinkPreview.of(
|
||||
"https://example.com", "A".repeat(5_000), "B".repeat(5_000))
|
||||
assertEquals(Content.LinkPreview.MAX_TITLE, preview.title.length)
|
||||
assertEquals(Content.LinkPreview.MAX_DESC, preview.desc.length)
|
||||
}
|
||||
|
||||
/** A preview with no title is not a preview — it would render as a blank card. */
|
||||
@Test
|
||||
fun `a preview without a title is refused`() {
|
||||
val json = org.json.JSONObject().put("url", "https://example.com").put("title", "")
|
||||
assertNull(Content.LinkPreview.fromJson(json))
|
||||
}
|
||||
|
||||
/** Optional: a plain message must be byte-identical to before. */
|
||||
@Test
|
||||
fun `a message without a preview stays raw text`() {
|
||||
assertEquals("plain", Content.text("plain"))
|
||||
assertNull(Content.parse("plain").link)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown attachment kind shows a label rather than disappearing`() {
|
||||
val parsed = Content.parse("""{"fnc":1,"kind":"hologram"}""")
|
||||
|
||||
Reference in New Issue
Block a user