Skip to content

Commit 4281c06

Browse files
committed
fix: handle CORS in contract verify API (#324)
1 parent 8230647 commit 4281c06

File tree

10 files changed

+366
-54
lines changed

10 files changed

+366
-54
lines changed

apps/contract-verification/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,30 @@ and [/apps/contract-verification/scripts/medium-verify.sh](./scripts/medium-veri
5252
#### Direct API Usage
5353

5454
- Standard JSON: see [/apps/contract-verification/scripts/verify-with-curl.sh](./scripts/verify-with-curl.sh) for a full example.
55+
56+
### Development
57+
58+
#### Prerequisites
59+
60+
- A container runtime (e.g., [OrbStack](https://docs.orbstack.dev), [Colima](https://github.com/abiosoft/colima), Docker Desktop)
61+
62+
```sh
63+
cp .env.example .env # Copy example environment variables
64+
pnpm install # Install dependencies
65+
pnpm dev # Start development server
66+
```
67+
68+
Once dev server is running, you can run scripts in the [/apps/contract-verification/scripts](./scripts) directory to populate your local database with verified contracts.
69+
70+
#### Database
71+
72+
We use [D1](https://developers.cloudflare.com/d1), a serverless SQLite-compatible database by Cloudflare.
73+
Sometimes you need to debug the local database in development,
74+
especially when it's SQLite. Sadly, wrangler doesn't have a nice way to do this.
75+
76+
Improvised solution: we [locate the local SQLite file wrangler uses](/apps/contract-verification/scripts/local-d1.sh) and run drizzle-kit studio to view it.
77+
78+
| environment | database | dialect | GUI |
79+
|-------------|----------|----------|-----|
80+
| production | Cloudflare D1 | SQLite | [DrizzleKit Studio](https://github.com/drizzle-team/drizzle-studio) |
81+
| development | Local SQLite | SQLite | [Cloudflare Dashboard](https://dash.cloudflare.com/) |

apps/contract-verification/env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
interface EnvironmentVariables {
22
readonly PORT: string
33

4+
readonly WHITELISTED_ORIGINS: string
45
readonly VITE_LOG_LEVEL: 'info' | 'warn' | 'silent'
56

67
readonly CLOUDFLARE_ACCOUNT_ID: string

apps/contract-verification/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"cbor-x": "^1.6.0",
2828
"drizzle-orm": "^0.45.1",
2929
"hono": "catalog:",
30+
"hono-rate-limiter": "catalog:",
3031
"ox": "catalog:",
3132
"semver": "^7.7.3",
3233
"tempo.ts": "catalog:",
@@ -39,13 +40,14 @@
3940
"@libsql/client": "^0.15.15",
4041
"@scalar/api-reference": "catalog:",
4142
"@total-typescript/ts-reset": "catalog:",
42-
"@types/bun": "^1.3.4",
43+
"@types/bun": "^1.3.5",
4344
"@types/node": "catalog:",
4445
"@types/semver": "^7.7.1",
4546
"dbmate": "^2.28.0",
4647
"drizzle-kit": "^0.31.8",
4748
"typescript": "catalog:",
4849
"vite": "catalog:",
50+
"vite-plugin-devtools-json": "catalog:",
4951
"wrangler": "catalog:"
5052
},
5153
"license": "MIT"

apps/contract-verification/src/chains.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { tempoDevnet, tempoTestnet } from 'tempo.ts/chains'
1+
import { tempoDevnet, tempoTestnet } from 'viem/chains'
22

33
export const DEVNET_CHAIN_ID = tempoDevnet.id
44
export const TESTNET_CHAIN_ID = tempoTestnet.id
@@ -11,22 +11,18 @@ export const chains = {
1111
// matches https://sourcify.dev/server/chains format
1212
export const sourcifyChains = [tempoDevnet, tempoTestnet].map((chain) => {
1313
const returnValue = {
14-
name: chain().name,
15-
title: chain().name,
16-
chainId: chain().id,
17-
rpc: [
18-
chain().rpcUrls.default.http,
19-
chain().rpcUrls.default.webSocket,
20-
].flat(),
14+
name: chain.name,
15+
title: chain.name,
16+
chainId: chain.id,
17+
rpc: [chain.rpcUrls.default.http, chain.rpcUrls.default.webSocket].flat(),
2118
supported: true,
2219
etherscanAPI: false,
2320
_extra: {},
2421
}
25-
// @ts-expect-error
26-
if (chain()?.blockExplorers)
22+
23+
if (chain?.blockExplorers)
2724
returnValue._extra = {
28-
// @ts-expect-error
29-
blockExplorer: chain()?.blockExplorers.default,
25+
blockExplorer: chain?.blockExplorers.default,
3026
}
3127

3228
return returnValue

apps/contract-verification/src/index.tsx

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { env } from 'cloudflare:workers'
12
import { getContainer } from '@cloudflare/containers'
2-
import { Hono } from 'hono'
3+
import { bodyLimit } from 'hono/body-limit'
4+
import { cors } from 'hono/cors'
35
import { showRoutes } from 'hono/dev'
6+
import { createFactory } from 'hono/factory'
47
import { prettyJSON } from 'hono/pretty-json'
58
import { requestId } from 'hono/request-id'
69
import { timeout } from 'hono/timeout'
7-
10+
import { rateLimiter } from 'hono-rate-limiter'
811
import { sourcifyChains } from '#chains.ts'
912
import { VerificationContainer } from '#container.ts'
1013
import OpenApiSpec from '#openapi.json' with { type: 'json' }
@@ -13,24 +16,67 @@ import { docsRoute } from '#route.docs.tsx'
1316
import { lookupAllChainContractsRoute, lookupRoute } from '#route.lookup.ts'
1417
import { verifyRoute } from '#route.verify.ts'
1518
import { legacyVerifyRoute } from '#route.verify-legacy.ts'
19+
import { originMatches } from '#utilities.ts'
1620

1721
export { VerificationContainer }
1822

19-
/**
20-
* TODO:
21-
* - CORS,
22-
* - Cache,
23-
* - Security
24-
* - Rate limiting,
25-
*/
23+
const WHITELISTED_ORIGINS = [
24+
'http://localhost',
25+
'https://*.ts.net', // `tailscale funnel`
26+
...(env.WHITELISTED_ORIGINS.split(',') ?? []),
27+
]
2628

27-
const app = new Hono<{ Bindings: Cloudflare.Env }>()
29+
type AppEnv = { Bindings: Cloudflare.Env }
30+
const factory = createFactory<AppEnv>()
31+
const app = factory.createApp()
2832

2933
// @note: order matters
30-
app.use('*', requestId({ headerName: 'X-Tempo-Request-Id' }))
31-
// TODO: update before merging to main
32-
app.use('*', timeout(20_000)) // 20 seconds
33-
app.use(prettyJSON())
34+
app
35+
.use('*', requestId({ headerName: 'X-Tempo-Request-Id' }))
36+
.use(
37+
cors({
38+
allowMethods: ['GET', 'POST', 'OPTIONS', 'HEAD'],
39+
origin: (origin, _) => {
40+
return WHITELISTED_ORIGINS.some((p) =>
41+
originMatches({ origin, pattern: p }),
42+
)
43+
? origin
44+
: null
45+
},
46+
}),
47+
)
48+
.use(
49+
rateLimiter<AppEnv>({
50+
binding: (context) => context.env.RATE_LIMITER,
51+
keyGenerator: (context) =>
52+
(context.req.header('X-Real-IP') ??
53+
context.req.header('CF-Connecting-IP') ??
54+
context.req.header('X-Forwarded-For')) ||
55+
'',
56+
skip: (context) =>
57+
WHITELISTED_ORIGINS.some((p) =>
58+
originMatches({
59+
origin: new URL(context.req.url).hostname,
60+
pattern: p,
61+
}),
62+
),
63+
message: { error: 'Rate limit exceeded', retryAfter: '60s' },
64+
}),
65+
)
66+
.use(bodyLimit({ maxSize: 2 * 10_24 })) // 1mb
67+
.use('*', timeout(12_000)) // 12 seconds
68+
.use(prettyJSON())
69+
.use(async (context, next) => {
70+
if (context.env.NODE_ENV !== 'development') return await next()
71+
const baseLogMessage = `${context.get('requestId')}-[${context.req.method}] ${context.req.path}`
72+
if (context.req.method === 'GET') {
73+
console.info(`${baseLogMessage}\n`)
74+
return await next()
75+
}
76+
const body = await context.req.text()
77+
console.info(`${baseLogMessage} \n${body}\n`)
78+
return await next()
79+
})
3480

3581
app.route('/docs', docsRoute)
3682
app.route('/verify', legacyVerifyRoute)
@@ -60,12 +106,6 @@ app
60106
),
61107
)
62108

63-
app.use('*', async (context, next) => {
64-
if (context.env.NODE_ENV !== 'development') return await next()
65-
console.info(`[${context.req.method}] ${context.req.path}`)
66-
await next()
67-
})
68-
69109
showRoutes(app)
70110

71111
export default app satisfies ExportedHandler<Cloudflare.Env>

apps/contract-verification/src/utilities.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { env } from 'cloudflare:workers'
12
import type { Context } from 'hono'
23
import type { ContentfulStatusCode } from 'hono/utils/http-status'
34

@@ -37,3 +38,30 @@ export function sourcifyError(
3738
status,
3839
)
3940
}
41+
42+
/**
43+
* Checks if an origin matches an allowed hostname pattern.
44+
* pathname and search parameters are ignored
45+
*/
46+
export function originMatches(params: { origin: string; pattern: string }) {
47+
if (env.NODE_ENV === 'development') return true
48+
49+
const { pattern } = params
50+
51+
if (!params.origin) return false
52+
let origin: string
53+
54+
try {
55+
const stripExtra = new URL(params.origin)
56+
origin = `${stripExtra.protocol}//${stripExtra.hostname}`
57+
} catch {
58+
return false
59+
}
60+
61+
if (origin === pattern) return true
62+
if (!pattern.includes('*')) return false
63+
64+
return new RegExp(
65+
`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`,
66+
).test(origin)
67+
}

apps/contract-verification/vite.config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import NodeChildProcess from 'node:child_process'
22
import NodeProcess from 'node:process'
33
import { cloudflare } from '@cloudflare/vite-plugin'
44
import { defineConfig, loadEnv } from 'vite'
5+
import vitePluginChromiumDevTools from 'vite-plugin-devtools-json'
56

67
const commitSha =
78
NodeChildProcess.execSync('git rev-parse --short HEAD').toString().trim() ||
@@ -19,10 +20,11 @@ export default defineConfig((config) => {
1920
const port = Number(lastPort ?? env.PORT ?? 3_000)
2021

2122
return {
22-
plugins: [cloudflare()],
23+
plugins: [cloudflare(), vitePluginChromiumDevTools()],
2324
server: {
2425
port,
25-
cors: config.mode === 'development' ? true : undefined,
26+
// https://hono.dev/docs/middleware/builtin/cors#using-with-vite
27+
cors: false,
2628
allowedHosts: config.mode === 'development' ? true : undefined,
2729
},
2830
define: {

apps/contract-verification/wrangler.jsonc

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@
77
"keep_vars": true,
88
"workers_dev": true,
99
"preview_urls": true,
10+
"vars": {
11+
// in addition to localhost
12+
"WHITELISTED_ORIGINS": "https://tempo.xyz,https://*.tempo.xyz,https://*.porto.workers.dev"
13+
},
14+
"ratelimits": [
15+
{
16+
"name": "RATE_LIMITER",
17+
"namespace_id": "1001",
18+
"simple": {
19+
"limit": 10,
20+
"period": 60
21+
}
22+
}
23+
],
1024
"d1_databases": [
1125
{
1226
"binding": "CONTRACTS_DB",
@@ -39,9 +53,9 @@
3953
],
4054
"observability": {
4155
"enabled": true,
56+
"head_sampling_rate": 1,
4257
"logs": {
4358
"enabled": true,
44-
"persist": true,
4559
"head_sampling_rate": 1,
4660
"invocation_logs": true
4761
}

0 commit comments

Comments
 (0)