Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions src/Asset/Asset.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,37 @@ export class Asset extends Model<AssetAttributes> {
return counts[0].count > 0
}

static findByIds(ids: string[]) {
return this.query<AssetAttributes>(SQL`
SELECT *
static findByIds(ids: string[], ethAddresses?: string[]) {
const query = SQL`
SELECT a.* FROM ${SQL.raw(this.tableName)} a`

if (ethAddresses) {
query.append(SQL`
INNER JOIN ${SQL.raw(AssetPack.tableName)} ap ON a.asset_pack_id = ap.id
WHERE a.id = ANY(${ids})
AND ap.eth_address = ANY(${ethAddresses})
AND ap.is_deleted = FALSE`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the is_deleted condition here and not in the original query?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, will add it to the original query, as we don't want to show deleted assets even using the id.

} else {
query.append(SQL`
INNER JOIN ${SQL.raw(AssetPack.tableName)} ap ON a.asset_pack_id = ap.id
WHERE a.id = ANY(${ids})
AND ap.is_deleted = FALSE`)
}

return this.query<AssetAttributes>(query)
}

static async existsAnyWithADifferentAssetPackId(
ids: string[],
assetPackId: string
): Promise<boolean> {
const counts = await this.query(SQL`
SELECT COUNT(*) as count
FROM ${SQL.raw(this.tableName)}
WHERE id = ANY(${ids})`)
WHERE id = ANY(${ids})
AND asset_pack_id != ${assetPackId}`)

return counts[0].count > 0
}

static findByAssetPackId(assetPackId: string, limit: number | null = null) {
Expand Down
149 changes: 149 additions & 0 deletions src/Asset/Asset.router.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { ExpressApp } from '../common/ExpressApp'
import { AssetRouter } from './Asset.router'
import { Asset } from './Asset.model'
import { getDefaultEthAddress } from '../AssetPack/utils'

jest.mock('./Asset.model')
jest.mock('../AssetPack/utils')

const anOwnerAddress = 'anOwnerAddress'

const anAsset = {
id: 'anAssetId',
asset_pack_id: 'anAssetPackId',
name: 'anAsset',
model: 'model.glb',
category: 'decorations',
contents: {},
tags: [],
metrics: {
triangles: 0,
materials: 0,
textures: 0,
meshes: 0,
bodies: 0,
entities: 0,
},
}

const anotherAsset = {
id: 'anotherAssetId',
asset_pack_id: 'anotherAssetPackId',
name: 'anotherAsset',
model: 'another.glb',
category: 'decorations',
contents: {},
tags: [],
metrics: {
triangles: 0,
materials: 0,
textures: 0,
meshes: 0,
bodies: 0,
entities: 0,
},
}

describe('Asset router', () => {
let router: AssetRouter
let req: {
params: Record<string, string>
query: Record<string, unknown>
auth: { ethAddress: string }
}

beforeEach(() => {
router = new AssetRouter(new ExpressApp())
req = { params: {}, query: {}, auth: { ethAddress: anOwnerAddress } }
})

afterEach(() => {
jest.resetAllMocks()
})

describe('when getting a single asset', () => {
beforeEach(() => {
req.params = { id: anAsset.id }
})

describe('when the asset belongs to the caller', () => {
beforeEach(() => {
;(Asset.findByIds as jest.Mock).mockResolvedValueOnce([anAsset])
})

it('should call findByIds with the caller and default addresses', async () => {
await (router as any).getAsset(req)
expect(Asset.findByIds).toHaveBeenCalledWith(
[anAsset.id],
[anOwnerAddress, getDefaultEthAddress()]
)
})

it('should return the asset', async () => {
const result = await (router as any).getAsset(req)
expect(result).toEqual(anAsset)
})
})

describe('when the asset does not belong to the caller or the default address', () => {
beforeEach(() => {
;(Asset.findByIds as jest.Mock).mockResolvedValueOnce([])
})

it('should return null', async () => {
const result = await (router as any).getAsset(req)
expect(result).toBeNull()
})
})
})

describe('when getting multiple assets', () => {
beforeEach(() => {
req.query = { id: [anAsset.id, anotherAsset.id] }
})

describe('when all assets belong to the caller or the default address', () => {
beforeEach(() => {
;(Asset.findByIds as jest.Mock).mockResolvedValueOnce([
anAsset,
anotherAsset,
])
})

it('should call findByIds with the caller and default addresses', async () => {
await (router as any).getAssets(req)
expect(Asset.findByIds).toHaveBeenCalledWith(
[anAsset.id, anotherAsset.id],
[anOwnerAddress, getDefaultEthAddress()]
)
})

it('should return all visible assets', async () => {
const result = await (router as any).getAssets(req)
expect(result).toEqual([anAsset, anotherAsset])
})
})

describe('when some assets belong to another user', () => {
beforeEach(() => {
;(Asset.findByIds as jest.Mock).mockResolvedValueOnce([anAsset])
})

it('should return only the visible assets', async () => {
const result = await (router as any).getAssets(req)
expect(result).toEqual([anAsset])
})
})

describe('when all assets belong to another user', () => {
beforeEach(() => {
;(Asset.findByIds as jest.Mock).mockResolvedValueOnce([])
})

it('should return an empty array', async () => {
const result = await (router as any).getAssets(req)
expect(result).toEqual([])
})
})
})
})
27 changes: 21 additions & 6 deletions src/Asset/Asset.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
} from '../middleware'
import { getUploader, S3Content } from '../S3'
import { AssetPack } from '../AssetPack'
import { getDefaultEthAddress } from '../AssetPack/utils'
import { Asset } from './Asset.model'
import { withAuthentication } from '../middleware/authentication'
import { withAuthentication, AuthRequest } from '../middleware/authentication'

const DEFAULT_ETH_ADDRESS = getDefaultEthAddress()

export class AssetRouter extends Router {
mount() {
Expand Down Expand Up @@ -54,14 +57,20 @@
this.router.get(
'/assets/:id',
withCors,
withAuthentication,

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 4 days ago

In general, the fix is to introduce a rate-limiting middleware (for example, using express-rate-limit) and apply it to the sensitive routes that perform authentication/authorization and database access. This middleware should be placed in the route definition before server.handleRequest(...) so that obviously abusive traffic is rejected early, without hitting the application logic or database.

In this file, the best approach without changing existing functionality is:

  • Import a rate-limiting middleware implementation (e.g., express-rate-limit).
  • Define a limiter instance configured for these asset routes (for example, allowing a reasonable number of requests per 15-minute window).
  • Apply this limiter to the /assets/:id and /assets GET routes by inserting it into the middleware chain after withCors and withAuthentication but before withAssetExists / server.handleRequest(...).
    This preserves existing behavior, adds only rejection in case of too many requests, and ensures that authenticated users are each rate-limited according to the configuration (or, if using IP-based default, at least globally throttled).

Concretely, within src/Asset/Asset.router.ts:

  • Add import rateLimit from 'express-rate-limit' to the import section.
  • Inside mount(), define a const assetRateLimiter = rateLimit({...}).
  • Update the two this.router.get(...) blocks for /assets/:id and /assets to include assetRateLimiter in their middleware lists.
Suggested changeset 2
src/Asset/Asset.router.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/Asset/Asset.router.ts b/src/Asset/Asset.router.ts
--- a/src/Asset/Asset.router.ts
+++ b/src/Asset/Asset.router.ts
@@ -13,6 +13,7 @@
 import { getDefaultEthAddress } from '../AssetPack/utils'
 import { Asset } from './Asset.model'
 import { withAuthentication, AuthRequest } from '../middleware/authentication'
+import rateLimit from 'express-rate-limit'
 
 const DEFAULT_ETH_ADDRESS = getDefaultEthAddress()
 
@@ -24,6 +25,10 @@
       AssetPack,
       'assetPackId'
     )
+    const assetRateLimiter = rateLimit({
+      windowMs: 15 * 60 * 1000, // 15 minutes
+      max: 100, // limit each IP to 100 requests per windowMs for asset routes
+    })
 
     /**
      * CORS for the OPTIONS header
@@ -58,6 +63,7 @@
       '/assets/:id',
       withCors,
       withAuthentication,
+      assetRateLimiter,
       withAssetExists,
       server.handleRequest(this.getAsset)
     )
@@ -69,6 +75,7 @@
       '/assets',
       withCors,
       withAuthentication,
+      assetRateLimiter,
       server.handleRequest(this.getAssets)
     )
   }
EOF
@@ -13,6 +13,7 @@
import { getDefaultEthAddress } from '../AssetPack/utils'
import { Asset } from './Asset.model'
import { withAuthentication, AuthRequest } from '../middleware/authentication'
import rateLimit from 'express-rate-limit'

const DEFAULT_ETH_ADDRESS = getDefaultEthAddress()

@@ -24,6 +25,10 @@
AssetPack,
'assetPackId'
)
const assetRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs for asset routes
})

/**
* CORS for the OPTIONS header
@@ -58,6 +63,7 @@
'/assets/:id',
withCors,
withAuthentication,
assetRateLimiter,
withAssetExists,
server.handleRequest(this.getAsset)
)
@@ -69,6 +75,7 @@
'/assets',
withCors,
withAuthentication,
assetRateLimiter,
server.handleRequest(this.getAssets)
)
}
package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -73,7 +73,8 @@
     "prom-client": "^13.1.0",
     "pull-stream": "^3.6.12",
     "tslint": "^6.1.3",
-    "uuid": "^8.3.2"
+    "uuid": "^8.3.2",
+    "express-rate-limit": "^8.3.1"
   },
   "devDependencies": {
     "@swc/core": "^1.2.102",
EOF
@@ -73,7 +73,8 @@
"prom-client": "^13.1.0",
"pull-stream": "^3.6.12",
"tslint": "^6.1.3",
"uuid": "^8.3.2"
"uuid": "^8.3.2",
"express-rate-limit": "^8.3.1"
},
"devDependencies": {
"@swc/core": "^1.2.102",
This fix introduces these dependencies
Package Version Security advisories
express-rate-limit (npm) 8.3.1 None
Copilot is powered by AI and may make mistakes. Always verify output.
withAssetExists,
server.handleRequest(this.getAsset)
)

/**
* Get a multiple assets
*/
this.router.get('/assets', withCors, server.handleRequest(this.getAssets))
this.router.get(
'/assets',
withCors,
withAuthentication,

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 4 days ago

In general, to fix missing rate limiting on an Express route, you add a rate-limiting middleware (for example using express-rate-limit) and plug it into the handler chain before the expensive operation. This limits how many requests a client can make to that endpoint in a given time window, mitigating denial-of-service risks.

For this specific code, the minimal, non-breaking fix is to (1) import express-rate-limit, (2) define a rate limiter instance suitable for the /assets listing endpoint, and (3) insert that limiter middleware into the /assets route before server.handleRequest(this.getAssets). To avoid altering other routes’ behavior, we will only apply the limiter to the /assets route, not globally. Concretely: at the top of src/Asset/Asset.router.ts we add an import for express-rate-limit. Inside mount(), before defining the routes, we create a const assetsRateLimiter = rateLimit({ ... }) with reasonable defaults, e.g., limiting requests per IP for that route. Then, in the .get('/assets', ...) handler chain, we add assetsRateLimiter between withAuthentication and server.handleRequest(this.getAssets). No existing functions need to change their signatures or logic; we just enrich the middleware chain.

Suggested changeset 2
src/Asset/Asset.router.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/Asset/Asset.router.ts b/src/Asset/Asset.router.ts
--- a/src/Asset/Asset.router.ts
+++ b/src/Asset/Asset.router.ts
@@ -13,6 +13,7 @@
 import { getDefaultEthAddress } from '../AssetPack/utils'
 import { Asset } from './Asset.model'
 import { withAuthentication, AuthRequest } from '../middleware/authentication'
+import rateLimit from 'express-rate-limit'
 
 const DEFAULT_ETH_ADDRESS = getDefaultEthAddress()
 
@@ -24,6 +25,10 @@
       AssetPack,
       'assetPackId'
     )
+    const assetsRateLimiter = rateLimit({
+      windowMs: 15 * 60 * 1000, // 15 minutes
+      max: 100, // limit each IP to 100 requests per windowMs for /assets
+    })
 
     /**
      * CORS for the OPTIONS header
@@ -69,6 +74,7 @@
       '/assets',
       withCors,
       withAuthentication,
+      assetsRateLimiter,
       server.handleRequest(this.getAssets)
     )
   }
EOF
@@ -13,6 +13,7 @@
import { getDefaultEthAddress } from '../AssetPack/utils'
import { Asset } from './Asset.model'
import { withAuthentication, AuthRequest } from '../middleware/authentication'
import rateLimit from 'express-rate-limit'

const DEFAULT_ETH_ADDRESS = getDefaultEthAddress()

@@ -24,6 +25,10 @@
AssetPack,
'assetPackId'
)
const assetsRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs for /assets
})

/**
* CORS for the OPTIONS header
@@ -69,6 +74,7 @@
'/assets',
withCors,
withAuthentication,
assetsRateLimiter,
server.handleRequest(this.getAssets)
)
}
package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -73,7 +73,8 @@
     "prom-client": "^13.1.0",
     "pull-stream": "^3.6.12",
     "tslint": "^6.1.3",
-    "uuid": "^8.3.2"
+    "uuid": "^8.3.2",
+    "express-rate-limit": "^8.3.1"
   },
   "devDependencies": {
     "@swc/core": "^1.2.102",
EOF
@@ -73,7 +73,8 @@
"prom-client": "^13.1.0",
"pull-stream": "^3.6.12",
"tslint": "^6.1.3",
"uuid": "^8.3.2"
"uuid": "^8.3.2",
"express-rate-limit": "^8.3.1"
},
"devDependencies": {
"@swc/core": "^1.2.102",
This fix introduces these dependencies
Package Version Security advisories
express-rate-limit (npm) 8.3.1 None
Copilot is powered by AI and may make mistakes. Always verify output.
server.handleRequest(this.getAssets)
)
}

async assetBelongsToPackMiddleware(req: Request) {
Expand All @@ -80,14 +89,20 @@
// This handler is only here so `server.handleRequest` has a valid callback and it can return the appropiate formated response
}

private getAsset(req: Request) {
private async getAsset(req: AuthRequest) {
const id = server.extractFromReq(req, 'id')
return Asset.findOne(id)
const eth_address = req.auth.ethAddress
const [asset] = await Asset.findByIds(
[id],
[eth_address, DEFAULT_ETH_ADDRESS]
)
return asset ?? null
}

private getAssets(req: Request) {
private async getAssets(req: AuthRequest) {
const reqIds = server.extractFromReq<string | string[]>(req, 'id')
const ids: string[] = Array.isArray(reqIds) ? reqIds : [reqIds]
return Asset.findByIds(ids)
const eth_address = req.auth.ethAddress
return Asset.findByIds(ids, [eth_address, DEFAULT_ETH_ADDRESS])
}
}
83 changes: 83 additions & 0 deletions src/AssetPack/AssetPack.router.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { ILoggerComponent } from '@well-known-components/interfaces'
import { ExpressApp } from '../common/ExpressApp'
import { AssetPackRouter } from './AssetPack.router'
import { AssetPack } from './AssetPack.model'
import { Asset } from '../Asset'
import { getDefaultEthAddress } from './utils'

jest.mock('./AssetPack.model')
jest.mock('../Asset/Asset.model')

const anAssetPack = {
id: 'anId',
Expand Down Expand Up @@ -375,4 +377,85 @@ describe('AssetPack router', () => {
})
})
})

describe('when upserting an asset pack', () => {
const anAssetPackId = '8c251928-fb34-40e9-86d2-868a60d2fa78'
const anotherAssetPackId = '49c9ae13-6779-4ced-b208-dd352c9b7541'
const anOwnerAddress = 'anOwnerAddress'
let upsertReq: any
let assetUpsertSpy: jest.SpyInstance
let upsertedAssetAttrs: any[]

beforeEach(() => {
upsertedAssetAttrs = []
const mockUpsert = jest.fn().mockResolvedValue({})
;((Asset as unknown) as jest.Mock).mockImplementation((attrs: any) => {
upsertedAssetAttrs.push(attrs)
return { upsert: mockUpsert, attributes: attrs }
})
assetUpsertSpy = mockUpsert
;(AssetPack.count as jest.Mock).mockResolvedValue(0)
;(Asset.existsAnyWithADifferentEthAddress as jest.Mock).mockResolvedValue(
false
)
;(Asset.existsAnyWithADifferentAssetPackId as jest.Mock).mockResolvedValue(
false
)
;(AssetPack.findOneWithAssets as jest.Mock).mockResolvedValue(null)
;((AssetPack as unknown) as jest.Mock).mockImplementation(
(attrs: any) => ({
upsert: jest.fn().mockResolvedValue(attrs),
attributes: attrs,
})
)
})

describe('when an asset already belongs to a different asset pack', () => {
beforeEach(() => {
;(Asset.existsAnyWithADifferentAssetPackId as jest.Mock).mockResolvedValue(
true
)
upsertReq = {
params: { id: anAssetPackId },
body: {
assetPack: {
id: anAssetPackId,
title: 'an-asset-pack',
assets: [
{
id: '1e27cbda-5582-4219-8f83-2db817344cc1',
asset_pack_id: anotherAssetPackId,
name: 'an-asset',
model: 'model.glb',
category: 'decorations',
contents: {},
tags: ['test'],
metrics: {
triangles: 0,
materials: 0,
textures: 0,
meshes: 0,
bodies: 0,
entities: 0,
},
},
],
},
},
auth: { ethAddress: anOwnerAddress },
}
})

it('should throw an error', async () => {
await expect(router.upsertAssetPack(upsertReq)).rejects.toThrow(
"One of the assets you're trying to upload is invalid"
)
})

it('should not upsert any assets', async () => {
await expect(router.upsertAssetPack(upsertReq)).rejects.toThrow()
expect(assetUpsertSpy).not.toHaveBeenCalled()
})
})
})
})
13 changes: 11 additions & 2 deletions src/AssetPack/AssetPack.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,17 @@ export class AssetPackRouter extends Router {
const assetIds = assets.map((asset) => asset.id)
if (await Asset.existsAnyWithADifferentEthAddress(assetIds, eth_address)) {
throw new HTTPError(
"One of the assets you're trying to upload belongs to a different address. Check the ids",
{ eth_address, assetIds }
"One of the assets you're trying to upload is invalid",
{},
STATUS_CODES.badRequest
)
}

if (await Asset.existsAnyWithADifferentAssetPackId(assetIds, id)) {
throw new HTTPError(
"One of the assets you're trying to upload is invalid",
{},
STATUS_CODES.badRequest
)
}

Expand Down
Loading