🐛 Filter share-link tokens in get-view-only-bundle response

The get-view-only-bundle RPC command returned all share-link tokens for a file, allowing an anonymous holder of a restrictive share-link to enumerate and use more permissive tokens.

When authenticating via a share-link, the response now only includes the share-link used for authentication, preventing token disclosure and scope escalation.

Implemented using TDD:
- RED: Test demonstrates vulnerability (all tokens visible)
- GREEN: Filter share-links when (:type perms) = :share-link
- Verified all existing tests still pass

Closes #11285

AI-assisted-by: qwen3.7-plus
This commit is contained in:
Andrey Antukh 2026-08-19 11:36:31 +00:00
parent fda6d56139
commit e57d949a7e
2 changed files with 74 additions and 11 deletions

View File

@ -56,7 +56,7 @@
(assoc :can-read true)))
(defn- get-view-only-bundle
[{:keys [::db/conn] :as cfg} {:keys [profile-id file-id ::perms] :as params}]
[{:keys [::db/conn] :as cfg} {:keys [profile-id file-id share-id ::perms] :as params}]
(let [file (bfc/get-file cfg file-id)
project (db/get conn :project
@ -89,16 +89,18 @@
(mapv (fn [{:keys [id] :as lib}]
(merge lib (bfc/get-file cfg id)))))
links (->> (db/query conn :share-link {:file-id file-id})
(mapv (fn [row]
(-> row
(update :pages db/decode-pgarray #{})
;; NOTE: the flags are deprecated but are still present
;; on the table on old rows. The flags are pgarray and
;; for avoid decoding it (because they are no longer used
;; on frontend) we just dissoc the column attribute from
;; row.
(dissoc :flags)))))
links (cond->> (->> (db/query conn :share-link {:file-id file-id})
(mapv (fn [row]
(-> row
(update :pages db/decode-pgarray #{})
;; NOTE: the flags are deprecated but are still present
;; on the table on old rows. The flags are pgarray and
;; for avoid decoding it (because they are no longer used
;; on frontend) we just dissoc the column attribute from
;; row.
(dissoc :flags)))))
(= :share-link (:type perms))
(filterv #(= (:id %) share-id)))
fonts (db/query conn :team-font-variant
{:team-id (:id team)

View File

@ -128,3 +128,64 @@
(let [result (:result out)]
(t/is (contains? result :file))
(t/is (contains? result :project)))))))
(t/deftest share-link-token-disclosure
(let [owner (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id owner)
file (th/create-file* 1 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
page-a (get-in file [:data :pages 0])
page-b (uuid/random)
;; Add a second page to the file
_ (th/command! {::th/type :update-file
::rpc/profile-id (:id owner)
:id (:id file)
:session-id (uuid/random)
:revn 0
:vern 0
:changes [{:type :add-page
:id page-b
:page {:id page-b
:name "Page B"
:options {}
:objects {}}}]})
;; Create Link A: restrictive (no pages, team-only comments/inspect)
link-a (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
link-a-id (get-in link-a [:result :id])
;; Create Link B: permissive (all pages, all can comment/inspect)
link-b (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{page-a page-b}
:who-comment "all"
:who-inspect "all"})
link-b-id (get-in link-b [:result :id])]
(t/testing "restrictive share-link holder cannot see other share-link tokens"
(let [out (th/command! {::th/type :get-view-only-bundle
:share-id link-a-id
:file-id (:id file)})
err (:error out)
result (:result out)
share-links (:share-links result)]
;; Should not error
(t/is (nil? err))
;; Should only see the share-link used for authentication
(t/is (= 1 (count share-links)))
(t/is (= link-a-id (:id (first share-links))))
;; Should NOT see Link B's token
(t/is (not (some #(= link-b-id (:id %)) share-links)))))))