Problem/Motivation
Every place the module advertises which languages a resource has content in builds the list from getTranslationLanguages() with no access check:
- the strict 406's error
detailandmeta.availableTranslations(#3612568), - the
hreflangattribute on theselflink of every individual read, - the per-item details of
meta.omittedentries on the related endpoint (#3612537), on included resources (#3613728), and on thejsonapi_menu_itemstree (#3612677).
A low-privilege or anonymous client can therefore learn that unpublished or otherwise view-restricted translations EXIST, and in which languages, without any right to see them. This is metadata disclosure rather than content disclosure, but "an fr translation of this node exists" can itself be sensitive: unannounced launches, embargoed content, work in progress.
Steps to reproduce
- Languages
en(default) andfr. An article has a publishedentranslation and an unpublishedfrtranslation. - As an anonymous user,
GET /jsonapi/node/article/{uuid}withAccept-Language: de. Expected: the 406 advertisesenonly. Actual:detailandmeta.availableTranslationsnamefr. - As an anonymous user,
GET /jsonapi/node/article/{uuid}. Expected: thehreflangattribute listsen. Actual: it listsen,fr. - The same list leaks through the omission details of related, included and menu-items responses whose target has the unpublished translation.
Proposed resolution
Advertise only translations the requesting user may view, and filter at the advertisement sites, not in the negotiator:
- A
viewableTranslationLangcodes()helper on the controller keeps langcodes whose translation'saccess('view')is allowed (sorted), and feeds the 406 lists,targetAvailableLangcodes()and thehreflangattribute. Small, deliberately duplicated loops (commented) cover the menu-items miss recording inRequestLanguageEntityRepository, which must stay dependency-light, and the include-omission reason inEntityAccessChecker. - Language SELECTION is unchanged: negotiation still operates over all existing translations, and a selected translation the user cannot view keeps returning an access error, matching core JSON:API's entity-level posture where 403-vs-404 already distinguishes existence. Only what gets printed is filtered.
- Cacheability follows the filter: every access result, allowed or denied, is attached where a filtered list lands on a cacheable response (the
hreflanglink'sCacheableMetadata, the omission entries' access result), so users with different access are served correctly different lists. The 406s are exempt because they aremax-age 0by construction. - An empty viewable list advertises nothing: the "Available translations" clause is omitted from the detail rather than rendered empty, and the 406 omits
meta.availableTranslationsentirely. - Two surfaces stay unfiltered because they do not expose per-entity data: the menu 406's list of the site's configured languages (public configuration), and config-entity override availability (config overrides have no per-translation access concept).
- Entity-type quirk, decided and recorded: core's
MenuLinkContentAccessControlHandlergatesviewbehind theadminister menupermission for the whole entity, andmenu_link_content'senabledfield is not translatable, so there is no per-translation restricted state on menu links and the filter empties their advertised lists for every non-admin client. The filter applies as specified anyway: an anonymous menu omission report names the omitted link but no languages, while a user withadminister menusees the full list. The report still tells the client the link was omitted for language reasons. - The advertised lists become access-dependent rather than absolute, by design: clients cannot assume list stability across principals.
Issue fork jsonapi_multilingual-3613770
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #4
lauriii