CityRP Documentation Repository

Image Hosts

Last updated

Players can point several systems at an image URL and have the server or every other client fetch it. Each of those systems used to carry its own copy of the "safe hosts" list, and the copies drifted — the billboard UI advertised a host the validator rejected, and the 3D sign editor accepted a host the Material URL tool had already dropped.

There is now one list, in gamemode/core/libraries/sh_imagehosts.lua, exposed as cityrp.imageHosts. It is shared (client and server), loads with the core libraries, and is the only place a host name should appear.

Maintaining the list

Edit cityrp.imageHosts.definitions in sh_imagehosts.lua, which is the current list; it is not repeated here so this page cannot drift from it. Each entry is one host:

{host = "i.ibb.co", enabled = true, reason = "ImgBB direct image host."},
  • Set enabled = false rather than deleting an entry, so the reason a host was dropped stays on record. Disabled entries are invisible to every consumer.
  • Give the exact hostname players will link to. Matching is exact and case-insensitive; subdomains are not implied, so i.ibb.co does not permit evil.i.ibb.co.
  • Prefer hosts that serve the raw image bytes. imgur.com/abc returns an HTML page; i.imgur.com/abc.png returns the image.
  • Mark a host extensionless = true only when it serves images from paths with no file extension, as Steam user content does (images.steamusercontent.com/ugc/<id>/<hash>/). Systems that require an image extension then accept that host's extensionless URLs, and rely on checking the downloaded file instead. An extension that is present still has to be an accepted one.

Accepted file extensions live alongside it in cityrp.imageHosts.extensions.

The UIs that list approved hosts read them from here, so no UI text needs updating when the list changes. Player-facing prose goes through getHostList() / getExtensionList(), which use FormatList so the wording matches the rest of the gamemode; UI that lays the hostnames out itself (one per line, bullet-separated) uses getHosts() and does its own formatting.

Using the list

Validate whole URLs with cityrp.imageHosts.validate, which normalises the input and returns a player-facing reason on rejection:

local url, reason = cityrp.imageHosts.validate(input, true) -- true = also require png/jpg/jpeg

if not url then
	ply:Notify(reason, NOTIFY_ERROR)
	return
end

Other entry points:

Function Purpose
cityrp.imageHosts.validate(url, requireExtension) Normalise and check a URL. Returns the normalised URL, or nil plus a reason. requireExtension applies hasImagePath.
cityrp.imageHosts.isAllowed(url) Boolean check of a full URL.
cityrp.imageHosts.isAllowedHost(host) Boolean check of a bare hostname.
cityrp.imageHosts.normalise(url) Lowercase the scheme, assume https:// when none is given, keep any trailing slash (Steam user-content URLs 404 without it), reject anything that is not http or https. Does not check the allowlist.
cityrp.imageHosts.getHost(url) Parse the hostname out of a URL.
cityrp.imageHosts.hasAllowedExtension(url) Check the file extension only.
cityrp.imageHosts.hasImagePath(url) An accepted extension, or none at all on an extensionless host. A consumer accepting the second case must check the downloaded bytes or content type.
cityrp.imageHosts.getHosts() Copy of the enabled hostnames, for UI that lays them out itself.
cityrp.imageHosts.getHostList(conjunction) The enabled hostnames as prose, via FormatList — "a, b or c".
cityrp.imageHosts.getExtensionList(conjunction) The accepted extensions as prose, the same way.

Schemes

Both http and https are accepted, and a URL with no scheme is assumed to be https.

Plain http means the image bytes can be tampered with in transit by anything sitting between the server and the host. That is a deliberate, accepted trade-off rather than an oversight: the host is still allowlisted, the response is only ever decoded as an image, and enough approved links arrive as http that rejecting them broke more than it protected. Anything that is neither http nor https — ftp://, javascript:, data:, a protocol-relative //host/path — is rejected.

A URL carrying userinfo (user@host) or an explicit port is also rejected outright rather than parsed. That is not new hardening — the per-system pattern matches this library replaced already rejected both, because they required / or end-of-string straight after the hostname. It is called out here because parsing the authority instead of pattern-matching it is exactly where that property gets dropped by accident, and dropping it would let a crafted authority read as one host to the validator and another to whatever fetches the URL.

Current consumers

System Where it validates How the download is checked
Billboards (plugins/billboards) sh/zad_url_validation.lua wraps the library; the config UI lists the hosts from it. cl/zad_imageloader.lua requires a png or jpeg Content-Type.
Material URL tool (addons/material_url_2559426779) lua/autorun/materialurl_whitelist.lua bridges MatURL.checkURL and MatURL.checkFormat onto the library. The client sniffs the file's magic bytes and Content-Type before baking a material.
3D signs (addons/3dsign, entities/cityrp_3d_sign) The editor validates as you type; the apply net messages re-check server-side. Rendered by an HTML panel's <img>, which draws only what decodes as an image.

The Material URL bridge runs from lua/autorun, which loads before the gamemode. It therefore resolves cityrp.imageHosts at call time and denies the URL if the library is not loaded yet, rather than caching a snapshot. Any addon-side consumer added later needs to do the same.

What this does not cover

rRadio keeps its own allowlist of audio stream hosts (rRadio.config.AllowedHosts). Those are stream endpoints with different rules — plain http is normal for them, and the list is maintained against the station packs — so they are deliberately not merged into cityrp.imageHosts.