#416·coai

Conversation share links use a predictable, guessable hash instead of a random secret

Author: carfeiiCreated Sep 15, 2026Updated Sep 15, 2026

Affected versions: confirmed on commit 3048a49 (current main).

Summary

coai's "share conversation" feature generates a share link identified by a hash query parameter that GET /conversation/view treats as the sole access control, no login required. That hash is computed as md5(JSON({id: user_id, conversation_id, refs})), a value entirely derived from the sharer's numeric user id, the numeric conversation id, and the list of included message indices, with no random or server-side secret component. Because both ids are small sequential integers (auth.id and conversation.conversation_id are plain AUTO_INCREMENT columns) and an unqualified share defaults to refs=[-1], anyone can recompute a valid share hash for a guessed (or known) user id and conversation id and view that user's shared conversation content without ever receiving the link.

Details

manager/conversation/shared.go, before the fix:

go
func ShareConversation(db *sql.DB, user *auth.User, id int64, refs []int) (string, error) {
	if id < 0 || user == nil {
		return "", nil
	}

	ref := GetRef(refs)
	hash := utils.Md5EncryptForm(SharedHashForm{
		Id:             user.GetID(db),
		ConversationId: id,
		Refs:           refs,
	})

	if _, err := globals.ExecDb(db, `
		INSERT INTO sharing (hash, user_id, conversation_id, refs) VALUES (?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE refs = ?
	`, hash, user.GetID(db), id, ref, ref); err != nil {
		return "", err
	}

	return hash, nil
}

utils.Md5EncryptForm is a plain, unsalted md5(json.Marshal(form)). connection/database.go's schema confirms both ids are sequential:

sql
CREATE TABLE IF NOT EXISTS auth (
  id INT PRIMARY KEY AUTO_INCREMENT,
  ...
);
...
CREATE TABLE IF NOT EXISTS conversation (
  id INT PRIMARY KEY AUTO_INCREMENT,
  user_id INT,
  conversation_id INT,
  ...
);

GET /conversation/view (ViewAPI in manager/conversation/api.go) requires no authentication and trusts the hash alone:

go
func ViewAPI(c *gin.Context) {
	db := utils.GetDBFromContext(c)
	hash := strings.TrimSpace(c.Query("hash"))
	...
	shared, err := GetSharedConversation(db, hash)
	...
}

GetSharedConversation looks the hash up directly against the sharing table with no other check:

go
func GetSharedConversation(db *sql.DB, hash string) (*SharedForm, error) {
	...
	if err := globals.QueryRowDb(db, `
		SELECT auth.username, sharing.refs, sharing.updated_at, conversation.conversation_name,
		       sharing.user_id, sharing.conversation_id, conversation.model
		FROM sharing
		INNER JOIN auth ON auth.id = sharing.user_id
		INNER JOIN conversation ON conversation.conversation_id = sharing.conversation_id AND conversation.user_id = sharing.user_id
		WHERE sharing.hash = ?
	`, hash).Scan(...)
	...
}

The hash is the entire security boundary here, and it is not a secret: it is fully reconstructible by anyone who knows (or, given sequential small integers, guesses) the sharer's user id and the conversation id, using the default refs=[-1] that an unqualified share uses.

POC

(available upon request)

Impact

Any user who shares a conversation (a normal, intended action, meant to hand a link to one chosen recipient) has that share become enumerable and viewable by anyone else who can guess their small integer user id and a plausible conversation id, since the hash carries no randomness. This defeats the confidentiality guarantee a share link is expected to provide and discloses the full conversation content (including whatever the user typed into the chat) to unintended parties, without needing the original link, an account, or any interaction from the sharer.