Snipe-IT models "read the raw license serial (product key)" as a distinct authorization concern from "list and view license records." The gate is LicensePolicy::viewKeys(), which grants access only to users holding licenses.keys, licenses.create, or licenses.edit, and the API transformer at app/Http/Transformers/LicensesTransformer.php honors it correctly:
'product_key' => (Gate::allows('viewKeys', $license)) ? e($license->serial) : '------------',
Two other code paths that touch licenses.serial were missed and only enforce the coarser view gate (licenses.view).
Vector A: bulk cleartext extraction via CSV export
LicensesController::getExportLicensesCsv() (route licenses.export, GET /licenses/export) authorizes only view on the License model and then streams the serial column into the CSV without further checks:
public function getExportLicensesCsv()
{
$this->authorize('view', License::class);
// ...
foreach ($licenses as $license) {
$values = [
$license->id,
$license->company ? $license->company->name : '',
$license->name,
$license->serial,
// ...
];
fputcsv($handle, $formatter->escapeRecord($values));
}
}
A user holding only licenses.view can request this endpoint and receive every product key in the instance in cleartext, one row per license, subject only to the standard FMCS company scope. Values are emitted plain (they are not passed through the product_key masking that the transformer applies), so the attacker walks away with the same key material the license owner would need to activate the software.
Vector B: existence oracle on the API index
Api\LicensesController::index() (route api.licenses.index, GET /api/v1/licenses) authorizes view and then honors two filter shapes that both read from licenses.serial:
if ($request->filled('product_key')) {
$licenses->where('licenses.serial', '=', $request->input('product_key'));
}
// ...
if ($request->filled('filter') || $request->filled('search')) {
$licenses->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
License::$searchableAttributes includes serial, so TextSearch performs a LIKE %term% against the raw column as well. The response body correctly masks product_key back to ------------ for callers who fail viewKeys, but the total count and the presence or absence of rows in the payload leak whether the queried value matched. A caller can iterate candidate keys (or substrings) and confirm which ones are valid by comparing total=0 against total>0, converting any partial leak from logs, backups, screenshots, or vendor documentation into a validated key.
Both vectors share the same root cause: the serial column is authorized-adjacent, so every read, filter, search, and export path against it must consult viewKeys in addition to the base view gate. The masking in the transformer is necessary but not sufficient.
Impact
Any authenticated user holding only the routine licenses.view permission can
- download every license key in the instance in bulk as CSV (Vector A), and
- validate candidate keys one at a time against the API without needing to see the masked value in the response (Vector B).
License product keys are secrets under normal Snipe-IT deployment assumptions. Their disclosure carries direct financial impact (relicensing costs, vendor audits) and, for BYOL-style deployments where the key doubles as an activation credential, enables unauthorized use of the licensed software.
Patches
Fixed in commit c4d3a95. The CSV export now emits the shared License::PRODUCT_KEY_MASK placeholder (------------, same mask the API transformer already used) in the serial column for callers who fail the viewKeys gate. The API index silently drops the product_key filter for those callers (so a valid vs invalid candidate return the same result set, killing the oracle), and its search / filter paths run through a new License::scopeTextSearchWithoutSerial scope that removes serial from the searchable attribute set before delegating to the standard TextSearch. The same mask constant is also used by AssetsTransformer::transformLicenseCheckedToAsset for consistency across every surface that renders a masked product key.
Regression coverage in tests/Feature/Licenses/Ui/ExportLicensesCsvTest.php and tests/Feature/Licenses/Api/LicenseIndexTest.php pins: CSV export mask presence for view-only callers, real values for licenses.keys / licenses.edit / superuser callers, product_key filter suppression, structured filter suppression on the serial key, free-text search suppression on serial values, and restoration of the searchable attribute set after the scope returns.
Workarounds
Until the patch lands, deployments should ensure only trusted users hold the licenses.view permission, or additionally restrict access to the /licenses/export route and the product_key, search, and filter query parameters on /api/v1/licenses at the web-server or WAF layer.
Credits
Reported by Pavel Kohout (Aisle Research), GitHub handle pavelkohout396.
Metadata
- CVSS:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N (6.5 Medium)
- CWE: CWE-863 (Incorrect Authorization), with CWE-204 (Observable Response Discrepancy) applying to Vector B specifically
- Affected:
<= 8.6.3 (both vulnerable code paths predate v3.x and have existed for the entire supported life of the project)
- Patched:
8.7.0
- Fix commit: c4d3a95
Snipe-IT models "read the raw license serial (product key)" as a distinct authorization concern from "list and view license records." The gate is
LicensePolicy::viewKeys(), which grants access only to users holdinglicenses.keys,licenses.create, orlicenses.edit, and the API transformer atapp/Http/Transformers/LicensesTransformer.phphonors it correctly:Two other code paths that touch
licenses.serialwere missed and only enforce the coarserviewgate (licenses.view).Vector A: bulk cleartext extraction via CSV export
LicensesController::getExportLicensesCsv()(routelicenses.export,GET /licenses/export) authorizes onlyviewon the License model and then streams theserialcolumn into the CSV without further checks:A user holding only
licenses.viewcan request this endpoint and receive every product key in the instance in cleartext, one row per license, subject only to the standard FMCS company scope. Values are emitted plain (they are not passed through theproduct_keymasking that the transformer applies), so the attacker walks away with the same key material the license owner would need to activate the software.Vector B: existence oracle on the API index
Api\LicensesController::index()(routeapi.licenses.index,GET /api/v1/licenses) authorizesviewand then honors two filter shapes that both read fromlicenses.serial:License::$searchableAttributesincludesserial, soTextSearchperforms aLIKE %term%against the raw column as well. The response body correctly masksproduct_keyback to------------for callers who failviewKeys, but thetotalcount and the presence or absence of rows in the payload leak whether the queried value matched. A caller can iterate candidate keys (or substrings) and confirm which ones are valid by comparingtotal=0againsttotal>0, converting any partial leak from logs, backups, screenshots, or vendor documentation into a validated key.Both vectors share the same root cause: the
serialcolumn is authorized-adjacent, so every read, filter, search, and export path against it must consultviewKeysin addition to the baseviewgate. The masking in the transformer is necessary but not sufficient.Impact
Any authenticated user holding only the routine
licenses.viewpermission canLicense product keys are secrets under normal Snipe-IT deployment assumptions. Their disclosure carries direct financial impact (relicensing costs, vendor audits) and, for BYOL-style deployments where the key doubles as an activation credential, enables unauthorized use of the licensed software.
Patches
Fixed in commit c4d3a95. The CSV export now emits the shared
License::PRODUCT_KEY_MASKplaceholder (------------, same mask the API transformer already used) in theserialcolumn for callers who fail theviewKeysgate. The API index silently drops theproduct_keyfilter for those callers (so a valid vs invalid candidate return the same result set, killing the oracle), and itssearch/filterpaths run through a newLicense::scopeTextSearchWithoutSerialscope that removesserialfrom the searchable attribute set before delegating to the standardTextSearch. The same mask constant is also used byAssetsTransformer::transformLicenseCheckedToAssetfor consistency across every surface that renders a masked product key.Regression coverage in
tests/Feature/Licenses/Ui/ExportLicensesCsvTest.phpandtests/Feature/Licenses/Api/LicenseIndexTest.phppins: CSV export mask presence for view-only callers, real values forlicenses.keys/licenses.edit/ superuser callers,product_keyfilter suppression, structured filter suppression on theserialkey, free-text search suppression on serial values, and restoration of the searchable attribute set after the scope returns.Workarounds
Until the patch lands, deployments should ensure only trusted users hold the
licenses.viewpermission, or additionally restrict access to the/licenses/exportroute and theproduct_key,search, andfilterquery parameters on/api/v1/licensesat the web-server or WAF layer.Credits
Reported by Pavel Kohout (Aisle Research), GitHub handle pavelkohout396.
Metadata
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N(6.5 Medium)<= 8.6.3(both vulnerable code paths predate v3.x and have existed for the entire supported life of the project)8.7.0