fix: stricter public reads

This commit is contained in:
Vlad Stan
2026-07-16 15:29:56 +03:00
parent 58ccdb7212
commit b678773728
8 changed files with 257 additions and 46 deletions
+51 -9
View File
@@ -52,6 +52,7 @@ from .models import (
StorageGetResponse, StorageGetResponse,
StoragePaginatedRequest, StoragePaginatedRequest,
StoragePaginatedResponse, StoragePaginatedResponse,
StoragePublicPaginatedRequest,
StorageSetRequest, StorageSetRequest,
StorageSetResponse, StorageSetResponse,
UserWalletSummary, UserWalletSummary,
@@ -128,7 +129,7 @@ class ExtensionHostAPI:
async def storage_get_public( async def storage_get_public(
self, request: StorageGetRequest self, request: StorageGetRequest
) -> StorageGetResponse: ) -> StorageGetResponse:
public_fields = self._public_storage_fields(request.table) public_fields = self._public_storage_policy(request.table)["public_fields"]
row = await storage_get_public_row(self.extension_id, request.table, request.id) row = await storage_get_public_row(self.extension_id, request.table, request.id)
if not row: if not row:
return StorageGetResponse() return StorageGetResponse()
@@ -239,14 +240,23 @@ class ExtensionHostAPI:
require_auth=False, require_auth=False,
) )
async def storage_get_public_paginated( async def storage_get_public_paginated(
self, request: StoragePaginatedRequest self, request: StoragePublicPaginatedRequest
) -> StoragePaginatedResponse: ) -> StoragePaginatedResponse:
public_fields = self._public_storage_fields(request.table) policy = self._public_storage_policy(request.table)
self._validate_public_storage_query_fields(request, public_fields) public_fields = policy["public_fields"]
source_id_field = policy["source_id_field"]
if not isinstance(source_id_field, str) or not source_id_field:
raise PermissionError(
"Public paginated storage reads require a source ID field policy."
)
filters = self._public_storage_paginated_filters(
request, public_fields, source_id_field
)
page = await storage_get_public_paginated_rows( page = await storage_get_public_paginated_rows(
self.extension_id, self.extension_id,
request.table, request.table,
request.filters, filters,
search=request.search, search=request.search,
search_fields=request.search_fields, search_fields=request.search_fields,
sort_by=request.sort_by, sort_by=request.sort_by,
@@ -737,7 +747,7 @@ class ExtensionHostAPI:
return permission_ids, policies return permission_ids, policies
def _public_storage_fields(self, table: str) -> set[str]: def _public_storage_policy(self, table: str) -> dict[str, Any]:
tables = self.permission_policies.get("ext.storage.read_public") tables = self.permission_policies.get("ext.storage.read_public")
if not isinstance(tables, list) or not tables: if not isinstance(tables, list) or not tables:
raise PermissionError( raise PermissionError(
@@ -757,24 +767,56 @@ class ExtensionHostAPI:
raise PermissionError( raise PermissionError(
f"Public storage table '{table}' has no valid public fields." f"Public storage table '{table}' has no valid public fields."
) )
return set(public_fields) source_id_field = table_policy.get("source_id_field")
if source_id_field is not None and (
not isinstance(source_id_field, str) or not source_id_field
):
raise PermissionError(
f"Public storage table '{table}' has no valid source ID field."
)
return {
"public_fields": set(public_fields),
"source_id_field": source_id_field,
}
raise PermissionError(f"Storage table '{table}' is not publicly readable.") raise PermissionError(f"Storage table '{table}' is not publicly readable.")
def _validate_public_storage_query_fields( def _validate_public_storage_query_fields(
self, request: StoragePaginatedRequest, public_fields: set[str] self,
request: StoragePaginatedRequest,
public_fields: set[str],
allowed_private_fields: set[str] | None = None,
) -> None: ) -> None:
allowed_private_fields = allowed_private_fields or set()
query_fields = set(request.filters) query_fields = set(request.filters)
query_fields.update(request.search_fields) query_fields.update(request.search_fields)
if request.sort_by: if request.sort_by:
query_fields.add(request.sort_by) query_fields.add(request.sort_by)
private_fields = sorted(query_fields - public_fields) private_fields = sorted(query_fields - public_fields - allowed_private_fields)
if private_fields: if private_fields:
raise PermissionError( raise PermissionError(
"Public storage query uses non-public fields: " "Public storage query uses non-public fields: "
+ ", ".join(private_fields) + ", ".join(private_fields)
) )
def _public_storage_paginated_filters(
self,
request: StoragePublicPaginatedRequest,
public_fields: set[str],
source_id_field: str,
) -> dict[str, Any]:
self._validate_public_storage_query_fields(
request, public_fields, {source_id_field}
)
filters = dict(request.filters)
requested_source_id = filters.get(source_id_field)
if requested_source_id is not None and requested_source_id != request.source_id:
raise PermissionError(
"Public storage source filter does not match source_id."
)
filters[source_id_field] = request.source_id
return filters
async def _public_storage_append_policy( async def _public_storage_append_policy(
self, table: str, source_id: str self, table: str, source_id: str
) -> tuple[dict[str, Any], str]: ) -> tuple[dict[str, Any], str]:
+4
View File
@@ -109,6 +109,10 @@ class StoragePaginatedRequest(BaseModel):
return values return values
class StoragePublicPaginatedRequest(StoragePaginatedRequest):
source_id: str = Field(..., min_length=1, max_length=512)
class StoragePaginatedResponse(BaseModel): class StoragePaginatedResponse(BaseModel):
rows_json: str = "[]" rows_json: str = "[]"
total: int = 0 total: int = 0
+24 -6
View File
@@ -210,29 +210,47 @@ def _public_storage_grant_is_subset(
) -> bool: ) -> bool:
requested_tables = _public_storage_tables(requested_policies) requested_tables = _public_storage_tables(requested_policies)
granted_tables = _public_storage_tables(granted_policies) granted_tables = _public_storage_tables(granted_policies)
for table_name, granted_fields in granted_tables.items(): for table_name, granted_policy in granted_tables.items():
requested_fields = requested_tables.get(table_name) requested_policy = requested_tables.get(table_name)
if requested_fields is None or not granted_fields.issubset(requested_fields): if requested_policy is None:
return False
if not granted_policy["public_fields"].issubset(
requested_policy["public_fields"]
):
return False
requested_source_id_field = requested_policy["source_id_field"]
if (
requested_source_id_field
and granted_policy["source_id_field"] != requested_source_id_field
):
return False return False
return True return True
def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]: def _public_storage_tables(policies: list[Any] | None) -> dict[str, dict[str, Any]]:
tables: dict[str, set[str]] = {} tables: dict[str, dict[str, Any]] = {}
for policy in _policy_list(policies): for policy in _policy_list(policies):
if not isinstance(policy, dict): if not isinstance(policy, dict):
continue continue
table_name = policy.get("table_name") table_name = policy.get("table_name")
public_fields = policy.get("public_fields") public_fields = policy.get("public_fields")
source_id_field = policy.get("source_id_field")
if ( if (
not isinstance(table_name, str) not isinstance(table_name, str)
or table_name in tables or table_name in tables
or not isinstance(public_fields, list) or not isinstance(public_fields, list)
or (
source_id_field is not None
and (not isinstance(source_id_field, str) or not source_id_field)
)
): ):
continue continue
fields = {field for field in public_fields if isinstance(field, str) and field} fields = {field for field in public_fields if isinstance(field, str) and field}
if fields: if fields:
tables[table_name] = fields tables[table_name] = {
"public_fields": fields,
"source_id_field": source_id_field,
}
return tables return tables
+2
View File
@@ -549,6 +549,8 @@ window.localisation.en = {
extension_permission_ext_storage_append_public_sources: extension_permission_ext_storage_append_public_sources:
'Allowed append targets', 'Allowed append targets',
extension_permission_ext_storage_read_public: 'Read public extension storage', extension_permission_ext_storage_read_public: 'Read public extension storage',
extension_permission_ext_storage_read_public_source_required:
'required to read',
extension_permission_ext_storage_write: 'Write extension storage', extension_permission_ext_storage_write: 'Write extension storage',
extension_permission_ext_storage_read_write: 'Read & Write extension storage', extension_permission_ext_storage_read_write: 'Read & Write extension storage',
extension_permission_extension_api_request: 'Use other extensions', extension_permission_extension_api_request: 'Use other extensions',
@@ -174,7 +174,12 @@
: table.public_fields.filter( : table.public_fields.filter(
field => typeof field === 'string' && field field => typeof field === 'string' && field
) )
return tableName ? {table: tableName, fields} : null const sourceIdField =
typeof table === 'string' ||
typeof table?.source_id_field !== 'string'
? ''
: table.source_id_field
return tableName ? {table: tableName, fields, sourceIdField} : null
}) })
.filter(Boolean) .filter(Boolean)
} }
@@ -9,28 +9,31 @@
> >
<template v-slot:header> <template v-slot:header>
<q-item-section> <q-item-section>
<q-item-label class="text-weight-medium"> <div class="row items-center q-col-gutter-x-md q-row-gutter-sm">
<span v-text="permission.label"></span> <q-item-label
</q-item-label> class="text-weight-medium col-auto"
</q-item-section> style="max-width: 100%"
<q-item-section >
v-if="permission.risk.level !== 'low' || permission.badges.length" <span v-text="permission.label"></span>
side </q-item-label>
top <div
> v-if="permission.risk.level !== 'low' || permission.badges.length"
<div class="row items-center justify-end q-gutter-xs"> class="row items-center q-gutter-xs col-auto q-mb-xs"
<q-badge style="max-width: 100%"
v-for="badge of permission.badges" >
:key="badge.key" <q-badge
outline v-for="badge of permission.badges"
color="primary" :key="badge.key"
v-text="badge.label" outline
></q-badge> color="primary"
<q-badge v-text="badge.label"
v-if="permission.risk.level !== 'low'" ></q-badge>
:color="permission.risk.color" <q-badge
v-text="permission.risk.label" v-if="permission.risk.level !== 'low'"
></q-badge> :color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
</div>
</div> </div>
</q-item-section> </q-item-section>
</template> </template>
@@ -57,7 +60,24 @@
></p> ></p>
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md"> <ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
<li v-for="group of permission.fieldGroups" :key="group.table"> <li v-for="group of permission.fieldGroups" :key="group.table">
<span v-text="group.table"></span> <div class="row items-center q-gutter-xs">
<span v-text="group.table"></span>
<template v-if="group.sourceIdField">
<q-badge
color="warning"
text-color="dark"
v-text="group.sourceIdField"
></q-badge>
<span
class="text-caption text-grey"
v-text="
$t(
'extension_permission_ext_storage_read_public_source_required'
)
"
></span>
</template>
</div>
<ul v-if="group.fields.length" class="q-pl-md"> <ul v-if="group.fields.length" class="q-pl-md">
<li <li
v-for="field of group.fields" v-for="field of group.fields"
+88 -7
View File
@@ -12,7 +12,7 @@ from lnbits.core.wasm_ext.api.models import (
PayInvoiceRequest, PayInvoiceRequest,
StorageAppendPublicRequest, StorageAppendPublicRequest,
StorageGetRequest, StorageGetRequest,
StoragePaginatedRequest, StoragePublicPaginatedRequest,
WalletBalanceRequest, WalletBalanceRequest,
WebsocketPublishRequest, WebsocketPublishRequest,
) )
@@ -79,6 +79,7 @@ async def test_host_api_filters_public_paginated_storage_rows(
policies=[ policies=[
{ {
"table_name": "messages", "table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "thread_id", "message"], "public_fields": ["id", "thread_id", "message"],
} }
], ],
@@ -87,15 +88,16 @@ async def test_host_api_filters_public_paginated_storage_rows(
) )
response = await api.storage_get_public_paginated( response = await api.storage_get_public_paginated(
StoragePaginatedRequest( StoragePublicPaginatedRequest(
table="messages", table="messages",
filters={"thread_id": "thread-1"}, filters={},
search="hello", search="hello",
search_fields=["message"], search_fields=["message"],
sort_by="id", sort_by="id",
descending=False, descending=False,
limit=25, limit=25,
offset=0, offset=0,
source_id="thread-1",
) )
) )
@@ -103,11 +105,55 @@ async def test_host_api_filters_public_paginated_storage_rows(
{"id": "message-1", "thread_id": "thread-1", "message": "Hello"} {"id": "message-1", "thread_id": "thread-1", "message": "Hello"}
] ]
assert response.total == 1 assert response.total == 1
storage_mock.assert_awaited_once() storage_mock.assert_awaited_once_with(
"demoext",
"messages",
{"thread_id": "thread-1"},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_private_query_fields(): async def test_host_api_public_paginated_storage_rejects_private_query_fields():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="non-public fields"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"admin_note": "secret"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_requires_source_policy():
api = ExtensionHostAPI( api = ExtensionHostAPI(
"demoext", "demoext",
[ [
@@ -123,17 +169,52 @@ async def test_host_api_public_paginated_storage_rejects_private_query_fields():
], ],
) )
with pytest.raises(PermissionError, match="non-public fields"): with pytest.raises(PermissionError, match="source ID field policy"):
await api.storage_get_public_paginated( await api.storage_get_public_paginated(
StoragePaginatedRequest( StoragePublicPaginatedRequest(
table="messages", table="messages",
filters={"admin_note": "secret"}, filters={},
search=None, search=None,
search_fields=[], search_fields=[],
sort_by=None, sort_by=None,
descending=False, descending=False,
limit=25, limit=25,
offset=0, offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_conflicting_source_filter():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="does not match source_id"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"thread_id": "thread-2"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
) )
) )
@@ -65,6 +65,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
"policies": [ "policies": [
{ {
"table_name": "tip_jars", "table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title", "description"], "public_fields": ["id", "title", "description"],
} }
], ],
@@ -80,6 +81,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
policies=[ policies=[
{ {
"table_name": "tip_jars", "table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title"], "public_fields": ["id", "title"],
} }
], ],
@@ -95,6 +97,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
policies=[ policies=[
{ {
"table_name": "tip_jars", "table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title"], "public_fields": ["id", "title"],
} }
], ],
@@ -102,6 +105,42 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
] ]
def test_validate_wasm_permissions_rejects_public_read_source_field_omission():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.read_public",
"policies": [
{
"table_name": "messages",
"source_id_field": "conversation_id",
"public_fields": ["id", "body"],
}
],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "body"],
}
],
)
],
extension_config,
)
def test_validate_wasm_permissions_allows_narrower_public_append_grant(): def test_validate_wasm_permissions_allows_narrower_public_append_grant():
ext_info = make_installable_extension("demoext") ext_info = make_installable_extension("demoext")
extension_config = _wasm_config( extension_config = _wasm_config(