From c277ae86973f91ce84b57e3a9fef4e780140d3ba Mon Sep 17 00:00:00 2001 From: im10furry Date: Sun, 5 Jul 2026 23:21:17 -0500 Subject: [PATCH] feat: add database module and harden registry --- AGENTS.md | 9 +- AGENTS_zh.md | 9 +- README.md | 4 +- README_zh.md | 4 +- docs/.vitepress/config.ts | 2 + docs/contributing.md | 2 +- docs/index.md | 4 + docs/modules/database.md | 91 +++++++ docs/modules/index.md | 3 +- docs/zh/contributing.md | 2 +- docs/zh/index.md | 4 + docs/zh/modules/database.md | 91 +++++++ docs/zh/modules/index.md | 3 +- internal/bundle/manifest.json | 166 +++++++++--- internal/bundle/registry.zip | Bin 663175 -> 720620 bytes registry/auth/src/go/handlers.go | 5 +- registry/auth/src/go/handlers_test.go | 12 +- registry/crud/src/go/handlers.go | 60 +++-- registry/crud/src/go/handlers_test.go | 56 ++++- registry/crud/src/go/pentest_test.go | 22 ++ registry/database/README.md | 112 +++++++++ registry/database/__llms__.md | 3 + registry/database/src/go/config.go | 221 ++++++++++++++++ registry/database/src/go/config_test.go | 131 ++++++++++ registry/database/src/go/driver_test.go | 136 ++++++++++ registry/database/src/go/go.mod | 3 + registry/database/src/go/open.go | 38 +++ registry/database/src/go/open_test.go | 88 +++++++ registry/database/src/go/pentest_test.go | 107 ++++++++ registry/database/src/go/profile.go | 110 ++++++++ registry/database/src/go/profile_test.go | 82 ++++++ registry/database/src/go/query.go | 251 +++++++++++++++++++ registry/database/src/go/query_test.go | 220 ++++++++++++++++ registry/database/src/go/transaction.go | 62 +++++ registry/database/src/go/transaction_test.go | 92 +++++++ registry/file-upload/src/go/storage.go | 87 ++++++- registry/file-upload/src/go/storage_test.go | 32 +++ registry/index.json | 19 +- registry/middleware/src/go/config.go | 3 +- registry/middleware/src/go/proxy.go | 17 +- registry/middleware/src/go/proxy_test.go | 34 ++- registry/rbac/src/go/manager.go | 20 ++ registry/rbac/src/go/manager_test.go | 46 ++++ 43 files changed, 2387 insertions(+), 76 deletions(-) create mode 100644 docs/modules/database.md create mode 100644 docs/zh/modules/database.md create mode 100644 registry/database/README.md create mode 100644 registry/database/__llms__.md create mode 100644 registry/database/src/go/config.go create mode 100644 registry/database/src/go/config_test.go create mode 100644 registry/database/src/go/driver_test.go create mode 100644 registry/database/src/go/go.mod create mode 100644 registry/database/src/go/open.go create mode 100644 registry/database/src/go/open_test.go create mode 100644 registry/database/src/go/pentest_test.go create mode 100644 registry/database/src/go/profile.go create mode 100644 registry/database/src/go/profile_test.go create mode 100644 registry/database/src/go/query.go create mode 100644 registry/database/src/go/query_test.go create mode 100644 registry/database/src/go/transaction.go create mode 100644 registry/database/src/go/transaction_test.go diff --git a/AGENTS.md b/AGENTS.md index 7e63984..3b50ad8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ ## Project Description -Scion is a copy-paste code library for Go backend development. It contains 11 self-contained modules in `registry/` — each is a standalone Go package. Modules are standard-library only by default; security-sensitive modules may be declared as `stdlibOnly:false` in `registry/index.json` and copied in standalone mode. Modules are meant to be copied into a user's project and adapted, not imported as a dependency. +Scion is a copy-paste code library for Go backend development. It contains 12 self-contained modules in `registry/` — each is a standalone Go package. Modules are standard-library only by default; security-sensitive modules may be declared as `stdlibOnly:false` in `registry/index.json` and copied in standalone mode. Modules are meant to be copied into a user's project and adapted, not imported as a dependency. ## Coding Standards @@ -63,7 +63,7 @@ cd registry//src/go && go test -v -count=1 ./... ```bash # PowerShell -$modules = @('middleware','auth','crud','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') +$modules = @('middleware','auth','crud','database','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Pop-Location } ``` @@ -89,7 +89,7 @@ You are working on Scion, a copy-paste code library for Go backend development. Project location: Architecture: -- 11 modules in registry/ — each is a standalone Go package +- 12 modules in registry/ — each is a standalone Go package - Module path pattern: registry//src/go/ - Go 1.22+, standard library by default, gofmt mandatory @@ -125,9 +125,10 @@ Task: ``` scion/ -├── registry/ # 11 copy-paste modules +├── registry/ # 12 copy-paste modules │ ├── auth/ # JWT auth + bcrypt │ ├── crud/ # Generic CRUD + pagination +│ ├── database/ # database/sql helpers │ ├── middleware/ # 9 HTTP middlewares │ ├── rbac/ # Role-based access control │ ├── ratelimit/ # 3 rate limiting algorithms diff --git a/AGENTS_zh.md b/AGENTS_zh.md index 0a1976c..fe30e47 100644 --- a/AGENTS_zh.md +++ b/AGENTS_zh.md @@ -6,7 +6,7 @@ ## 项目描述 -Scion 是一个面向 Go 后端开发的复制粘贴代码库。`registry/` 目录下有 11 个自包含模块,每个都是独立的 Go 包。模块默认仅使用标准库;安全敏感模块可以在 `registry/index.json` 中显式标记为 `stdlibOnly:false`,并以 standalone 模式复制。模块的设计目的是被复制到用户项目中并适配,而非作为依赖导入。 +Scion 是一个面向 Go 后端开发的复制粘贴代码库。`registry/` 目录下有 12 个自包含模块,每个都是独立的 Go 包。模块默认仅使用标准库;安全敏感模块可以在 `registry/index.json` 中显式标记为 `stdlibOnly:false`,并以 standalone 模式复制。模块的设计目的是被复制到用户项目中并适配,而非作为依赖导入。 ## 编码标准 @@ -63,7 +63,7 @@ cd registry//src/go && go test -v -count=1 ./... ```bash # PowerShell -$modules = @('middleware','auth','crud','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') +$modules = @('middleware','auth','crud','database','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Pop-Location } ``` @@ -89,7 +89,7 @@ foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Po 项目路径: 架构: -- registry/ 下有 11 个模块 — 每个是独立的 Go 包 +- registry/ 下有 12 个模块 — 每个是独立的 Go 包 - 模块路径模式: registry/<模块名>/src/go/ - Go 1.22+,默认仅使用标准库,gofmt 强制 @@ -125,9 +125,10 @@ foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Po ``` scion/ -├── registry/ # 11 个复制粘贴模块 +├── registry/ # 12 个复制粘贴模块 │ ├── auth/ # JWT 认证 + bcrypt │ ├── crud/ # 泛型 CRUD + 分页 +│ ├── database/ # database/sql 工具 │ ├── middleware/ # 9 个 HTTP 中间件 │ ├── rbac/ # 角色权限控制 │ ├── ratelimit/ # 3 种限流算法 diff --git a/README.md b/README.md index 1acb599..b5bd143 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Backend modules such as auth, CRUD, file upload, and rate limiting share most of |--------|-------------|-------------------| | [auth](registry/auth/) | JWT email/password auth + bcrypt | Rate limiting, user enumeration prevention, JTI, aud/iss validation | | [crud](registry/crud/) | Generic CRUD with pagination | Sort/filter whitelist, SQL injection prevention, pagination ceiling | +| [database](registry/database/) | `database/sql` setup + transactions | DSN-safe errors, whitelisted SQL fragments, parameterized values | | [middleware](registry/middleware/) | Recovery, CORS, logging, timeout, etc. | CRLF injection prevention, trusted proxy, body size limit | | [rbac](registry/rbac/) | Role-based access control | Wildcard permissions, cycle detection, hierarchy inheritance | | [ratelimit](registry/ratelimit/) | Fixed window / sliding window / token bucket | Memory exhaustion protection, LRU eviction, key length limit | @@ -109,6 +110,7 @@ scion/ | |-- auth/ # Authentication module | |-- cache/ # TTL + LRU cache | |-- crud/ # CRUD operations module +| |-- database/ # database/sql helpers | |-- file-upload/ # File upload handler | |-- health/ # Health check probes | |-- mail/ # SMTP email sender @@ -143,7 +145,7 @@ go run ./cmd/scion doctor --strict Run tests for all registry modules in PowerShell: ```powershell -$modules = @('middleware','auth','crud','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') +$modules = @('middleware','auth','crud','database','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Pop-Location } ``` diff --git a/README_zh.md b/README_zh.md index c82108d..aef946d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -74,6 +74,7 @@ Get-FileHash .\scion_v0.1.3_windows_amd64.zip -Algorithm SHA256 |------|------|----------| | [auth](registry/auth/) | JWT 邮箱/密码认证 + bcrypt | 限流、用户枚举防护、JTI、aud/iss 校验 | | [crud](registry/crud/) | 泛型 CRUD + 分页 | 排序/过滤白名单、SQL 注入防护、分页上限 | +| [database](registry/database/) | `database/sql` 设置 + 事务 | DSN 安全错误、SQL 片段白名单、参数化值 | | [middleware](registry/middleware/) | Recovery、CORS、日志、超时等 | CRLF 注入防护、可信代理、请求体大小限制 | | [rbac](registry/rbac/) | 基于角色的访问控制 | 通配符权限、循环检测、层级继承 | | [ratelimit](registry/ratelimit/) | 固定窗口 / 滑动窗口 / 令牌桶 | 内存耗尽防护、LRU 驱逐、key 长度限制 | @@ -109,6 +110,7 @@ scion/ | |-- auth/ # 认证模块 | |-- cache/ # TTL + LRU 缓存 | |-- crud/ # CRUD 模块 +| |-- database/ # database/sql 工具 | |-- file-upload/ # 文件上传模块 | |-- health/ # 健康检查模块 | |-- mail/ # SMTP 邮件模块 @@ -143,7 +145,7 @@ go run ./cmd/scion doctor --strict PowerShell 中运行所有 registry 模块测试: ```powershell -$modules = @('middleware','auth','crud','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') +$modules = @('middleware','auth','crud','database','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Pop-Location } ``` diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 2fee675..e5f74b3 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -37,6 +37,7 @@ export default defineConfig({ { text: 'Overview', link: '/modules/' }, { text: 'Auth', link: '/modules/auth' }, { text: 'CRUD', link: '/modules/crud' }, + { text: 'Database', link: '/modules/database' }, { text: 'Middleware', link: '/modules/middleware' }, { text: 'RBAC', link: '/modules/rbac' }, { text: 'Rate Limit', link: '/modules/ratelimit' }, @@ -85,6 +86,7 @@ export default defineConfig({ { text: '概览', link: '/zh/modules/' }, { text: 'Auth 认证', link: '/zh/modules/auth' }, { text: 'CRUD 增删改查', link: '/zh/modules/crud' }, + { text: 'Database 数据库', link: '/zh/modules/database' }, { text: 'Middleware 中间件', link: '/zh/modules/middleware' }, { text: 'RBAC 权限控制', link: '/zh/modules/rbac' }, { text: 'Rate Limit 限流', link: '/zh/modules/ratelimit' }, diff --git a/docs/contributing.md b/docs/contributing.md index 15323c4..8c5fa36 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -66,7 +66,7 @@ Add your module to `registry/index.json`. cd registry//src/go && go test -v ./... # Test all modules -$modules = @('middleware','auth','crud','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') +$modules = @('middleware','auth','crud','database','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Pop-Location } ``` diff --git a/docs/index.md b/docs/index.md index ff901ff..e897177 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,10 @@ features: title: CRUD details: Generic CRUD operations with pagination, sort/filter whitelist, SQL injection prevention. link: /modules/crud + - icon: SQL + title: Database + details: database/sql setup, transactions, and whitelisted query fragments. + link: /modules/database - icon: 🛡️ title: Middleware details: Recovery, CORS, logging, timeout, request ID, body size limit. diff --git a/docs/modules/database.md b/docs/modules/database.md new file mode 100644 index 0000000..619c486 --- /dev/null +++ b/docs/modules/database.md @@ -0,0 +1,91 @@ +# Database Module + +`database/sql` setup, transaction helpers, and safe query fragments. + +## What's Included + +- Bounded dynamic connection pool defaults +- Dynamic pool sizing based on CPU and IO capacity +- `FromEnv()` for `DATABASE_*` settings +- Startup ping with timeout and DSN-safe errors +- `WithinTx` helper with rollback on error or panic +- `DBTX` interface for repositories +- Whitelisted `WHERE` and `ORDER BY` fragment builders +- Maximum 32 filter conditions per builder/query + +## Quick Copy + +```bash +cp -r registry/database/src/go/* yourproject/internal/database/ +``` + +## Usage + +```go +opts := database.FromEnv() +db, err := database.Open(ctx, opts) +if err != nil { + return err +} +defer db.Close() + +err = database.WithinTx(ctx, db, nil, func(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "UPDATE users SET active = ? WHERE id = ?", true, 123) + return err +}) +``` + +## Safe Fragments + +```go +columns := database.ColumnMap{ + "email": "users.email", + "created_at": "users.created_at", +} + +where, args, err := database.WhereEqual( + map[string]string{"email": "ada@example.com"}, + columns, + database.DollarPlaceholder, +) +order, err := database.OrderBy("-created_at", columns) +``` + +## Dynamic Pool Sizing + +`Defaults()` uses a balanced dynamic pool based on `GOMAXPROCS`. For IO-heavy +services, opt into a larger bounded pool: + +```go +opts := database.ApplyPoolStrategy(database.FromEnv(), database.PoolStrategy{ + Profile: database.PoolIOHeavy, + CPUCores: 8, + IOParallelism: 4, + MaxOpenLimit: 96, +}) +``` + +Environment knobs: + +| Variable | Purpose | +|----------|---------| +| `DATABASE_POOL_PROFILE` | `conservative`, `balanced`, or `io-heavy` | +| `DATABASE_POOL_CPU_CORES` | CPU cores used by the formula | +| `DATABASE_IO_PARALLELISM` | extra independent IO capacity | +| `DATABASE_POOL_MAX_OPEN_LIMIT` | hard cap for derived max open connections | + +## Security Features + +- Rejects CRLF and null bytes in string inputs +- Enforces length limits on driver names, DSNs, fields, and values +- Requires whitelist mapping for SQL identifiers +- Keeps filter values parameterized +- Caps generated filter conditions to avoid unbounded memory growth +- Does not include DSNs in open or ping errors + +## Tests + +```bash +cd registry/database/src/go +go test -v ./... +``` diff --git a/docs/modules/index.md b/docs/modules/index.md index bf247a3..e23d752 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -1,6 +1,6 @@ # Modules Overview -Scion provides 11 production-ready, copy-paste Go modules. Each module is self-contained. Modules are standard-library only by default; declared security exceptions are marked in the registry. +Scion provides 12 production-ready, copy-paste Go modules. Each module is self-contained. Modules are standard-library only by default; declared security exceptions are marked in the registry. ## Available Modules @@ -8,6 +8,7 @@ Scion provides 11 production-ready, copy-paste Go modules. Each module is self-c |--------|-------------|-------------------| | [Auth](/modules/auth) | JWT email/password auth + bcrypt | Rate limiting, user enumeration prevention, JTI | | [CRUD](/modules/crud) | Generic CRUD with pagination | Sort/filter whitelist, SQL injection prevention | +| [Database](/modules/database) | `database/sql` setup + transactions | DSN-safe errors, whitelisted SQL fragments | | [Middleware](/modules/middleware) | Recovery, CORS, logging, timeout | CRLF injection prevention, body size limit | | [RBAC](/modules/rbac) | Role-based access control | Wildcard permissions, cycle detection | | [Rate Limit](/modules/ratelimit) | Fixed/sliding window, token bucket | Memory exhaustion protection, LRU eviction | diff --git a/docs/zh/contributing.md b/docs/zh/contributing.md index 261f9bb..32aa69a 100644 --- a/docs/zh/contributing.md +++ b/docs/zh/contributing.md @@ -66,7 +66,7 @@ go test -v ./... cd registry//src/go && go test -v ./... # 测试所有模块 -$modules = @('middleware','auth','crud','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') +$modules = @('middleware','auth','crud','database','rbac','ratelimit','validation','file-upload','health','cache','pagination','mail') foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Pop-Location } ``` diff --git a/docs/zh/index.md b/docs/zh/index.md index 9c1e3e8..0f55717 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -25,6 +25,10 @@ features: title: CRUD 增删改查 details: 通用 CRUD 操作 + 分页,排序/过滤白名单,SQL注入防护。 link: /zh/modules/crud + - icon: SQL + title: Database 数据库 + details: database/sql 设置、事务封装和 SQL 片段白名单。 + link: /zh/modules/database - icon: 🛡️ title: Middleware 中间件 details: Recovery、CORS、日志、超时、请求ID、请求体大小限制。 diff --git a/docs/zh/modules/database.md b/docs/zh/modules/database.md new file mode 100644 index 0000000..c0d335e --- /dev/null +++ b/docs/zh/modules/database.md @@ -0,0 +1,91 @@ +# Database 数据库模块 + +`database/sql` 设置、事务封装和安全 SQL 片段构建。 + +## 包含内容 + +- 显式连接池默认值 +- 基于 CPU 和 IO 能力的动态连接池 sizing +- 基于 `DATABASE_*` 的 `FromEnv()` +- 带超时的启动 ping,错误不暴露 DSN +- `WithinTx`,在错误或 panic 时回滚 +- 供 repository 使用的 `DBTX` 接口 +- 基于白名单的 `WHERE` 和 `ORDER BY` 片段构建 +- 每个 builder/query 最多 32 个过滤条件 + +## 快速复制 + +```bash +cp -r registry/database/src/go/* yourproject/internal/database/ +``` + +## 使用方式 + +```go +opts := database.FromEnv() +db, err := database.Open(ctx, opts) +if err != nil { + return err +} +defer db.Close() + +err = database.WithinTx(ctx, db, nil, func(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "UPDATE users SET active = ? WHERE id = ?", true, 123) + return err +}) +``` + +## 安全 SQL 片段 + +```go +columns := database.ColumnMap{ + "email": "users.email", + "created_at": "users.created_at", +} + +where, args, err := database.WhereEqual( + map[string]string{"email": "ada@example.com"}, + columns, + database.DollarPlaceholder, +) +order, err := database.OrderBy("-created_at", columns) +``` + +## 动态连接池策略 + +`Defaults()` 默认使用基于 `GOMAXPROCS` 的 balanced 动态连接池。IO-heavy +服务可以显式使用更大的有界连接池: + +```go +opts := database.ApplyPoolStrategy(database.FromEnv(), database.PoolStrategy{ + Profile: database.PoolIOHeavy, + CPUCores: 8, + IOParallelism: 4, + MaxOpenLimit: 96, +}) +``` + +环境变量: + +| 变量 | 用途 | +|------|------| +| `DATABASE_POOL_PROFILE` | `conservative`、`balanced` 或 `io-heavy` | +| `DATABASE_POOL_CPU_CORES` | sizing 公式使用的 CPU 核数 | +| `DATABASE_IO_PARALLELISM` | 额外独立 IO 能力 | +| `DATABASE_POOL_MAX_OPEN_LIMIT` | 推导出的最大连接数硬上限 | + +## 安全特性 + +- 拒绝字符串输入中的 CRLF 和 null 字节 +- 对 driver 名称、DSN、字段和值设置长度限制 +- SQL 标识符必须来自白名单映射 +- 过滤值只作为参数返回,不拼接进 SQL +- 限制生成的过滤条件数量,避免无界内存增长 +- open 或 ping 错误不包含 DSN + +## 测试 + +```bash +cd registry/database/src/go +go test -v ./... +``` diff --git a/docs/zh/modules/index.md b/docs/zh/modules/index.md index b349ec9..92caba4 100644 --- a/docs/zh/modules/index.md +++ b/docs/zh/modules/index.md @@ -1,6 +1,6 @@ # 模块概览 -Scion 提供 11 个生产就绪、可复制粘贴的 Go 模块。每个模块自包含。模块默认仅使用标准库;安全例外会在 registry 中显式标记。 +Scion 提供 12 个生产就绪、可复制粘贴的 Go 模块。每个模块自包含。模块默认仅使用标准库;安全例外会在 registry 中显式标记。 ## 可用模块 @@ -8,6 +8,7 @@ Scion 提供 11 个生产就绪、可复制粘贴的 Go 模块。每个模块自 |------|------|---------| | [Auth](/zh/modules/auth) | JWT 认证 + bcrypt | 限流,用户枚举防护,JTI | | [CRUD](/zh/modules/crud) | 通用 CRUD + 分页 | 排序/过滤白名单,SQL注入防护 | +| [Database](/zh/modules/database) | `database/sql` 设置 + 事务 | DSN 安全错误,SQL 片段白名单 | | [Middleware](/zh/modules/middleware) | Recovery、CORS、日志、超时 | CRLF 注入防护,请求体大小限制 | | [RBAC](/zh/modules/rbac) | 基于角色的访问控制 | 通配符权限,循环检测 | | [Rate Limit](/zh/modules/ratelimit) | 固定/滑动窗口、令牌桶 | 内存耗尽防护,LRU 淘汰 | diff --git a/internal/bundle/manifest.json b/internal/bundle/manifest.json index 75ea502..0d13152 100644 --- a/internal/bundle/manifest.json +++ b/internal/bundle/manifest.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "registryVersion": "0.1.0", - "bundleHash": "18284db2be7ca31829f14a922988ce14aab2f5a2f224077c8e6ad7157ac7e003", + "registryVersion": "0.2.0", + "bundleHash": "a02b457e62e7e559ecef0dff6c0aeb47e901c0092099f1e83085aeb9ddc8c3a6", "modules": [ { "id": "auth", @@ -18,6 +18,11 @@ "version": "0.1.0", "source": "registry/crud/src/go" }, + { + "id": "database", + "version": "0.1.0", + "source": "registry/database/src/go" + }, { "id": "file-upload", "version": "0.1.0", @@ -113,15 +118,15 @@ "path": "registry/auth/src/go/handlers.go", "module": "auth", "moduleVersion": "0.1.0", - "sha256": "552c82e00c6eb87771f39987bf3e3bf4aec162b76a676f1aed01c545b4d80b06", - "size": 6555 + "sha256": "eadff0ccce281b58a7006f4cc5e84f3b54e4f880b1f4628ab86d5775505ab264", + "size": 6643 }, { "path": "registry/auth/src/go/handlers_test.go", "module": "auth", "moduleVersion": "0.1.0", - "sha256": "b0b29d3b202902b9da2ba4dee70981f32c96664ca593fa08633ec905ff473028", - "size": 10671 + "sha256": "188362f3741384dacf1631ac5f110c1578b0adbd8de87c105eb4bedb34c68623", + "size": 11023 }, { "path": "registry/auth/src/go/jwt.go", @@ -316,15 +321,15 @@ "path": "registry/crud/src/go/handlers.go", "module": "crud", "moduleVersion": "0.1.0", - "sha256": "9693b7588a165f4c218be244fb456d72e014ea9ab539b1eb51ed886affed0793", - "size": 6610 + "sha256": "b17cd5d67b8eac99c7f406a8ac90ddb87296ee6bbf9b815eae2a014e946da705", + "size": 7439 }, { "path": "registry/crud/src/go/handlers_test.go", "module": "crud", "moduleVersion": "0.1.0", - "sha256": "de92b97b818cb0a64c5203399d1c1f5c222893187b7eb6e439e6b5f025e331cf", - "size": 10024 + "sha256": "07248e7f6421e02182dcedaf5bbf1beb13565ee81d0538984292f6145cd21ed7", + "size": 11705 }, { "path": "registry/crud/src/go/models.go", @@ -344,8 +349,8 @@ "path": "registry/crud/src/go/pentest_test.go", "module": "crud", "moduleVersion": "0.1.0", - "sha256": "d971b4ef9942d82ae8e1a5825e67d9925577f8c6f079180c061667dd2bd433e5", - "size": 7847 + "sha256": "8c10b6eaaabadd67a2fa6bacf838af94ddb965c1a4918fec480baaa463de1ded", + "size": 8462 }, { "path": "registry/crud/src/go/routes.go", @@ -361,6 +366,111 @@ "sha256": "962e48fd141f103df2a6648e3232c4b796a9e2356ad3adcf88b6ecdf946d7713", "size": 3052 }, + { + "path": "registry/database/README.md", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "5ed6a2fe853d535b0f742b8ba0f9cb34b1ac06344946b3177e555757edcdeb81", + "size": 3005 + }, + { + "path": "registry/database/__llms__.md", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "6b2ea5f58fa15f14cc95830b33695cc24fcd0bbc7a25f580a108eea32dbbccac", + "size": 491 + }, + { + "path": "registry/database/src/go/config.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "35fceebcf600899f9b34f2bb8296a27343c7d1edb61f3e3ed0f94863172ce955", + "size": 6588 + }, + { + "path": "registry/database/src/go/config_test.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "54658ff8144fbc31589ed1f159ee68eb9d3e9dcbcde464161067a887ee06dc58", + "size": 4491 + }, + { + "path": "registry/database/src/go/driver_test.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "00a03b8f9cbc21328daef9624a37ace812fa91c5abfa04d07fbc20e9b63602f8", + "size": 2461 + }, + { + "path": "registry/database/src/go/go.mod", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "c5e7df7c136894507b1fe57e2e2f172c279d43bf0e1dc85187192b11034c3214", + "size": 25 + }, + { + "path": "registry/database/src/go/open.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "4177178b6b7b6c8093fbb11c79eb65b8b00c50447daa4f486a25c6b5af0273cf", + "size": 1000 + }, + { + "path": "registry/database/src/go/open_test.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "a808038e1ebaadf6830c76971ab8c455aaece0d08f54fb0679913145d2a80a99", + "size": 2054 + }, + { + "path": "registry/database/src/go/pentest_test.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "6b50d4b74b34a8469a857f95086895f4bb0b66ddce74588c0f6c4bb66ca312c9", + "size": 2874 + }, + { + "path": "registry/database/src/go/profile.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "2e4390e4ad1c17536cb50ed824f9847d4e317d11fbfa20941059b4923641bdc8", + "size": 2828 + }, + { + "path": "registry/database/src/go/profile_test.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "dfdc3188d4b8528a6ad0b60d4dc87c1a7f389455c7b04607f6274c7fa2128460", + "size": 2203 + }, + { + "path": "registry/database/src/go/query.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "599296ec3b5a1362635377a3508d972d8a81249256f35b016c7745287ef2a1f7", + "size": 6211 + }, + { + "path": "registry/database/src/go/query_test.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "85c05f1cbae1dab1d2f5799f0e3139b461631d5d0bcde6a3062d4c6cb07d28f9", + "size": 5832 + }, + { + "path": "registry/database/src/go/transaction.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "2888af8693a78d802183aece1b2ac16585197ebc258b17836938b07ebecbb0e6", + "size": 1841 + }, + { + "path": "registry/database/src/go/transaction_test.go", + "module": "database", + "moduleVersion": "0.1.0", + "sha256": "533397ceffc2a3a8f936ff42dc724db6b45be4552da47777faee93ef99daef80", + "size": 2823 + }, { "path": "registry/file-upload/README.md", "module": "file-upload", @@ -421,15 +531,15 @@ "path": "registry/file-upload/src/go/storage.go", "module": "file-upload", "moduleVersion": "0.1.0", - "sha256": "311c0244f5d12942048e892a7bcce92b1e8c3d25f5c7715194fb1823f1623cfa", - "size": 5572 + "sha256": "7d9be5c746bf1c2fa7e0c2d5f428aa4570ffdd961243cb790259326d754c7fd0", + "size": 7118 }, { "path": "registry/file-upload/src/go/storage_test.go", "module": "file-upload", "moduleVersion": "0.1.0", - "sha256": "a1b57a3ba9c83933e4c780b98402e0b571b8205d76a67accd184a8abb2be886a", - "size": 1265 + "sha256": "78309929f161124c981fd293beb246cb04e1a480a9aa29badaffca0f05f120a6", + "size": 2211 }, { "path": "registry/file-upload/src/go/validate.go", @@ -517,8 +627,8 @@ }, { "path": "registry/index.json", - "sha256": "535534825c49a7ee784b67e65b7d9d74b79e89f5b102739e61e96ab0633f6452", - "size": 6856 + "sha256": "529693ad0317b61ed6c347ee0ed37fb3d4a186716afae194fb31bf76c8510362", + "size": 7464 }, { "path": "registry/mail/README.md", @@ -650,8 +760,8 @@ "path": "registry/middleware/src/go/config.go", "module": "middleware", "moduleVersion": "0.1.0", - "sha256": "9e4c17317971a22e9ebc37f9ee39b620b3fa86f43f9014e543d96ae890e3dbe4", - "size": 5460 + "sha256": "058cf100feb21b79a1c842d26e61568a1d817a8a53bcdcc078bc880f2a2b5ec2", + "size": 5556 }, { "path": "registry/middleware/src/go/config_test.go", @@ -734,15 +844,15 @@ "path": "registry/middleware/src/go/proxy.go", "module": "middleware", "moduleVersion": "0.1.0", - "sha256": "076abf946207c97047c2d0bc979d3550687ba5c4f394f79a561efe33e873953a", - "size": 4624 + "sha256": "a4017e82ffb0487f99fb7abe7aa9c69d9ada8485e6aa454c2a687961e69b6ab9", + "size": 4854 }, { "path": "registry/middleware/src/go/proxy_test.go", "module": "middleware", "moduleVersion": "0.1.0", - "sha256": "7389c8876c91bc8581f89791b28afccdb5372d67a1ffc383ac20e465196e72ac", - "size": 4051 + "sha256": "6a831f91796d8e3cb09ec4ccf7cc45abbbee056e27ba471b9c1e27bfdbe2c74a", + "size": 5076 }, { "path": "registry/middleware/src/go/recovery.go", @@ -1049,15 +1159,15 @@ "path": "registry/rbac/src/go/manager.go", "module": "rbac", "moduleVersion": "0.1.0", - "sha256": "a3a73effe29751c7920589d1d9d97d28a3439a03df7d8abf1ff9ef579ef9ba8f", - "size": 7109 + "sha256": "0d8e1c9adb45f190a4b1f5ad345dc5f6bd4e33e100ac11876d8237486201fe3c", + "size": 7616 }, { "path": "registry/rbac/src/go/manager_test.go", "module": "rbac", "moduleVersion": "0.1.0", - "sha256": "ebc41319f5aa024ff615a53ede9c5ba6178638080d378dbe86bbf16955feccd3", - "size": 6170 + "sha256": "2a2646c190b4ad0ab6dc515ceb135c851af95b46841c9f961747752a0ddd16ca", + "size": 7641 }, { "path": "registry/rbac/src/go/middleware.go", diff --git a/internal/bundle/registry.zip b/internal/bundle/registry.zip index 860e6a863c6951981c0fe12db26676353842cb50..e5f3887ee3a162a0b051492245316404af8197fe 100644 GIT binary patch delta 47899 zcmd7531FPXkuR<}HQ)G@by$`y^J#pIY-uFfm_xQO@&y9hk}Vq(jAM5AbPkr2Ukc)9)y4&$tq+a;N9HG67zpKHu)z3HrXtgww1>Z`g@K z!^Y7Eo>;wfR@h^TzkhX3Ny)2o#8Yu^{n)L)o+|Ep+6X<+)p>H^1D&~6kLdbe!QgxX zyJuVCeTlwYw)0>jlSLQ(m&FH@{g-riZV^}3d#YCrXA%R+!)9xnxodatXf6@$ITG2Q z7>%?>4#o#Z5|N`l=$A1;mH6;*BGn(2Z5l+~Gv112(TEwTZzwXDMjETETEDENJ{F6O z9q(%yyE`}glb4l_eLoiwpFQadk4;-Yb?mL2Au^BnYQ$Na4P$y*qskNz_uuTVe&C}c z*ZIQF&H0CCW-KcynXzo_+7&B5*gZPlJ?d%NF?L&Lcd8UY4K2%yZ_UOvN z@wq?t+!H*vFP+Nf%xKtXY9bsrCI@qg%)0bQDrXKRhmyIhxi^tYWa7C*zd67kQEfOY zK6-;^-coM0H8@3W1Iff-znO{;C9<)w4MWv!Gh3FQ7dFIqe(KpUm7#&C2NS8BsQQ^FR%6%4 z%#I`odYid?{p&e;_hF0YJ5k~>B3)m7K)30FY}d(JFg2a9xmetPtke+KYz&0PUwGQ{-#oR(U_6`KM4V{S z|8~VQ@u6(^XgEy75ih^tsTjZUSj4&6H!nO02@U61-?pg z@;Ogn{O;#HA1|K@?(SbZx;`-w9~m608(bjnf5kH-o|xiYEM^A1GiMCPv)M!GOn+N8 z(U(c&MEq^f|Ey*z=hB&k)me<0a4gkcGZ}~uB=_)h1Y#FZstSJ)lk%5T0jQ9*I;$W;JmySo10BUfr ziT+)vwj0tTm*G`@0Dmfpf1ts{w`GyG!;$*2mv@_@%kTumi*I?RiO=5S35m6Ddd{zk zER!6Zja`yXrlM#QSsbYs@jrRCiMRjFTP9Au>4}fu==Ii2<^8Fa_NUWtPi@7vClZv{ zpLQnu_a<_G^9@Q|cFGf(88!~aGiE+r4|`~C46(bM6r9$zri`_)GA$a&si*}83;Hnykww~P!9N(zi!^L&N4{T0tV zapMZ_KJmWuy(`7nS9zH0O()|f>ZC7cn$TxWd<3DfnHcts(`tXSh zrq`8}Os^XUF}_=N?0|n-X@5Ky?~P~0Q@{0B<=4v}33D?DJ(viGyNAG!W{;|rXv!WK z>@oKx28W@+m^2V@Zwlxn(&?Zw0fof*Xr z9^X5JeGZ#!c)Yk+(m|)CKw^@07j)Ri{^x zDLa_#OPEqKLc^Xl5^h&Io7?CmL8CsT=TvMB8WH=~ zeFMIhv-CH!u@QY@_e*3_@xdltEFKRURbtIaQX!s=LtgoF= zR-=8n!wqJ7IG3#tW4oX#b-WuxESmU7-1J_5d2@enY~5fwn*g9NRdE2>2}LiN+J0C9 zqHeI2i)$bD)#Sl;hs~wn&e-{VZr7MROJ=g4eqe=>C~klPxT2oX#a1|U3hb5Yk#85mr7-=;lj7m)YY~Y?E*cM4C-V@K+6&z&^ z;U?_YhxR2hzz^q>tTkkd{yXX8h65wj_`Y?9uL}p?yom9MbG#Y7iVh1MX;n#=vfRcEenTC5A;=DkSroe0;f|-sK(5W0dq7xf(-yXWF~VB zka;A%wq?y3zWF{uE6iqcV>*)xNg9d&8df+d3}Wu&oCz3igox3|3i&4_f$0 zXE_{s`U#%dW4G?!LQQ4e#w_-|<2w22a;E#@9ON_w5MxZ=f;jrkoFiE z#5n}bLF_UI6Fm)PkLFeSJDG0WM|{H-tq%nhVTj-<;I(Z74~}-YKH@Omj^$|beWuz7 z(dR{-7z~hDN7Y^A?daUo)xNEL)20oZI=VMYN?`>!H%kXl2h5fNK#UcUjOo6qBfq(0 zJAvC5&-By2>w;oU!qwQ!copfDSpb#hZx2D;)Uk2H_KwX=LTU{vYMo;+Igp@@8J(?Y z9?}5br|54%;Hh+i;0aU>TkM8<-a58iw1=v9?%;7`oyvgH1JL8jta-I4`%>^wUJWF+ zH%-IVvde)d%OE``Gzr9psyZ!sRs~qsh|};`7a>J~x3u+Z)yz3mE7ex2B3q@lrF0R} zch;}ne!1MY2e9o+y0GS@+BvlmQW5l?rFA=T@<8bvQMS^v#?}m)Go^Lml8>(3`|y;K zl82{=_AdnME%mYa8Gm)@dZkNlea1h#Ld!&_&M1DIDP5M=UwT%tfTEo@IW(NkHEZ^C4wEjF#d6WhmI>SA6wtJmlz5_wJ!*5HKZ(*k4Fk}> zlqSJ-K`Q+BkW)WX8(B|NkG*kHT`h!JpCwhRsZh7d#!PjL5-;2xoRc3+Lw{6fF_Oo_ z+R#JOELA(~G$|O7SeNd@{*%JLStxkWH&|Pgr`@J35h$4&9-;q*BnO2g8vSWhPvPjB zO!WGsy0kR(!nY3Yd(Kl*@|-xZ-&ZS6oeWL0C;7xZ z!9~^t(E^o}&+<7yPaI9D<`i`b7#?lJIX;&#FG`1b6Ykp&^CY%CR{HIg&?r~JX^#p~ zFtB4f2%h`YK(qr*N5ts8!PWT=7||>eNIoKCAm~FaB@@}4HUy$#zu8Ngc!n5G)u2CN zL8Of{rT7qM7wj7l*6CC%jKjnJ_+HdjN2G9A@vQh|uYZO3M6cIqhHzTX``Q*96s7g= zoU_@Ah$*4p>Yp~8-Oh(qP+XhvRxj2NnW`ig=*U5fm`T-}*cU&TOpl0`K7UOf1G0M{ zRe%SeP+|abyu7i(3^LNU&x~iy2)1oH4P>FlMutW)eROaIFz62v{KC;>MI>f!x2eHo zf^kI-5~2tSRBm5B!7G*#bP~sKASIcdq0>gKab&vKdpHf+%%(>&kWO@bhVlXi1|RcE zP@LS4xnMw&ea%dsb#9Ffst@rw6_#4+%F**?sY$KFPLb6XUNBAHNCuitZg5np0XAD= z*=m^q%?PucAbk6|b0un^N{!~F>3JI#CpI0Y)QQrMwMzA@2SP3kuwWS!3Ok@WxX|LQ z$bku%GZ#;$mfN0v~ys5m5TnPNw1^}0tO_0^DEz6aaYn? zmS<__+MBec0bG9ie5V8C5$Ivd%7H|qWgnG_t$dYG@~GqbpmLx&Z-Tj*8c0=K=K5|()V58 z+}gYAQ=vW%bsXRE|7J%DmPlAMQaJl%aFhaL^{drrvW+s!VkNmZg(C$Hi8a`Uc#DyY zPpealCvnkUf30|R$UEyYy7!_L(w)fBxqKi#n8no;3TX+t3-wtHB8FN}_2#hQD5WN{ zRNS8ORxY<^n~#!_`c>vZvK!&{g&3Q7J?2FRoAe&6I_4xvQSl;KRX z;Ly?#=Oz6YiJQ~j^1MTEfP~mu$rcW~m4R)Wtu8d{XA}!|bvIwZpr}ui0F%zfE=oX? zJ{XNy=pR~nPCqOy6qs-8(G&dB2tGj6gruzEjgMtJ7qyV>}O)xet)HSW4?dJYH~6mLsz%F zUynGnb*ScPha}^1*@6wH2XWv_{yFTyB3_;EpS5@btZYic=-wh5^O?hLRfaW^j;UE? zX(O%~^w-YUI%cbFAM1mFkheNnaRutkL@T1Flu@hAX7-M`!rd~73J%*$7Dt5?9ZPYF zNG0~N&5Ipp_y(O3u|Zx?h#7g`SSCY>*<|n&hx-!PtH2}^w0dWM1wzl%qv*CrVGo<3 zS!(C$2ciJeU`PsDfHzDEm`6g&*BMAeB2zRC`WIH&poWRts4a9?@x?*^A~EA3Pf(nC zAhIpUq0cTchHdr?@ft!`NtOF*`1HnC^N@}V#arVVmjX~YDC8{qmZmDR#s!YZV^ z1ul(yO4m*V$zcI3_{0K(Q7fdk8(f}tHft^>!qcB@c&?2}Z9P*+3BYborSRuERz z(c^Z2cebD&h`^-gs48e-CZN%o?v55OCZWgNV>i0M1Ud+2ZZ}{;7_YL;e=j0{@AOwS2RtT|-sE*a3~H z50P6x0RvZE@v}RO4dTn_W}&@6o!#(hmb?aG1%&&>ZDa5$_}9-FQ^mK(JhQ}%t0u)y zTyYaT3mfh9EVRjULDgskCbHjAVH^OnHXgb%JJI`6_ z1V!ay#)?3-sCvM=pzySgK}52B(2Hmv&cz_g+$uRtAXH^2F_g}XPEyBxAfxK%PFgeX zytJdLD<-L0cv9Lxm6s2lskS~4arG6=*~0pBirgO&+N219{1cVt4iyUt}1t5@#9bU7H;8@DLpW>OoZ=RPNo7oeLIRK3ed!Un z6-eulRG0FDIa~lqhs=rGS%hm=C|+>0O);)%DPGelrs~?fN_xFdBdSeZMX@c0Y!Qyq z=TuF_!T8W{cD=kV=;#+Go`%V#=>c!pmW3|61K32H`X-rtx8;EN>g-8sx=jKFHBFcC zVo!ZPQ7fkDm`0@As%f$^t{t9u;3*JDzLvTVs zO1UY`2QiYGw$K#wQ~o-Uzr{aAoV?x_p35p8iB~$DgBQs+7`6qHlVf}CnlCOK^M&&e zoLZkoIq~?B$WStyrIVICprci%gmDD@!AVPPSq-L>L@<~)m<|?fl?HYx*^VA16T1U2 zpVkQ~PE6S&_6Px84)Cu4-j^91D1x$e@t|Q}nYl8>j;eH5od4e%51B{coJ*z$i$oLp z!$*s5R~~L|rtRJu-lZf7Fgfplczl~aaZ2p|#>^)w^FsyKu<+J)^zZ8Xop+t2qn9)_ zWWi90$p@Tgjpxigf^{s0Z#9>nnRPH@?2mc|Qm?dW{Z7W(%$#}jnCaapK007b3Fcy0 zFImz40slO)Y_Bn8Z0>c2IIzH|6hHkZ|Lm~~9y7||ojRDkB9Tdxwa(sEv0y{Tj`>4P zMY-AH0K)2M3^xpLDnJ-iv|ijX;;k)maD*b^+=uM#?A`5Jy0&9!lIF3}rFa|>ZRZ-( zmRVRKtN_;}FkitC3RePK=hX!)q-&SmkuAQEWRws8tz!sO&tZyD=uacc_bpGMG zvXYYP%JebS;Q^HCYucM`qJ=ANn(diuyPLF!{nZQf*_r$oB4H!q{LPILnXwyJR){@0 z&w@PE$_PAA;ZsY&Wbl;9*_5S?fMq8YN5Wx>8_=#Eq!h}UW_C2y7wd$>V>p}}9ZtY? zn1edV4++-Xkv_I$_oat8B+WGAxis9+V;!j+oH_D__VUI}neziw1?rA^-fqLQLCb&| zSfH~bnS_lDE^V>lOhhuaJ{_e99QoY4?K78ZQ1x&@OAL3y;eu{Q zTG}TNp7SxhH9kKi7U^6FF5%2bt3-3do^3~4bdUGu%I)$myB-#$Q`3XJr z#kkt`!{X)3JhNkpn%g0JSl1y&ux7$N6gsjW7m!Vlwm?l<=l533>WhH_&@_kfP10O= zuJYH^(C7mH?&C1e8>~nT!cUr2OwSfiHFFgA6|iyrz|QI zry(<0rAED%s4WMr8I6n7xJmcMRh5&wwjY+f#SrWXwwP=C;WMxn#VW`(RuBIdk~5fX zo?#LOxw-D;r`KnDe}A+^y!^w!>`H_($O)6*B=$UI(`1Q4UOX8$D)@q6P1M=xHWjJj z>ek(>z3Aj-M+%huMK@N{V6K6~* zD0z7`w%E+Ams-6^(H+rUyA>0VT9U9C08a2DgTBOWB2TeZkb;)Xkn7g& zg@U$5C+`MeimcYe&~R>)x^M^t<`?UOCQZ~@wbmzM5{{q%9d_-O639Gq>>*EOIBHe0 zDR{A62oh4LOCe1xR#RM^Ovg5KZUjlmJ#_U0D;~eaC@Hzcpp9JY`K;4DwCsR?z7-u5 z4)0ByEwSaxx!t}`Kk_k{sY~Vv&xcBD$J$m-E4MdtsQ^HLi};)VO85Rh)vk5`;XI#$ z*&6#hE?a5SuuT8f5seI^*hS5_<%%iYQR68nV7R$u2db=ep2X&dIB5jL5I9sYvEv|w z02jaz9el%i5D{V0sfx5iB}mR8CthMSY&1-(EIdq|F17@Y++v{8svBRzm85DFUs>y; z5ZF(7a65SdW@m&#iR0DS>SjLgnO>_2&`M~LBx6rVgaeW65!0uXA{syCH|bnEiz92r zqpN+TO<}_kYDF3XA;JB-?vY8Gr(^QiffMt@HBb1;EdSnCCZ&>^=Ar}8ZV%ybAsbkS zDn?yOK?G;Pi0jlpWp~aJ)U>Ku%Bxk4O7b9cifom#9{mCU?TsR<78Dj%CNQ+GxGL*H zaF0Pd*E)(f!`*FPVvz1Y6m4TW#V2+Jr|h$$(VRDZ)DQ1WvKfNV=pq6T2!HP?lExqJ2U^F*qVJ5FUOoXxRp6oPsm!hR`1Om+lk=iHDB>A z@OBQ1kKgXA?zd@(#+U{Q@0Qchor~wP(R$}SkaAa8#kDXQXY*9p_HzfCsw%Kr)Gwau z3(T$|bm)oA4QJ3ntQS!f03xYtz~nxWJ|!>_9GRk|Br>!^$sETa(0B9t>Hd z&pSqM=xaQMT*1(B-TrQ=!vKj_Cnbm=dTu->4A!AL?CRabP1bGizkr=BvOnf zcCaNiV3M4tNpc!`D1!y;8)TS*&5n|tjGg$)?6F6e%p}u8h4{wJ{^`>RsEV%Gg2>O? z@Au6SFI`(YZ8d#}V)+%fK^0KnZ5}x)4m<+yUCMSMdj8-G1f>M#R42?AwB$EX_{4ey z8Ei@CHYMWw*W+~mLtpuhVl<$$1h~|pOLKH-8#X2c1M5@=BNRc+`3`X6Ekd>{G}#I` z=?ttymN;TBLYm|woJ3q!NPM#hJCy{O+~4HgboJQMu^DA>LAFq~#D9g&d<_>^#VvW~2wM3so52XPZ|CLnS3av9-lhE4tXsKc2wQ#%(cf=BG=5tdt#eJE%fOe4IM{vwO6&DoNv3yoQ) zGwu>ohILwmG}#{S_#rl>1u6d$@#|!ue5Ss?!8fwwM<}zwyb4S1E1udPR^EuV1KqvTFUd&Mp&|u$xo{0XYo*MvFo`@>c1b=|hz2 z3R$J#PlTHYaeHU!ve_7nJsvy8P@MR6sJulXhijR`5ttW9I#{D?Fme7w#7H{j2+0@e z8{rxH?uX1r$F{y+F1|SCnVFArCkjsCrlWB?gs3(MbsaBBkzz6?QZY1O zKzD~&0q4NCH%d6ylah|u<*RuFyP9{?Zw=5=hFaK-NEfSXZTPUtvof|i^bBa|hcgY~ z&#~h#&zZjiNlyk6>k+;-n8pTb1=!jPs9)tlpw%qrle0e{e~e7_fcbXhet^xC*$pW; zh^ypCY@V@FEy%Vw=SUL)Ycumk7#TZ=Qu)a7A`nj<4pojl`g-Xs=ukz_WR0q90({YP zlP@yE3d@uW%7V4jl|(5k2aU8(3D{{9bVnk?C@(_(Hc|I_s6xEB$Q$%z#mmR>_cz}g z43@FW-8w5K7I~EJ`?{C!@|=au&2}OsT+D!Hv%3a{;K(ryvuqCQo&CA_vLabe`IE4O z9K^HfXER<@!C`|>;qnT|gejyVHru)IR0~U}NJ%ZtCwD#fnYpb9kZzsA=94GqdZ$@F zh)r2(2a$mUqU`Zdx&bag^#|2yjDaKGD{^8p{#L2h(scrrwa>sFdkx?y9@+| zE(;uBPst+0pR~WV91K?o&DlMs0w`NOVOC&6p$a-^l58Gvl>u$ULmN7ykOR z11p*aVt^sLLt=1!Lswzss3hs1ME(@$kNqfe=DV;%rn3p^9Z?^7VpL>l+z-31wJ-pW zLc>u6wv-g;2N^)WM0`j^tJ{P&+{kDYl}SPd*l=n9<{RltWO*YWO7f9*{)?}bxB&@22@SFwjqr=!M2C%ScAc~=4*B^hJ(U=VkI3z8IETh-w5?%Gstx?z3q}W+T#ctbSb+#GNNybrFxl@Z6>FT19*) z=8=;C4oTk%EJVria_R7TqRaOrtis#KLPgpjlvM;vBmaS>8j!_`O)yV7H@#L2Tmgo{ zAP2V$0l8`*dCJj*Dy}Y}N1lT+yF)g7THuu|F9Lb#*$6+dU=!%2NJ~2bIAxht(DO7N zGN9^8#h@-PggOx>WukB)yh73y3bG#kYBi<$H%1vY@|biEi*wE9W;qi)0aD4Pq*YQl z6IaLT`&%>8m5RB_-=>lPGK49Uae zBF@oDRs-Fms;EFS|B^&-nw7IAVhF4X`YSaA3t?3{qGXh8qdFvb_};MSGSv*}Sh`YD zs%1;)Dc(gDLo0t#@jn-PA{2261w@P6di+E6E!HdKY=^{kesZ*MFsq}oDs|M8irQwJ zp)tRXnp%N#F=)~6B#`Nf@f%mZd1NYd`Vo zL_ywG`3V)Go!Tb?K6Z``iTG5JT<<*eS&v6dh&K#J))nI%&^KFwFo&TIeM&s7bu|vzzMTvJv+?M5& zQ+M?QpoQc;4Ryuw-@vQoZ^D(6lW_%cV4?ma3l-x*`sO*K;PU1|@)lB8?!hiTvxxZD zQkP(gU#=~kx&ioD)GLH$eNbF$lU2oXXmKeXeHzx`{~LVl7rT7yT>ER7LF zc}?`R2N!FvR2NOiKvl?$!;ndq5T-+9!*WKmX=5M+5X>?QJ+Sln{RTjLH>X<-B1JCcD+7#zNOWmM3+Ad3#- zAeQQ!sN91fzr5%r=ATsJb)hW z_0OIH6CUg{5lV3Y0Zb?H30d%-$P zXz!8=pYr?S5MI+7U<9>D}p)6bR904QyO+1xOjSi(p z;Hzb0Av0(ofoYNMqI;Z3(Uz^WOmwe~`URH^IGM{TStx!wA9iClH;O59cG_w##0BL- z#9-iQ6l^?sybCb_R8-Th5DRF2)+c=ou?qA#M5DTJLfU^_l^u3L8<^hnlt!6sgb+>A z+6&hdSgJS&B)HX1?C0PfVmw#oWg;|YF-;~ON=&UYjl+Z?v#(9*k-g#_>t@2f^AKh9Ld3$^-gCkc$Wy?8 z&L>`;@2^ba$3ba**6@VSUyi?DfEUblc8h^*)Ng(OC~tCCiH!HDgxpPnI7A>tdHpw z^duZ<-pQr~aWsq?xqX@R$liTy&bDrZRwa|a6O7&JB6oA;Rg^?v>7;igq@8nel!|C)^Lx^np>5ca)8Zu}>>$*3_JfL69vKKZdxVlW=_q>DE zE0O2LPpikZDKI+7_J~j`8lk;0*x9_MwP*ISW*a4J#pA!l3vBdQUA#BO{AjIS`CDbZ z!NOM~u$Q+g&_Z%q1fL?ydSecvM&-~6aDBaabd$H@e0ASXkGRNOs1w$my!gP)rCSvd zWuG)EF4XP<_P+0+HLo6vGLEW~l4M42j2i)U{;xQ($y?sa6AW2msU@W07VKdpGU#@7 z7%6@S@CE|i#q7;8l6l=8VH9WF6fDNDvP%_8d8xK2ae*0E(v^1^@uYKW$h9KQA2X&K8c0;U z^v}VHpro_7qs&v0r@LFYh>VZ}0bHdD#26?^v{eTQBuqxaW+eKQPfDd2hs^g7F8p&7fvj+0@m;zcN)H8 zIaA;}<=7r6IK$RHcq_#=s%`-=G`8T(+r zKf1*~^>W#XRxOJGX93RF8xy6RC3MwZVDD0_^#@oKik2QL&+{(KuPWk^e$P)KH?ztSNS|yh}--9B&-#fUEJGl@zNE( z3UTlM>z^63E+Jp>Rc+dYS|d`aFEZXm&FIq=GQ(!XkK{o`Bnm>=zdT=5Q&=dSy0lQ{&usL#?yk7v%vZWg1DO{7YtWa_#8Yguprk_4d^lt-dh{7Hc+9 zJz7|w%#NyTeR3}XU;FAOz)(tYt2p-2U^LHyrT`4is5wlVevwmc&CwyFyCuzqAkJTcl}x73>jo}cw640OWL>rJeAHK8 zskvGE`|){z!?mnDeJ4-(XN##r^!~=vc&PKG4qpJF{SHT8TX51EQX^c)vIU14;-5&e zAJ2j5f0Q*5Wpo>4xs>u}r(dc3>@XWyZl@PH3K>QgQC1^a)rW$i%Cs8KlS3DaUABtP zp=Nn-ev#8mv}HMa;gZ3S#-J{mq7mNo$vEOrQ*}-$nT_1Cu_>X04knuD6!eA5^9Atx z`57}8bfowhio-$Vbnu!bwhLR%3{Dxy`zRrlne(S%QWRTPl$HnBBqlFZ_ssKFii!F#2yvpzyLcY!lX&wjmy4my?X51lE(|R!RW-zK29T4}XzDFQ7l>`C<(XZS1BW)X*cww!g3*=7cfUQ@laBXxM9nktk=nCn6sI zC;wbyr+#oxwBH>lpLbfnc{u%7?TA>bEh3*FELg{pcz^uD#9=VkV1iP1(C5F($vzKU z=ptPmL?k20DQ*ML^65hsN{ny@Te#fC{&2G<7`SVvPztA~eI1Sf`1Hz*G= zd@4G^W7zB(gsDe+ly_k*5YPoRq&~El{lq&?Izb3?N}y8ooNa`5s{;k+50HWqagv1& z-R#dT6{2ExE+sdyY6{3L%D(TdooUVLpj<7Y{iL^MzROak=aE3LCo8usbF`FX<)&4= zG0j*`%RM(~QeSZ0=Ez!BECd5V*)Xt;mHpjW-tMi!^BsT1Y3F!Nu_<0-8`>s_7M2oD zaeYPxKx)O)aKPV`0bqGsuaYy_dyt4s36wKev`xYlP{U8d1+Gfe+H?0# z=7MYM2`N>-CZ84cyy&|k?_|^k#;03G^W_l{O zp^i%_x{w@kKWAJAt&^;S-k3W1pMFV9qeFfizWa0)m=!0!>#Z!)5|AHoCpjawb{W;3 zup{uhb0pZ>5`(VEvKISbqb@rNLV^Cu zFd&y2?%Y2N`vqkyW_+e~JXSbx;P`yCfZRj2aOY7YE>hlB;4nIb80x5{+)oUJI{oN5 zuNI)V1EJLDp+u^Vc2PZ!iINgtr_W*?2$xUA@186rKJ3u&37^YcvtiH0nXs8;`A{?L znTKibD;I0FlumKHy$G*9wPKQNM~2){7fq8uD?$nvy3h;I)#GpY7L{8Qh(oWskmEU= zD2st@Db~s?}*4n<4wNn$!ER5)56qm_< zEff{IZ9#A-JZb3BUfbv*R~il=Q{HQ1%O?!N(Z=}SZ@m8B?-lwep})bn9UC6$Ma)Y~ zG7o>ClLLmL^k+p6=+G4l-_hu-Kwa_J*Nv&s!Xc{58iFq6uH=Y^wGDAjf%*=^YpvBT(jV{#8ekL}L8ubtgs{(!=xVaw=8e@o zQLzI2r_Db&c}!H@t%c@TXI95y&V0%p^UR;+7BKW+4kEJPd)jWdr;~tOAs>>KvGSDbXmV;2zTvB%1t{cBsRZ{Z3DxE5_rqE&nPh^C! zlDovaNf@=BU%i$0R}^&)b3%5cm$r5G(xTgDTx-C;K_zA48x8epJf(bMPaNb+psU!S z1pFm&E6LG&96XNO2ZRB_p#~2F(xbY#<3OB@nSf*ET4)!rmBKVhPB?J0uQxs9_*lAnq#uUd#VQ~p+(&TQUQ(aN!HmeVH;`DS*4;+0fGj`l?Kc(Y~Dc zs_>M{2NKo1cgolRjlP|0QQuOlr-Es&5#LCDadL89GfS6Y#Bp@tc!(lLQBm6L;G2rP zG{gy%A1_P6k-hsT)S(MIk|)qJ>5`4E)vW6K@q!YBAbHc^jonnTZ$G)@0U(4dy5S<} zHV2O$g)OHd>p^Y{9(pPPz~oXZ8Q)5eh`S45N?At9;ETXWJCmZKj=IGKbx7r96#1Nv zu2l-N^1}B|6ezwcQ7O!Lx9jiaYZ|WJqGt~m>()w7VVuM$GQ9uzo`PxSLma37LZZZNR*psNvIfsm)M}s=7`VkSafrI4!KJ{XKs9%` zAgkEe{p*8sGrb#RH5YWC(Yrys`~`nx&>oSKs&XvQpAMm_LQ3Umw2vY1vLomUFKurI zTD-$!x)p}#dL8q**!o#K|9Ibizu~ySkV~KlB{>o0bh4F)TRyU`rL?4^rIdt!w4uZ( zk=buEqfHurZ8vv}qDZo{X?Atw4Hnn#3RQ`-;=T~)iQRsfPPXgUMsqUNCYDTWJLg4F zrdPPekUU+#mL@G1yErk3tPPa!t!VO?UKVLIMbwBqsU7KUx3KC;DOY$d#pT~a7E?+; zAJUb@v)99)BfP^i?R-0}mq~Fh%CO(xYh4Nw-g+5JQfE6;TVUyW>Sq7!bvVPi6Z*mh zl6ChqaIyyt_11n>MNORs%4xYuRlYfK?ZdvBTH-h@8yt=sq@*HI;p53b6&99Sl_K=0 zF;9H`!O)zLhDf}tYitFfv8s=MtUdKlYCKnK<$8(k--IHL!%-!-vy zsx}bazKl-8w5}vP3AJJqvRzFGWb!)@$Si3T?@*5zl4QO*)o(|ZB=)iDz--a=312AK z&nSp^y|;GVX~{=3)qI+n$}$zjQ-hwW|C{D3Rc}$bZJ`4LedlRuxBh?7|G&?z+f$>GtAAPgMj~DvO#Fjt!W-pQoOW`T3 z*veYMz3~5>c4@hLsH($&crn>(NHOS~)slfzhMf6UB0+yCbw&r6`)>BnS*(Z58cBPq zUrP23EF{`9iEik1IhlUxyh#YjN{Y@??Nk~xXqM}X-z_!AzPV)9rBVTMjFxv5INud+ zDpJ(`pTPqDpQJ!Li7wBJGq-KLqZHx7rBlX^AFmfzclf4?KfPwm644&-wDFm{yzRe) zdksAYpgadQ^MhhYD^59CHd7qYAQFdQ2ST*ac;`yrg)`<+rVLq3Cpsu$LnCxm&QAk~ zTi5%}nxUM2wO_D!Xua>wCAt;v-A~uo_^u7UI*)j1I8;d&;n^6BgM@*tC0a$&_UDN6 zkFx>UCtb9m7(vpX~v+@I7q$_xqjjs=W&nV2L%SQV4ZA$kcMsP2c~nz*IhPfCW_YU3L8D^bWi6jg2(CVd}y2J?dEAx@Scu5ApI$BHs6bAa08IVS;wggoYseCSBrU+J0CVNYP1Tn)(8NJjAz z*(KNT2P8aWp5Px?4QhIJwX{+>P!nYxzN-8xJzk?9d4FXC*c1H z0o|AM{nFe`WSb0%92o)7?r>3@PdAGn4TX%wOvOTSTbet-QlCk2VAO}ZPQ`cB8Aid2 z0_r}mHlhHP!v;P)ijhb{BwKgm-dg`y1_EGN&~lNVl<6hVosx@8X9LcaBfhG z^Qz_jt3YU4skv(A;Y`ks|3w3H%z10U7$*+yXjmTCYG1tkvAHEBkIfa2-|AgG{)rBs z5fH1d@Px&w`B*)*-@$SEjf0_jasTIn$bovPZ^QlWV_Qgdo$mO- zggo;`;<4P}9JG5W0NKjO_pd>!W5zi_;B9aC!i$Tv?Ja2A8>x4+?X}v@)@|`Tyt7-r zaTh(CeSXjBJL)Uws84k?myztV57gi&@HGv(PF%RfSGh_K&*kM1jlduPqa*@gU{$BU z1{k)K9}xny;Ev7A91-Vk@y)+Pj}SSim3_<1}^^ieedEKa&zDRr8|r} z5jK1$hYi#7w&OqE;!D5cSw6m|-G9ozRG;$U@FIQPtSgrFI>VYgyl2Hr7yIX2s_XNs z08k55mOP4V7#YUl&|z=d$A^AtbK|h11FpcGtT?gJ?|7`}$u9p2@%#>d`EuO{xRqRC z=sym)>t_w{QQ{FUYkI%W%f7ZX`H>NX(;t>4(<~~mUOA!zJ4m+7C$!3*K zfxp-&nSuFuD4k2-MGv;INmC}LOWQ6(pAiCo7}@VuQS!ILqx>uqtdZ#yJ>d?c7b`2& zL2nY{MT}G5;4}(Mg~?X7AMdXZ{Hip3=&l{V`D$fJ$yY1IQ;!AfMeSXISz?PX927I& zFv`U6hXZGgJ!CEz|4mCE^Wc1`yJ64dPGu6~O|YR#g-vum7g$@~y%p9?G#pL;DZd;GsYABcwD1L04!>*%5_RsKzM z?C6S;)eP&LL~7r_-WYNM!pEB9Mqu!O=d+^i7L>yRD1qRPPgfPUycYP~0uABtz<_cV zE}#aI`@S6XRr*jtd~?h*NBoc1@a*p2y&kw^?v^MD!E+5@JWWQPyJgGAfAM;tzBK%+ zb-#V!jaelnZ_HvIqKrGoJ|3JUE_yStQatv2Xu6pDTwvxgAL4SG{}ikofAGz~r##|C zZ)k>i@YjJ^V-3&Mi34w=?%m$tVsC>ve%IT9Deejj#BHU)(y#p@_#3aQ;Y{&uUvR(G zu-P9BxhvF-?+gZ)6akjE8qE&{mwImQ3WjE>2VEWPD%$T1){H&+>|$~93A}p%oTrPA zKVKRWpZH}UG}eAgjrd+TFhlJAQ(&qQX^HXwMa0&}LUY91;Xok3b2oP5Pb=0~ZwEwH z#?FbI8#}Mi>unu}K7j+sL!^P9qQ2J>ppMErQfh@zeev?; zo{C9O4ujTA%770NTRiR!)`?rZ!Lt`iCQA;p@7^Q`iu+FmDk_U(_nWr@RpS>M!RLM9 zX9nuZeo|gi@{@A*SLWsUkE{98IcIxUpGX*y1(oU?1g;l4zoUmWOyqJ>Ke+p<$Wu2a1Nq zOP`cr4_eB~b5!Dp&G8g;Hj;Pr-z*s*6XSomHFSa3n;a0+KOd^P0%~>VROV4^Kaa}oXXbX#G%@(uP`UW@*F*0S z_a7@Y#5EfOq47U{J@lj5wRYAXMM9NOYLvK%4MoTP1#Qpyi+|>MihS5@bbx|BMMP+@ z6A~pPu>^=7BK3Nx)=zcCQ=dkfDsmQC2w6--IjyG%H(V5<>ze_3u+AiXkS3=ks3Fd;ecyG-q>&jQTJ_JQOx{fC_s2ujtw_J zNIelMrA(>WM1krkPdEfq1#^H}Ja0C&3c$zO!N_}4M1?4O>qnMhKg4!+FxHVv$D>Jd zdZC_d7n}WdlhYE;ISN-d7AY0z$X`WKqK>BMn$MNiO`|b%!`UR^;+i~eXolDh!v0db zc6V8;m-jttWfx`PWf8WiC=aF*0&zb7g-{qQd%MjMT(74`GJTkgNCul}I}14ch(~bF z4dP$TerqD|8)}4j7;R)osBD8lI5Zdjb^Jw5l}r+Kx0aS8%LU!Jll%fVsG(~Q0WE@L zg`z`NZF$i0Z#it_#wVynmzf-?oOY3WJv7@nUI~OoEdRo2SSpA)Jfy|*@njyfB!1p7<*NG_-SLyyyxcp!!t9Mm6XuG zi*S#q|M~p2L!ILIbBH#$?`b16*40@i%r}ggqUjl<#`~{V$xpq{80UM>{yY9LcHf68 zWR-uVD#oW}l_}2}7kYQ!ZvDWKcY3%^eBxQu+5doSdFn)P?D^wU#S1T@gWu7Y+&}9t zvn9+6oW_gvIZkH&%H)3#p{rn^;Q!trd5@X4HwVzJP{PSBR4@7@NJV=ZdplG&;R=&KJWk z8e6>|;Qv;Ox6bvpEVe?`Ua*NmmU#FhMO9#2E8!{>@L(zp4P1B#yiEvJbui|OYA zv1-q9_58}0ivSVvKO0L{+0UXCGDZB}B8_yzeHJ{y31k(Dr_#2mpa+fSNC`^PQHIEO0gY%(sg)(r6FMr0I-#o0VP-n7cdnJbp$P zqWvW!(rSk(mXy@F(D?H&P6GnRV)4X_#{AW`-(E?{VpscPubjR;V)co7Yi;L`LeBX| z!#817Bs3u$wiPDETKh(*q=Y<3^go}!_L8!92#4?y-+c|j=d1I42-L$J0`8Pn`+i=h zes#WXT;Th~0{&Gi?tPuUouhuu9YNeNxOl0rZnY|U-y6pHzC@e)W&Xh^755+UP8BEr zjE&*+Sag=l5!UXj!X@R@xK@s`Ch0G2+y0wrM{oW0^-RY{{lIf54;J8pKK0@t#9K} ze#VeLC~kWMv9}}$#n0*Ei>>xYwLP`Gh4TNrK$R!|Y@8)NNJV?rsG@5QAo`DY|6fq~ z*EiYarQ*r=>*1XJHa;Hj;*Xd6X6y~z>`?GExvG-Sl|h-_RboYKMr}q7kFzPq#w27C!X*c@B1H?KfZ9v zUn9wRr8ivTJ?Dp1JYT%z4g1BXz2S4bpZZ@`$)b6-)vj>gyYCsQ_8#vy-=_aA5cm1Q zt9&1r85Her!U-(w53lx3pQV0Pe=4MCcfB9z@2^nh-}Q&j_x*OM`eg>f7x;!cf+8Nk z(8s#@k2fRBJ_%vOh1Vbuhf5lagS5^zMNfN zD$cn}!h;gG?YB{F)E3O(P`mxe5*f4KUPS-e4Rdj@t>$< zo`_8ci`4wH{4sA{c(M5MbbNmOM^*`|;H!MikIRyoZ^4GiL)||E71#Z{RU8y|&j63! zGd;wRpI-(Ym*q;sOjPVzDT|wXODjlwD-$1^8J^>Nw#_OIi66{F`?B_ssQVBq%_{?s z|HB6LD_w@spSXyB%@w!jN~`6xyjlj}w{Nq{r;6+HrCO(+g^$l)ras;{3uE2AlYg~{ z`>Vm<)5=kyFUduVL}xkr|KN92b?g#rz5R8<{7~sU@xF@ibn){t2$1C!Sl0VuR&j^- zi_7JYr%&N9@CtqY#|~Mtv@(35w`;Tf(R5X)THtbFsrM&WSS3@%BOeJVY%8nK^x5}{ zd#?*cBnR}N#P`)cn0uHfR->ius??(E91yxp3yE zCL~4Y*=D6e{qI(;wv@M3hV>Cqs=y(WTy&2kA$h zJh#oCgU_FTm|9hfZ~t3prkt}36~FQ=S$x+VNYA6+m3Y222Q>f96XJ<#>=$qP;cz1N z$r?2H?C)gpmunzwLJpYaWRFonTm;TxOgto0rz2$kPxOPgcY6^1@oMMR<++)!Yx%?yn6m m^&MTSivCy&QL^X#>etTs;mGv+O71JUqphT5<%uvfrT-6Jsp8-O delta 4420 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 K`CXTv=Klw?*eP28 diff --git a/registry/auth/src/go/handlers.go b/registry/auth/src/go/handlers.go index 6f1133c..6f7a043 100644 --- a/registry/auth/src/go/handlers.go +++ b/registry/auth/src/go/handlers.go @@ -63,11 +63,14 @@ func (h *Handler) WithLogger(logger *slog.Logger) *Handler { // decodeBody reads and size-limits the request body, then decodes JSON. func decodeBody(r *http.Request, dst interface{}) error { - body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize+1)) if err != nil { return err } defer r.Body.Close() + if len(body) > maxRequestBodySize { + return errors.New("request body too large") + } return json.Unmarshal(body, dst) } diff --git a/registry/auth/src/go/handlers_test.go b/registry/auth/src/go/handlers_test.go index e5908b3..2d5534d 100644 --- a/registry/auth/src/go/handlers_test.go +++ b/registry/auth/src/go/handlers_test.go @@ -332,7 +332,6 @@ func TestHandler_Me_Unauthorized(t *testing.T) { } func TestDecodeBody_MaxSize(t *testing.T) { - // 1MB + 1 should be truncated by LimitReader large := make([]byte, maxRequestBodySize+1) req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewReader(large)) @@ -344,6 +343,17 @@ func TestDecodeBody_MaxSize(t *testing.T) { } } +func TestDecodeBody_RejectsOversizedValidJSONPrefix(t *testing.T) { + prefix := []byte(`{"key":"value"}`) + body := append(prefix, bytes.Repeat([]byte(" "), maxRequestBodySize-len(prefix)+1)...) + req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewReader(body)) + + var dst map[string]interface{} + if err := decodeBody(req, &dst); err == nil { + t.Fatal("expected oversized body error") + } +} + func TestRespondJSON(t *testing.T) { rr := httptest.NewRecorder() respondJSON(rr, http.StatusOK, map[string]string{"key": "value"}) diff --git a/registry/crud/src/go/handlers.go b/registry/crud/src/go/handlers.go index b53811c..17b7e6d 100644 --- a/registry/crud/src/go/handlers.go +++ b/registry/crud/src/go/handlers.go @@ -2,10 +2,12 @@ package crud import ( "encoding/json" + "errors" "io" "log/slog" "net/http" "strconv" + "strings" ) // maxRequestBodySize limits request body to 1MB to prevent DoS. @@ -66,9 +68,16 @@ func (h *Handler[T]) WithFilterFields(allowed map[string]bool) *Handler[T] { return h } -// maxFilterValueLen limits the length of individual filter values to prevent -// abuse via excessively long query parameters. -const maxFilterValueLen = 256 +const ( + // maxFilterCount limits generated filter maps to prevent unbounded memory + // growth from requests with many unique query parameters. + maxFilterCount = 32 + // maxFilterKeyLen limits filter field names. + maxFilterKeyLen = 128 + // maxFilterValueLen limits the length of individual filter values to + // prevent abuse via excessively long query parameters. + maxFilterValueLen = 256 +) // Create handles POST requests. func (h *Handler[T]) Create(w http.ResponseWriter, r *http.Request) { @@ -122,26 +131,39 @@ func (h *Handler[T]) List(w http.ResponseWriter, r *http.Request) { return } - // Extract filter params + // Extract filter params. A nil allow-list disables filtering entirely. filter := make(map[string]string) - for key, values := range query { - if key == "offset" || key == "limit" || key == "sort" { - continue - } - if len(values) > 0 { - if h.filterAllowed != nil && !h.filterAllowed[key] { + if h.filterAllowed != nil { + for key, values := range query { + if key == "offset" || key == "limit" || key == "sort" { + continue + } + if len(filter) >= maxFilterCount { + respondError(w, http.StatusBadRequest, "too many filter fields") + return + } + if !isSafeFilterString(key, maxFilterKeyLen) { + respondError(w, http.StatusBadRequest, "invalid filter field") + return + } + if !h.filterAllowed[key] { respondError(w, http.StatusBadRequest, "invalid filter field: "+key) return } - // Limit filter value length to prevent abuse. + if len(values) == 0 { + continue + } val := values[0] - if len(val) > maxFilterValueLen { - respondError(w, http.StatusBadRequest, "filter value too long") + if !isSafeFilterString(val, maxFilterValueLen) { + respondError(w, http.StatusBadRequest, "invalid filter value") return } filter[key] = val } } + if len(filter) == 0 { + filter = nil + } params := ParseListParams(offset, limit, h.config.MaxPageSize, sort, filter) @@ -165,6 +187,13 @@ func (h *Handler[T]) List(w http.ResponseWriter, r *http.Request) { }) } +func isSafeFilterString(s string, maxLen int) bool { + if s == "" || len(s) > maxLen { + return false + } + return !strings.ContainsAny(s, "\r\n\x00") +} + // Update handles PUT requests by ID. func (h *Handler[T]) Update(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") @@ -218,11 +247,14 @@ func sortFieldFromRaw(raw string) string { // decodeBody reads and size-limits the request body, then decodes JSON. func decodeBody(r *http.Request, dst interface{}) error { - body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize+1)) if err != nil { return err } defer r.Body.Close() + if len(body) > maxRequestBodySize { + return errors.New("request body too large") + } return json.Unmarshal(body, dst) } diff --git a/registry/crud/src/go/handlers_test.go b/registry/crud/src/go/handlers_test.go index d30f777..56c9d53 100644 --- a/registry/crud/src/go/handlers_test.go +++ b/registry/crud/src/go/handlers_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strconv" "strings" "testing" ) @@ -27,6 +28,7 @@ type mockEntityStore[T any] struct { deleteErr error listData []T listTotal int64 + lastList ListParams } func newMockEntityStore[T any]() *mockEntityStore[T] { @@ -51,7 +53,8 @@ func (m *mockEntityStore[T]) GetByID(id uint) (*T, error) { return e, nil } -func (m *mockEntityStore[T]) List(_ ListParams) ([]T, int64, error) { +func (m *mockEntityStore[T]) List(params ListParams) ([]T, int64, error) { + m.lastList = params if m.listErr != nil { return nil, 0, m.listErr } @@ -238,6 +241,24 @@ func TestHandler_List_FilterAllowed(t *testing.T) { } } +func TestHandler_List_FilterDisabledByDefault(t *testing.T) { + store := newMockEntityStore[Product]() + cfg := &Config{DefaultPageSize: 20, MaxPageSize: 100} + h := NewHandler(store, cfg) + + req := httptest.NewRequest(http.MethodGet, "/products?password=secret", nil) + rr := httptest.NewRecorder() + + h.List(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + if store.lastList.Filter != nil { + t.Fatalf("expected filters to be disabled, got %#v", store.lastList.Filter) + } +} + func TestHandler_List_FilterDisallowed(t *testing.T) { store := newMockEntityStore[Product]() cfg := &Config{DefaultPageSize: 20, MaxPageSize: 100} @@ -256,6 +277,28 @@ func TestHandler_List_FilterDisallowed(t *testing.T) { } } +func TestHandler_List_RejectsTooManyFilters(t *testing.T) { + store := newMockEntityStore[Product]() + cfg := &Config{DefaultPageSize: 20, MaxPageSize: 100} + allowed := make(map[string]bool, maxFilterCount+1) + q := make([]string, 0, maxFilterCount+1) + for i := 0; i < maxFilterCount+1; i++ { + key := "f" + strconv.Itoa(i) + allowed[key] = true + q = append(q, key+"=x") + } + h := NewHandler(store, cfg).WithFilterFields(allowed) + + req := httptest.NewRequest(http.MethodGet, "/products?"+strings.Join(q, "&"), nil) + rr := httptest.NewRecorder() + + h.List(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, rr.Code) + } +} + func TestHandler_List_NilSliceProtection(t *testing.T) { store := newMockEntityStore[Product]() // listData is nil, listTotal is 0 @@ -371,6 +414,17 @@ func TestHandler_Delete_InvalidID(t *testing.T) { } } +func TestDecodeBodyRejectsOversizedValidJSONPrefix(t *testing.T) { + prefix := []byte(`{"name":"Widget"}`) + body := append(prefix, bytes.Repeat([]byte(" "), maxRequestBodySize-len(prefix)+1)...) + req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewReader(body)) + + var dst Product + if err := decodeBody(req, &dst); err == nil { + t.Fatal("expected oversized body error") + } +} + func TestSortFieldFromRaw(t *testing.T) { if sortFieldFromRaw("name") != "name" { t.Error("expected name") diff --git a/registry/crud/src/go/pentest_test.go b/registry/crud/src/go/pentest_test.go index d06bc4b..8f43e01 100644 --- a/registry/crud/src/go/pentest_test.go +++ b/registry/crud/src/go/pentest_test.go @@ -182,6 +182,28 @@ func TestPentest_CRUD_FilterSQLInjection(t *testing.T) { } } +func TestPentest_CRUD_FilterCRLFAndNullRejected(t *testing.T) { + store := newMockEntityStore[pentestEntity]() + h := NewHandler[pentestEntity](store, &Config{MaxPageSize: 100, DefaultPageSize: 20}) + h.WithFilterFields(map[string]bool{"name": true}) + + tests := []string{ + "name=good%0D%0Abad", + "name=good%00bad", + "bad%0D%0Akey=value", + } + for _, rawQuery := range tests { + req := httptest.NewRequest(http.MethodGet, "/items?"+rawQuery, nil) + rec := httptest.NewRecorder() + + h.List(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("query %q: expected 400, got %d", rawQuery, rec.Code) + } + } +} + func TestPentest_CRUD_NegativeOffset(t *testing.T) { store := newMockEntityStore[pentestEntity]() h := NewHandler[pentestEntity](store, &Config{MaxPageSize: 100, DefaultPageSize: 20}) diff --git a/registry/database/README.md b/registry/database/README.md new file mode 100644 index 0000000..23067c3 --- /dev/null +++ b/registry/database/README.md @@ -0,0 +1,112 @@ +# Database Module + +Small `database/sql` helpers for connection setup, transaction wrapping, and +safe query fragments. + +## What's Included + +- Bounded dynamic `database/sql` pool defaults +- Dynamic pool sizing based on CPU and IO capacity +- Environment-based configuration +- Startup ping with timeout +- Transaction helper with rollback on error or panic +- Whitelisted `WHERE` and `ORDER BY` builders +- Filter condition cap to prevent unbounded slice growth +- `?` and PostgreSQL `$n` placeholders + +## Quick Copy + +```bash +cp -r registry/database/src/go/*.go yourproject/internal/database/ +``` + +Or with the Scion CLI: + +```bash +scion add database --to internal/database +``` + +## Usage + +```go +opts := database.FromEnv() +db, err := database.Open(ctx, opts) +if err != nil { + return err +} +defer db.Close() + +err = database.WithinTx(ctx, db, nil, func(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "UPDATE users SET active = ? WHERE id = ?", true, 123) + return err +}) +``` + +Safe fragments: + +```go +columns := database.ColumnMap{ + "email": "users.email", + "created_at": "users.created_at", +} + +where, args, err := database.WhereEqual( + map[string]string{"email": "ada@example.com"}, + columns, + database.DollarPlaceholder, +) +order, err := database.OrderBy("-created_at", columns) +_ = where +_ = args +_ = order +_ = err +``` + +Dynamic pool sizing: + +```go +opts := database.FromEnv() // balanced dynamic pool by default +opts = database.ApplyPoolStrategy(opts, database.PoolStrategy{ + Profile: database.PoolIOHeavy, + CPUCores: 8, + IOParallelism: 4, + MaxOpenLimit: 96, +}) +``` + +`Builder` is intended for one query. If you reuse it, call `Reset()` to drop +old argument references. + +## Environment + +| Variable | Purpose | Default | +|----------|---------|---------| +| `DATABASE_DRIVER` | `database/sql` driver name | required | +| `DATABASE_DSN` | driver-specific DSN | required | +| `DATABASE_MAX_OPEN_CONNS` | explicit max open override | dynamic | +| `DATABASE_MAX_IDLE_CONNS` | explicit max idle override | dynamic | +| `DATABASE_POOL_PROFILE` | `conservative`, `balanced`, `io-heavy` | `balanced` | +| `DATABASE_POOL_CPU_CORES` | CPU cores for dynamic sizing | `GOMAXPROCS` | +| `DATABASE_IO_PARALLELISM` | extra IO capacity for dynamic sizing | 1 | +| `DATABASE_POOL_MAX_OPEN_LIMIT` | hard cap for dynamic max open | profile default | +| `DATABASE_CONN_MAX_LIFETIME` | max connection lifetime | 30m | +| `DATABASE_CONN_MAX_IDLE_TIME` | max idle time | 10m | +| `DATABASE_PING_TIMEOUT` | startup ping timeout | 5s | + +## File Reference + +| File | Purpose | +|------|---------| +| `config.go` | Options, defaults, environment loading | +| `profile.go` | Dynamic connection pool sizing | +| `open.go` | Open and ping a configured `*sql.DB` | +| `transaction.go` | Transaction helper and DBTX interface | +| `query.go` | Whitelisted SQL fragment helpers | +| `pentest_test.go` | Security and abuse-case tests | + +## Tests + +```bash +cd registry/database/src/go +go test -v ./... +``` diff --git a/registry/database/__llms__.md b/registry/database/__llms__.md new file mode 100644 index 0000000..6c3a5d7 --- /dev/null +++ b/registry/database/__llms__.md @@ -0,0 +1,3 @@ +# Database + +Zero-dependency Go `database/sql` helper module. Copy `src/go/*.go` into `internal/database`; callers import their own SQL driver. Provides `Options`, dynamic pool `PoolStrategy`, `Defaults`, `FromEnv`, `Open`, `WithinTx`, `DBTX`, whitelisted `OrderBy`/`WhereEqual`, `Builder`, and `?`/`$n` placeholders. Rejects CRLF/null bytes and overlong strings, caps filter conditions, never concatenates untrusted filter/sort input into SQL, and does not include DSNs in open/ping errors. diff --git a/registry/database/src/go/config.go b/registry/database/src/go/config.go new file mode 100644 index 0000000..bf18314 --- /dev/null +++ b/registry/database/src/go/config.go @@ -0,0 +1,221 @@ +// Package database provides small database/sql helpers for copy-paste Go +// backends: safe connection-pool setup, transaction wrapping, and whitelisted +// query fragment construction. +// +// The package uses only the Go standard library. It does not import any SQL +// driver; callers must register the driver they use in their application. +package database + +import ( + "errors" + "os" + "strconv" + "strings" + "time" +) + +const ( + maxDriverNameLen = 64 + maxDSNLen = 4096 +) + +// Options configures database/sql opening and pool behaviour. +type Options struct { + // DriverName is the database/sql driver name, such as "postgres", "mysql", + // or "sqlite3". The driver must be imported by the calling application. + DriverName string + // DSN is the driver-specific data source name. It is never included in + // errors returned by this module. + DSN string + // MaxOpenConns limits concurrently open connections. Defaults are derived + // dynamically from RuntimePoolStrategy(PoolBalanced). + MaxOpenConns int + // MaxIdleConns limits idle connections retained by database/sql. Defaults + // are derived dynamically from RuntimePoolStrategy(PoolBalanced). + MaxIdleConns int + // ConnMaxLifetime is the maximum lifetime of one connection. Defaults to 30m. + ConnMaxLifetime time.Duration + // ConnMaxIdleTime is the maximum idle lifetime of one connection. Defaults to 10m. + ConnMaxIdleTime time.Duration + // PingTimeout bounds the startup ping performed by Open. Defaults to 5s. + PingTimeout time.Duration +} + +// Defaults returns safe dynamic pool defaults. DriverName and DSN must still be +// supplied by the caller or loaded with FromEnv. +func Defaults() Options { + opts := Options{ + ConnMaxLifetime: 30 * time.Minute, + ConnMaxIdleTime: 10 * time.Minute, + PingTimeout: 5 * time.Second, + } + return ApplyPoolStrategy(opts, RuntimePoolStrategy(PoolBalanced)) +} + +// FromEnv returns Defaults() overridden by environment variables: +// +// DATABASE_DRIVER +// DATABASE_DSN +// DATABASE_MAX_OPEN_CONNS +// DATABASE_MAX_IDLE_CONNS +// DATABASE_CONN_MAX_LIFETIME +// DATABASE_CONN_MAX_IDLE_TIME +// DATABASE_PING_TIMEOUT +// DATABASE_POOL_PROFILE (conservative, balanced, io-heavy) +// DATABASE_POOL_CPU_CORES +// DATABASE_IO_PARALLELISM +// DATABASE_POOL_MAX_OPEN_LIMIT +// +// Invalid numeric or duration values are ignored and the default is retained. +func FromEnv() Options { + opts := Defaults() + maxOpenSet := false + maxIdleSet := false + explicitMaxOpen := 0 + explicitMaxIdle := 0 + + if v := os.Getenv("DATABASE_DRIVER"); v != "" { + opts.DriverName = v + } + if v := os.Getenv("DATABASE_DSN"); v != "" { + opts.DSN = v + } + if v := os.Getenv("DATABASE_MAX_OPEN_CONNS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + opts.MaxOpenConns = n + explicitMaxOpen = n + maxOpenSet = true + } + } + if v := os.Getenv("DATABASE_MAX_IDLE_CONNS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + opts.MaxIdleConns = n + explicitMaxIdle = n + maxIdleSet = true + } + } + if v := os.Getenv("DATABASE_CONN_MAX_LIFETIME"); v != "" { + if d, err := time.ParseDuration(v); err == nil && d >= 0 { + opts.ConnMaxLifetime = d + } + } + if v := os.Getenv("DATABASE_CONN_MAX_IDLE_TIME"); v != "" { + if d, err := time.ParseDuration(v); err == nil && d >= 0 { + opts.ConnMaxIdleTime = d + } + } + if v := os.Getenv("DATABASE_PING_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + opts.PingTimeout = d + } + } + + strategy := RuntimePoolStrategy(PoolBalanced) + strategySet := false + if profile := os.Getenv("DATABASE_POOL_PROFILE"); profile != "" { + strategy.Profile = PoolProfile(profile) + strategySet = true + } + if v := os.Getenv("DATABASE_POOL_CPU_CORES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + strategy.CPUCores = n + strategySet = true + } + } + if v := os.Getenv("DATABASE_IO_PARALLELISM"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + strategy.IOParallelism = n + strategySet = true + } + } + if v := os.Getenv("DATABASE_POOL_MAX_OPEN_LIMIT"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + strategy.MaxOpenLimit = n + strategySet = true + } + } + if strategySet { + opts = ApplyPoolStrategy(opts, strategy) + } + opts = applyExplicitPoolOverrides(opts, maxOpenSet, explicitMaxOpen, maxIdleSet, explicitMaxIdle) + return opts +} + +// Validate enforces required fields, length limits, and CRLF / null-byte +// rejection for all string options. +func (o Options) Validate() error { + if err := validateRequiredString("driver name", o.DriverName, maxDriverNameLen); err != nil { + return err + } + if err := validateRequiredString("dsn", o.DSN, maxDSNLen); err != nil { + return err + } + if o.MaxOpenConns <= 0 { + return errors.New("database: max open connections must be positive") + } + if o.MaxIdleConns < 0 { + return errors.New("database: max idle connections cannot be negative") + } + if o.MaxIdleConns > o.MaxOpenConns { + return errors.New("database: max idle connections cannot exceed max open connections") + } + if o.ConnMaxLifetime < 0 { + return errors.New("database: connection max lifetime cannot be negative") + } + if o.ConnMaxIdleTime < 0 { + return errors.New("database: connection max idle time cannot be negative") + } + if o.PingTimeout <= 0 { + return errors.New("database: ping timeout must be positive") + } + return nil +} + +func (o Options) normalize() Options { + defaults := Defaults() + if o.MaxOpenConns == 0 { + o.MaxOpenConns = defaults.MaxOpenConns + if o.MaxIdleConns == 0 { + o.MaxIdleConns = defaults.MaxIdleConns + } + } + if o.ConnMaxLifetime == 0 { + o.ConnMaxLifetime = defaults.ConnMaxLifetime + } + if o.ConnMaxIdleTime == 0 { + o.ConnMaxIdleTime = defaults.ConnMaxIdleTime + } + if o.PingTimeout == 0 { + o.PingTimeout = defaults.PingTimeout + } + return o +} + +func applyExplicitPoolOverrides(opts Options, maxOpenSet bool, maxOpen int, maxIdleSet bool, maxIdle int) Options { + if maxOpenSet { + opts.MaxOpenConns = maxOpen + } + if maxIdleSet { + opts.MaxIdleConns = maxIdle + } else if maxOpenSet && opts.MaxIdleConns > opts.MaxOpenConns { + opts.MaxIdleConns = opts.MaxOpenConns + } + return opts +} + +func validateRequiredString(name, value string, maxLen int) error { + if value == "" { + return errors.New("database: " + name + " is required") + } + return validateString(name, value, maxLen) +} + +func validateString(name, value string, maxLen int) error { + if len(value) > maxLen { + return errors.New("database: " + name + " is too long") + } + if strings.ContainsAny(value, "\r\n\x00") { + return errors.New("database: " + name + " contains invalid characters") + } + return nil +} diff --git a/registry/database/src/go/config_test.go b/registry/database/src/go/config_test.go new file mode 100644 index 0000000..020301a --- /dev/null +++ b/registry/database/src/go/config_test.go @@ -0,0 +1,131 @@ +package database + +import ( + "strings" + "testing" + "time" +) + +func TestDefaults(t *testing.T) { + opts := Defaults() + want := ApplyPoolStrategy(Options{ + ConnMaxLifetime: 30 * time.Minute, + ConnMaxIdleTime: 10 * time.Minute, + PingTimeout: 5 * time.Second, + }, RuntimePoolStrategy(PoolBalanced)) + if opts.MaxOpenConns != want.MaxOpenConns { + t.Fatalf("MaxOpenConns = %d", opts.MaxOpenConns) + } + if opts.MaxIdleConns != want.MaxIdleConns { + t.Fatalf("MaxIdleConns = %d", opts.MaxIdleConns) + } + if opts.ConnMaxLifetime != 30*time.Minute { + t.Fatalf("ConnMaxLifetime = %s", opts.ConnMaxLifetime) + } + if opts.ConnMaxIdleTime != 10*time.Minute { + t.Fatalf("ConnMaxIdleTime = %s", opts.ConnMaxIdleTime) + } + if opts.PingTimeout != 5*time.Second { + t.Fatalf("PingTimeout = %s", opts.PingTimeout) + } +} + +func TestFromEnv(t *testing.T) { + t.Setenv("DATABASE_DRIVER", "scion") + t.Setenv("DATABASE_DSN", "memory") + t.Setenv("DATABASE_MAX_OPEN_CONNS", "7") + t.Setenv("DATABASE_MAX_IDLE_CONNS", "3") + t.Setenv("DATABASE_CONN_MAX_LIFETIME", "2m") + t.Setenv("DATABASE_CONN_MAX_IDLE_TIME", "30s") + t.Setenv("DATABASE_PING_TIMEOUT", "1s") + + opts := FromEnv() + if opts.DriverName != "scion" || opts.DSN != "memory" { + t.Fatalf("env driver/dsn not loaded: %#v", opts) + } + if opts.MaxOpenConns != 7 || opts.MaxIdleConns != 3 { + t.Fatalf("env pool not loaded: %#v", opts) + } + if opts.ConnMaxLifetime != 2*time.Minute || opts.ConnMaxIdleTime != 30*time.Second || opts.PingTimeout != time.Second { + t.Fatalf("env durations not loaded: %#v", opts) + } +} + +func TestFromEnvIgnoresInvalidValues(t *testing.T) { + defaults := Defaults() + t.Setenv("DATABASE_MAX_OPEN_CONNS", "-1") + t.Setenv("DATABASE_MAX_IDLE_CONNS", "-1") + t.Setenv("DATABASE_CONN_MAX_LIFETIME", "bad") + t.Setenv("DATABASE_CONN_MAX_IDLE_TIME", "-1s") + t.Setenv("DATABASE_PING_TIMEOUT", "0s") + + opts := FromEnv() + if opts.MaxOpenConns != defaults.MaxOpenConns || opts.MaxIdleConns != defaults.MaxIdleConns { + t.Fatalf("invalid ints should retain defaults: %#v", opts) + } + if opts.ConnMaxLifetime != 30*time.Minute || opts.ConnMaxIdleTime != 10*time.Minute || opts.PingTimeout != 5*time.Second { + t.Fatalf("invalid durations should retain defaults: %#v", opts) + } +} + +func TestFromEnvExplicitMaxOpenClampsDerivedIdle(t *testing.T) { + t.Setenv("DATABASE_POOL_PROFILE", "io-heavy") + t.Setenv("DATABASE_POOL_CPU_CORES", "4") + t.Setenv("DATABASE_IO_PARALLELISM", "4") + t.Setenv("DATABASE_MAX_OPEN_CONNS", "5") + + opts := FromEnv() + if opts.MaxOpenConns != 5 { + t.Fatalf("MaxOpenConns = %d", opts.MaxOpenConns) + } + if opts.MaxIdleConns != 5 { + t.Fatalf("MaxIdleConns = %d", opts.MaxIdleConns) + } +} + +func TestOptionsValidate(t *testing.T) { + opts := Defaults() + opts.DriverName = "scion" + opts.DSN = "memory" + if err := opts.Validate(); err != nil { + t.Fatalf("valid options: %v", err) + } + + tests := []struct { + name string + opts Options + }{ + {"missing driver", Options{DSN: "memory", MaxOpenConns: 1, PingTimeout: time.Second}}, + {"missing dsn", Options{DriverName: "scion", MaxOpenConns: 1, PingTimeout: time.Second}}, + {"crlf driver", Options{DriverName: "scion\r\nbad", DSN: "memory", MaxOpenConns: 1, PingTimeout: time.Second}}, + {"null dsn", Options{DriverName: "scion", DSN: "mem\x00ory", MaxOpenConns: 1, PingTimeout: time.Second}}, + {"long driver", Options{DriverName: strings.Repeat("a", maxDriverNameLen+1), DSN: "memory", MaxOpenConns: 1, PingTimeout: time.Second}}, + {"idle exceeds open", Options{DriverName: "scion", DSN: "memory", MaxOpenConns: 1, MaxIdleConns: 2, PingTimeout: time.Second}}, + {"bad ping", Options{DriverName: "scion", DSN: "memory", MaxOpenConns: 1, PingTimeout: 0}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.opts.Validate(); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestNormalizeFillsZeroPoolValues(t *testing.T) { + opts := Options{DriverName: "scion", DSN: "memory"}.normalize() + defaults := Defaults() + if opts.MaxOpenConns != defaults.MaxOpenConns || opts.MaxIdleConns != defaults.MaxIdleConns || opts.PingTimeout != 5*time.Second { + t.Fatalf("normalize did not fill defaults: %#v", opts) + } +} + +func TestNormalizeAllowsZeroIdleWhenMaxOpenSet(t *testing.T) { + opts := Options{DriverName: "scion", DSN: "memory", MaxOpenConns: 8, MaxIdleConns: 0}.normalize() + if opts.MaxOpenConns != 8 { + t.Fatalf("MaxOpenConns = %d", opts.MaxOpenConns) + } + if opts.MaxIdleConns != 0 { + t.Fatalf("MaxIdleConns = %d", opts.MaxIdleConns) + } +} diff --git a/registry/database/src/go/driver_test.go b/registry/database/src/go/driver_test.go new file mode 100644 index 0000000..ff2b69d --- /dev/null +++ b/registry/database/src/go/driver_test.go @@ -0,0 +1,136 @@ +package database + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "io" + "strings" + "sync" + "sync/atomic" + "time" +) + +const testDriverName = "sciontest" + +var registerTestDriver sync.Once + +type txCounters struct { + commits atomic.Int64 + rollbacks atomic.Int64 +} + +var testTxCounters txCounters + +func ensureTestDriver() { + registerTestDriver.Do(func() { + sql.Register(testDriverName, testDriver{}) + }) +} + +func resetTxCounters() { + testTxCounters.commits.Store(0) + testTxCounters.rollbacks.Store(0) +} + +type testDriver struct{} + +func (testDriver) Open(name string) (driver.Conn, error) { + if strings.Contains(name, "open-error") { + return nil, errors.New("open failed for " + name) + } + return &testConn{dsn: name}, nil +} + +type testConn struct { + dsn string +} + +func (c *testConn) Prepare(string) (driver.Stmt, error) { + return testStmt{}, nil +} + +func (c *testConn) Close() error { + return nil +} + +func (c *testConn) Begin() (driver.Tx, error) { + return c.BeginTx(context.Background(), driver.TxOptions{}) +} + +func (c *testConn) Ping(ctx context.Context) error { + if strings.Contains(c.dsn, "ping-error") { + return errors.New("ping failed for " + c.dsn) + } + if strings.Contains(c.dsn, "slow") { + timer := time.NewTimer(time.Second) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } + } + return nil +} + +func (c *testConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { + if strings.Contains(c.dsn, "begin-error") { + return nil, errors.New("begin failed") + } + return &testTx{dsn: c.dsn}, nil +} + +type testTx struct { + dsn string +} + +func (tx *testTx) Commit() error { + testTxCounters.commits.Add(1) + if strings.Contains(tx.dsn, "commit-error") { + return errors.New("commit failed") + } + return nil +} + +func (tx *testTx) Rollback() error { + testTxCounters.rollbacks.Add(1) + if strings.Contains(tx.dsn, "rollback-error") { + return errors.New("rollback failed") + } + return nil +} + +type testStmt struct{} + +func (testStmt) Close() error { + return nil +} + +func (testStmt) NumInput() int { + return -1 +} + +func (testStmt) Exec([]driver.Value) (driver.Result, error) { + return driver.RowsAffected(0), nil +} + +func (testStmt) Query([]driver.Value) (driver.Rows, error) { + return emptyRows{}, nil +} + +type emptyRows struct{} + +func (emptyRows) Columns() []string { + return nil +} + +func (emptyRows) Close() error { + return nil +} + +func (emptyRows) Next([]driver.Value) error { + return io.EOF +} diff --git a/registry/database/src/go/go.mod b/registry/database/src/go/go.mod new file mode 100644 index 0000000..c83f728 --- /dev/null +++ b/registry/database/src/go/go.mod @@ -0,0 +1,3 @@ +module database + +go 1.22 diff --git a/registry/database/src/go/open.go b/registry/database/src/go/open.go new file mode 100644 index 0000000..ac4bcc7 --- /dev/null +++ b/registry/database/src/go/open.go @@ -0,0 +1,38 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// Open validates Options, opens a database/sql handle, applies pool settings, +// and verifies connectivity with PingContext. Returned errors do not include +// the DSN. +func Open(ctx context.Context, opts Options) (*sql.DB, error) { + if ctx == nil { + return nil, errors.New("database: context is nil") + } + opts = opts.normalize() + if err := opts.Validate(); err != nil { + return nil, err + } + + db, err := sql.Open(opts.DriverName, opts.DSN) + if err != nil { + return nil, fmt.Errorf("database: open driver %q: %w", opts.DriverName, err) + } + db.SetMaxOpenConns(opts.MaxOpenConns) + db.SetMaxIdleConns(opts.MaxIdleConns) + db.SetConnMaxLifetime(opts.ConnMaxLifetime) + db.SetConnMaxIdleTime(opts.ConnMaxIdleTime) + + pingCtx, cancel := context.WithTimeout(ctx, opts.PingTimeout) + defer cancel() + if err := db.PingContext(pingCtx); err != nil { + _ = db.Close() + return nil, errors.New("database: ping failed") + } + return db, nil +} diff --git a/registry/database/src/go/open_test.go b/registry/database/src/go/open_test.go new file mode 100644 index 0000000..138a0da --- /dev/null +++ b/registry/database/src/go/open_test.go @@ -0,0 +1,88 @@ +package database + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestOpenSuccess(t *testing.T) { + ensureTestDriver() + opts := Defaults() + opts.DriverName = testDriverName + opts.DSN = "memory" + opts.MaxOpenConns = 4 + opts.MaxIdleConns = 2 + + db, err := Open(context.Background(), opts) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer db.Close() + + if got := db.Stats().MaxOpenConnections; got != 4 { + t.Fatalf("MaxOpenConnections = %d", got) + } +} + +func TestOpenAppliesDefaultPoolValues(t *testing.T) { + ensureTestDriver() + db, err := Open(context.Background(), Options{ + DriverName: testDriverName, + DSN: "memory", + }) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer db.Close() + want := Defaults().MaxOpenConns + if got := db.Stats().MaxOpenConnections; got != want { + t.Fatalf("MaxOpenConnections = %d, want %d", got, want) + } +} + +func TestOpenValidationError(t *testing.T) { + ensureTestDriver() + _, err := Open(context.Background(), Options{DriverName: testDriverName, DSN: "bad\r\nvalue"}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestOpenRejectsNilContext(t *testing.T) { + if _, err := Open(nil, Options{DriverName: testDriverName, DSN: "memory"}); err == nil { + t.Fatal("expected nil context error") + } +} + +func TestOpenPingErrorDoesNotLeakDSN(t *testing.T) { + ensureTestDriver() + dsn := "ping-error user=app password=supersecret" + _, err := Open(context.Background(), Options{ + DriverName: testDriverName, + DSN: dsn, + PingTimeout: time.Second, + }) + if err == nil { + t.Fatal("expected ping error") + } + if strings.Contains(err.Error(), "supersecret") || strings.Contains(err.Error(), dsn) { + t.Fatalf("error leaked DSN: %v", err) + } +} + +func TestOpenPingTimeout(t *testing.T) { + ensureTestDriver() + _, err := Open(context.Background(), Options{ + DriverName: testDriverName, + DSN: "slow", + PingTimeout: time.Millisecond, + }) + if err == nil { + t.Fatal("expected ping timeout") + } + if strings.Contains(err.Error(), "slow") { + t.Fatalf("error leaked DSN: %v", err) + } +} diff --git a/registry/database/src/go/pentest_test.go b/registry/database/src/go/pentest_test.go new file mode 100644 index 0000000..bc7f90b --- /dev/null +++ b/registry/database/src/go/pentest_test.go @@ -0,0 +1,107 @@ +package database + +import ( + "context" + "fmt" + "strings" + "testing" + "time" +) + +func TestPentestRejectsCRLFAndNullOptions(t *testing.T) { + bad := []Options{ + {DriverName: "postgres\r\nX-Header: x", DSN: "memory", MaxOpenConns: 1, PingTimeout: time.Second}, + {DriverName: "postgres", DSN: "host=localhost\x00password=secret", MaxOpenConns: 1, PingTimeout: time.Second}, + } + for _, opts := range bad { + if err := opts.Validate(); err == nil { + t.Fatal("expected invalid options") + } + } +} + +func TestPentestOpenDoesNotLeakSecrets(t *testing.T) { + ensureTestDriver() + dsn := "ping-error host=db user=app password=do-not-leak" + _, err := Open(context.Background(), Options{ + DriverName: testDriverName, + DSN: dsn, + PingTimeout: time.Second, + }) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if strings.Contains(msg, "do-not-leak") || strings.Contains(msg, dsn) { + t.Fatalf("secret leaked in error: %s", msg) + } +} + +func TestPentestOrderByInjectionRejected(t *testing.T) { + payloads := []string{ + "created_at DESC; DROP TABLE users", + "created_at--", + "created_at/*comment*/", + "created_at\r\nLIMIT 1", + "-created_at;SELECT 1", + } + for _, payload := range payloads { + if _, err := OrderBy(payload, testColumns()); err == nil { + t.Fatalf("payload accepted: %q", payload) + } + } +} + +func TestPentestFilterKeyInjectionRejected(t *testing.T) { + payloads := []string{ + "email OR 1=1", + "email;DROP TABLE users", + "email\x00", + strings.Repeat("a", maxFieldLen+1), + } + for _, payload := range payloads { + _, _, err := WhereEqual(map[string]string{payload: "x"}, testColumns(), QuestionPlaceholder) + if err == nil { + t.Fatalf("payload accepted: %q", payload) + } + } +} + +func TestPentestFilterValueIsParameterized(t *testing.T) { + payload := "x' OR '1'='1" + sql, args, err := WhereEqual(map[string]string{"email": payload}, testColumns(), DollarPlaceholder) + if err != nil { + t.Fatalf("WhereEqual: %v", err) + } + if strings.Contains(sql, payload) { + t.Fatalf("payload was concatenated into SQL: %s", sql) + } + if len(args) != 1 || args[0] != payload { + t.Fatalf("payload should be returned as arg: %#v", args) + } +} + +func TestPentestUnsafeDeveloperWhitelistRejected(t *testing.T) { + columns := ColumnMap{ + "email": "users.email) OR 1=1 --", + } + if _, err := OrderBy("email", columns); err == nil { + t.Fatal("unsafe whitelist value accepted") + } +} + +func TestPentestNilWhitelistRejected(t *testing.T) { + if _, err := OrderBy("email", nil); err == nil { + t.Fatal("nil whitelist accepted") + } +} + +func TestPentestTooManyFiltersRejectedBeforeAllocationGrowth(t *testing.T) { + filters := make(map[string]string, maxConditions+100) + for i := 0; i < maxConditions+100; i++ { + filters[fmt.Sprintf("field%d", i)] = "x" + } + if _, _, err := WhereEqual(filters, nil, QuestionPlaceholder); err == nil { + t.Fatal("oversized filters accepted") + } +} diff --git a/registry/database/src/go/profile.go b/registry/database/src/go/profile.go new file mode 100644 index 0000000..fb8a52b --- /dev/null +++ b/registry/database/src/go/profile.go @@ -0,0 +1,110 @@ +package database + +import ( + "runtime" + "strings" +) + +// PoolProfile selects a connection-pool sizing formula. +type PoolProfile string + +const ( + // PoolConservative keeps database pressure low for small apps and shared DBs. + PoolConservative PoolProfile = "conservative" + // PoolBalanced is a general-purpose profile for mixed CPU and IO work. + PoolBalanced PoolProfile = "balanced" + // PoolIOHeavy allows more in-flight queries when the app is often waiting + // on network/storage IO and the database can absorb the extra concurrency. + PoolIOHeavy PoolProfile = "io-heavy" +) + +// PoolStrategy describes how to derive pool sizes from CPU and IO capacity. +type PoolStrategy struct { + // Profile selects the sizing formula. Unknown values fall back to balanced. + Profile PoolProfile + // CPUCores is the effective CPU parallelism. Values <= 0 use GOMAXPROCS. + CPUCores int + // IOParallelism represents extra independent IO capacity, such as separate + // disks, database replicas, or known downstream query capacity. + IOParallelism int + // MaxOpenLimit caps the derived MaxOpenConns. Values <= 0 use the profile default. + MaxOpenLimit int +} + +// RuntimePoolStrategy returns a strategy using the process CPU parallelism. +func RuntimePoolStrategy(profile PoolProfile) PoolStrategy { + return PoolStrategy{ + Profile: profile, + CPUCores: runtime.GOMAXPROCS(0), + IOParallelism: 1, + } +} + +// ApplyPoolStrategy returns opts with MaxOpenConns and MaxIdleConns derived +// from strategy. Other Options fields are preserved. +func ApplyPoolStrategy(opts Options, strategy PoolStrategy) Options { + profile := normalizePoolProfile(strategy.Profile) + cores := strategy.CPUCores + if cores <= 0 { + cores = runtime.GOMAXPROCS(0) + } + if cores < 1 { + cores = 1 + } + ioParallelism := strategy.IOParallelism + if ioParallelism < 0 { + ioParallelism = 0 + } + + var maxOpen, defaultLimit int + var idleRatio int + switch profile { + case PoolConservative: + maxOpen = cores + ioParallelism + defaultLimit = 16 + idleRatio = 4 + case PoolIOHeavy: + maxOpen = cores*4 + ioParallelism*2 + defaultLimit = 128 + idleRatio = 2 + default: + maxOpen = cores*2 + ioParallelism + defaultLimit = 64 + idleRatio = 3 + } + + if maxOpen < 1 { + maxOpen = 1 + } + limit := strategy.MaxOpenLimit + if limit <= 0 { + limit = defaultLimit + } + if maxOpen > limit { + maxOpen = limit + } + + maxIdle := maxOpen / idleRatio + if maxIdle < 1 { + maxIdle = 1 + } + if maxIdle > maxOpen { + maxIdle = maxOpen + } + opts.MaxOpenConns = maxOpen + opts.MaxIdleConns = maxIdle + return opts +} + +func normalizePoolProfile(profile PoolProfile) PoolProfile { + switch PoolProfile(strings.ToLower(strings.TrimSpace(string(profile)))) { + case PoolConservative: + return PoolConservative + case PoolIOHeavy: + return PoolIOHeavy + case PoolBalanced: + return PoolBalanced + default: + return PoolBalanced + } +} diff --git a/registry/database/src/go/profile_test.go b/registry/database/src/go/profile_test.go new file mode 100644 index 0000000..5b6a965 --- /dev/null +++ b/registry/database/src/go/profile_test.go @@ -0,0 +1,82 @@ +package database + +import "testing" + +func TestApplyPoolStrategyBalanced(t *testing.T) { + opts := ApplyPoolStrategy(Options{}, PoolStrategy{ + Profile: PoolBalanced, + CPUCores: 4, + IOParallelism: 2, + }) + if opts.MaxOpenConns != 10 { + t.Fatalf("MaxOpenConns = %d", opts.MaxOpenConns) + } + if opts.MaxIdleConns != 3 { + t.Fatalf("MaxIdleConns = %d", opts.MaxIdleConns) + } +} + +func TestApplyPoolStrategyIOHeavyUsesExtraCapacity(t *testing.T) { + opts := ApplyPoolStrategy(Options{}, PoolStrategy{ + Profile: PoolIOHeavy, + CPUCores: 4, + IOParallelism: 4, + }) + if opts.MaxOpenConns != 24 { + t.Fatalf("MaxOpenConns = %d", opts.MaxOpenConns) + } + if opts.MaxIdleConns != 12 { + t.Fatalf("MaxIdleConns = %d", opts.MaxIdleConns) + } +} + +func TestApplyPoolStrategyLimit(t *testing.T) { + opts := ApplyPoolStrategy(Options{}, PoolStrategy{ + Profile: PoolIOHeavy, + CPUCores: 32, + IOParallelism: 32, + MaxOpenLimit: 20, + }) + if opts.MaxOpenConns != 20 { + t.Fatalf("MaxOpenConns = %d", opts.MaxOpenConns) + } + if opts.MaxIdleConns != 10 { + t.Fatalf("MaxIdleConns = %d", opts.MaxIdleConns) + } +} + +func TestFromEnvPoolProfile(t *testing.T) { + t.Setenv("DATABASE_POOL_PROFILE", "io-heavy") + t.Setenv("DATABASE_POOL_CPU_CORES", "4") + t.Setenv("DATABASE_IO_PARALLELISM", "4") + t.Setenv("DATABASE_POOL_MAX_OPEN_LIMIT", "30") + + opts := FromEnv() + if opts.MaxOpenConns != 24 || opts.MaxIdleConns != 12 { + t.Fatalf("pool profile not applied: %#v", opts) + } +} + +func TestFromEnvPoolKnobsWithoutProfile(t *testing.T) { + t.Setenv("DATABASE_POOL_CPU_CORES", "4") + t.Setenv("DATABASE_IO_PARALLELISM", "3") + t.Setenv("DATABASE_POOL_MAX_OPEN_LIMIT", "20") + + opts := FromEnv() + if opts.MaxOpenConns != 11 || opts.MaxIdleConns != 3 { + t.Fatalf("pool knobs not applied: %#v", opts) + } +} + +func TestFromEnvExplicitPoolOverridesProfile(t *testing.T) { + t.Setenv("DATABASE_POOL_PROFILE", "io-heavy") + t.Setenv("DATABASE_POOL_CPU_CORES", "4") + t.Setenv("DATABASE_IO_PARALLELISM", "4") + t.Setenv("DATABASE_MAX_OPEN_CONNS", "9") + t.Setenv("DATABASE_MAX_IDLE_CONNS", "2") + + opts := FromEnv() + if opts.MaxOpenConns != 9 || opts.MaxIdleConns != 2 { + t.Fatalf("explicit pool values should win: %#v", opts) + } +} diff --git a/registry/database/src/go/query.go b/registry/database/src/go/query.go new file mode 100644 index 0000000..308aa33 --- /dev/null +++ b/registry/database/src/go/query.go @@ -0,0 +1,251 @@ +package database + +import ( + "errors" + "fmt" + "sort" + "strconv" + "strings" +) + +const ( + maxFieldLen = 128 + maxValueLen = 1024 + maxConditions = 32 +) + +// ColumnMap maps user-facing filter/sort keys to trusted SQL column names. +// Column names must be simple identifiers or dot-qualified identifiers such as +// "users.created_at". +type ColumnMap map[string]string + +// Placeholder returns a placeholder for the 1-based argument position. +type Placeholder func(position int) string + +// QuestionPlaceholder returns "?" for MySQL, SQLite, and other drivers that +// use anonymous placeholders. +func QuestionPlaceholder(_ int) string { + return "?" +} + +// DollarPlaceholder returns PostgreSQL-style placeholders: $1, $2, ... +func DollarPlaceholder(position int) string { + if position < 1 { + position = 1 + } + return "$" + strconv.Itoa(position) +} + +// OrderBy converts a client sort key such as "created_at" or "-created_at" +// into a safe ORDER BY fragment using a whitelist. Empty input returns an empty +// fragment and nil error. +func OrderBy(raw string, columns ColumnMap) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + if err := validateString("sort field", raw, maxFieldLen); err != nil { + return "", err + } + + desc := false + key := raw + if strings.HasPrefix(key, "-") { + desc = true + key = strings.TrimPrefix(key, "-") + } + column, err := lookupColumn(key, columns) + if err != nil { + return "", err + } + direction := "ASC" + if desc { + direction = "DESC" + } + return "ORDER BY " + column + " " + direction, nil +} + +// WhereEqual builds a deterministic WHERE fragment joined by AND. Filter keys +// are mapped through the whitelist; filter values are returned as args and are +// never concatenated into SQL. +func WhereEqual(filters map[string]string, columns ColumnMap, placeholder Placeholder) (string, []any, error) { + if len(filters) == 0 { + return "", nil, nil + } + if len(filters) > maxConditions { + return "", nil, errors.New("database: too many filter conditions") + } + b := NewBuilder(columns, placeholder) + keys := make([]string, 0, len(filters)) + for key := range filters { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if err := b.WhereEqual(key, filters[key]); err != nil { + return "", nil, err + } + } + return b.WhereSQL() +} + +// Builder incrementally constructs safe WHERE and ORDER BY fragments. +type Builder struct { + columns ColumnMap + placeholder Placeholder + where []string + args []any + order string +} + +// NewBuilder creates a query fragment builder. A nil placeholder defaults to +// QuestionPlaceholder. +func NewBuilder(columns ColumnMap, placeholder Placeholder) *Builder { + if placeholder == nil { + placeholder = QuestionPlaceholder + } + return &Builder{ + columns: columns, + placeholder: placeholder, + } +} + +// WhereEqual adds "column = placeholder" for a whitelisted field. +func (b *Builder) WhereEqual(field string, value any) error { + if len(b.where) >= maxConditions { + return errors.New("database: too many filter conditions") + } + column, err := lookupColumn(field, b.columns) + if err != nil { + return err + } + if err := validateArgValue(value); err != nil { + return err + } + b.args = append(b.args, value) + b.where = append(b.where, column+" = "+b.placeholder(len(b.args))) + return nil +} + +// OrderBy sets the ORDER BY fragment from a client sort key. +func (b *Builder) OrderBy(raw string) error { + order, err := OrderBy(raw, b.columns) + if err != nil { + return err + } + b.order = order + return nil +} + +// WhereSQL returns the WHERE fragment and a copy of its args. +func (b *Builder) WhereSQL() (string, []any, error) { + if len(b.where) == 0 { + return "", nil, nil + } + args := append([]any(nil), b.args...) + return "WHERE " + strings.Join(b.where, " AND "), args, nil +} + +// OrderSQL returns the ORDER BY fragment or an empty string. +func (b *Builder) OrderSQL() string { + return b.order +} + +// Reset clears all accumulated query fragments and arguments. It also drops +// references to old argument values so a reused Builder does not keep request +// data alive longer than necessary. +func (b *Builder) Reset() { + b.where = nil + b.args = nil + b.order = "" +} + +// SQL returns the combined WHERE and ORDER BY fragments plus WHERE args. +func (b *Builder) SQL() (string, []any, error) { + where, args, err := b.WhereSQL() + if err != nil { + return "", nil, err + } + switch { + case where == "": + return b.order, args, nil + case b.order == "": + return where, args, nil + default: + return where + " " + b.order, args, nil + } +} + +func lookupColumn(field string, columns ColumnMap) (string, error) { + field = strings.TrimSpace(field) + if err := validateRequiredString("field", field, maxFieldLen); err != nil { + return "", err + } + if columns == nil { + return "", errors.New("database: column whitelist is required") + } + column, ok := columns[field] + if !ok { + return "", fmt.Errorf("database: field is not allowed: %s", field) + } + if err := validateColumn(column); err != nil { + return "", err + } + return column, nil +} + +func validateColumn(column string) error { + if err := validateRequiredString("column", column, maxFieldLen); err != nil { + return err + } + parts := strings.Split(column, ".") + if len(parts) > 3 { + return errors.New("database: column whitelist contains too many identifier segments") + } + for _, part := range parts { + if !isIdentifier(part) { + return errors.New("database: column whitelist contains unsafe identifier") + } + } + return nil +} + +func validateArgValue(value any) error { + switch v := value.(type) { + case string: + return validateString("filter value", v, maxValueLen) + case []byte: + if len(v) > maxValueLen { + return errors.New("database: filter value is too long") + } + } + return nil +} + +func isIdentifier(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == '_': + continue + case i == 0 && isASCIILetter(c): + continue + case i > 0 && (isASCIILetter(c) || isASCIIDigit(c)): + continue + default: + return false + } + } + return true +} + +func isASCIILetter(c byte) bool { + return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') +} + +func isASCIIDigit(c byte) bool { + return '0' <= c && c <= '9' +} diff --git a/registry/database/src/go/query_test.go b/registry/database/src/go/query_test.go new file mode 100644 index 0000000..bd252bd --- /dev/null +++ b/registry/database/src/go/query_test.go @@ -0,0 +1,220 @@ +package database + +import ( + "bytes" + "fmt" + "reflect" + "strings" + "testing" +) + +func testColumns() ColumnMap { + return ColumnMap{ + "id": "users.id", + "email": "users.email", + "created_at": "users.created_at", + "status": "users.status", + } +} + +func TestPlaceholders(t *testing.T) { + if QuestionPlaceholder(12) != "?" { + t.Fatal("question placeholder should ignore position") + } + if DollarPlaceholder(2) != "$2" { + t.Fatal("dollar placeholder mismatch") + } + if DollarPlaceholder(0) != "$1" { + t.Fatal("dollar placeholder should clamp to $1") + } +} + +func TestOrderBy(t *testing.T) { + tests := []struct { + raw string + want string + }{ + {"", ""}, + {"created_at", "ORDER BY users.created_at ASC"}, + {"-created_at", "ORDER BY users.created_at DESC"}, + {" email ", "ORDER BY users.email ASC"}, + } + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + got, err := OrderBy(tt.raw, testColumns()) + if err != nil { + t.Fatalf("OrderBy: %v", err) + } + if got != tt.want { + t.Fatalf("got %q want %q", got, tt.want) + } + }) + } +} + +func TestOrderByRejectsUnsafeInput(t *testing.T) { + tests := []string{ + "created_at DESC", + "created_at;DROP TABLE users", + "missing", + "bad\r\nfield", + strings.Repeat("a", maxFieldLen+1), + } + for _, raw := range tests { + t.Run(raw, func(t *testing.T) { + if _, err := OrderBy(raw, testColumns()); err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestWhereEqual(t *testing.T) { + sql, args, err := WhereEqual(map[string]string{ + "status": "active", + "email": "ada@example.com", + }, testColumns(), DollarPlaceholder) + if err != nil { + t.Fatalf("WhereEqual: %v", err) + } + if sql != "WHERE users.email = $1 AND users.status = $2" { + t.Fatalf("sql = %q", sql) + } + if !reflect.DeepEqual(args, []any{"ada@example.com", "active"}) { + t.Fatalf("args = %#v", args) + } +} + +func TestBuilder(t *testing.T) { + b := NewBuilder(testColumns(), QuestionPlaceholder) + if err := b.WhereEqual("email", "ada@example.com"); err != nil { + t.Fatalf("WhereEqual email: %v", err) + } + if err := b.WhereEqual("status", "active"); err != nil { + t.Fatalf("WhereEqual status: %v", err) + } + if err := b.OrderBy("-created_at"); err != nil { + t.Fatalf("OrderBy: %v", err) + } + sql, args, err := b.SQL() + if err != nil { + t.Fatalf("SQL: %v", err) + } + if sql != "WHERE users.email = ? AND users.status = ? ORDER BY users.created_at DESC" { + t.Fatalf("sql = %q", sql) + } + if !reflect.DeepEqual(args, []any{"ada@example.com", "active"}) { + t.Fatalf("args = %#v", args) + } +} + +func TestBuilderDefaultsPlaceholder(t *testing.T) { + b := NewBuilder(testColumns(), nil) + if err := b.WhereEqual("id", 123); err != nil { + t.Fatalf("WhereEqual: %v", err) + } + sql, args, err := b.WhereSQL() + if err != nil { + t.Fatalf("WhereSQL: %v", err) + } + if sql != "WHERE users.id = ?" { + t.Fatalf("sql = %q", sql) + } + if !reflect.DeepEqual(args, []any{123}) { + t.Fatalf("args = %#v", args) + } +} + +func TestBuilderReset(t *testing.T) { + b := NewBuilder(testColumns(), DollarPlaceholder) + if err := b.WhereEqual("email", "ada@example.com"); err != nil { + t.Fatalf("WhereEqual: %v", err) + } + if err := b.OrderBy("-created_at"); err != nil { + t.Fatalf("OrderBy: %v", err) + } + b.Reset() + sql, args, err := b.SQL() + if err != nil { + t.Fatalf("SQL after reset: %v", err) + } + if sql != "" || args != nil { + t.Fatalf("reset SQL = %q args=%#v", sql, args) + } + if err := b.WhereEqual("status", "active"); err != nil { + t.Fatalf("WhereEqual after reset: %v", err) + } + sql, args, err = b.WhereSQL() + if err != nil { + t.Fatalf("WhereSQL after reset: %v", err) + } + if sql != "WHERE users.status = $1" { + t.Fatalf("sql = %q", sql) + } + if !reflect.DeepEqual(args, []any{"active"}) { + t.Fatalf("args = %#v", args) + } +} + +func TestWhereEqualRejectsTooManyFilters(t *testing.T) { + filters := make(map[string]string, maxConditions+1) + for i := 0; i < maxConditions+1; i++ { + filters[fmt.Sprintf("f%d", i)] = "x" + } + if _, _, err := WhereEqual(filters, testColumns(), QuestionPlaceholder); err == nil { + t.Fatal("expected too many filters error") + } +} + +func TestBuilderRejectsTooManyConditions(t *testing.T) { + columns := make(ColumnMap, maxConditions+1) + for i := 0; i < maxConditions+1; i++ { + name := fmt.Sprintf("f%d", i) + columns[name] = "users." + name + } + b := NewBuilder(columns, QuestionPlaceholder) + for i := 0; i < maxConditions; i++ { + name := fmt.Sprintf("f%d", i) + if err := b.WhereEqual(name, "x"); err != nil { + t.Fatalf("WhereEqual %d: %v", i, err) + } + } + if err := b.WhereEqual(fmt.Sprintf("f%d", maxConditions), "x"); err == nil { + t.Fatal("expected too many conditions error") + } +} + +func TestUnsafeWhitelistColumnRejected(t *testing.T) { + columns := ColumnMap{"name": "users.name; DROP TABLE users"} + if _, err := OrderBy("name", columns); err == nil { + t.Fatal("expected unsafe whitelist error") + } +} + +func TestColumnWhitelistRejectsNonPortableIdentifiers(t *testing.T) { + tests := []ColumnMap{ + {"name": "users.名字"}, + {"name": "app.public.users.name"}, + } + for _, columns := range tests { + if _, err := OrderBy("name", columns); err == nil { + t.Fatalf("expected unsafe whitelist error for %#v", columns) + } + } +} + +func TestWhereEqualRejectsUnsafeValue(t *testing.T) { + if _, _, err := WhereEqual(map[string]string{"email": "a\r\nb"}, testColumns(), QuestionPlaceholder); err == nil { + t.Fatal("expected CRLF value error") + } + if _, _, err := WhereEqual(map[string]string{"email": strings.Repeat("a", maxValueLen+1)}, testColumns(), QuestionPlaceholder); err == nil { + t.Fatal("expected long value error") + } +} + +func TestBuilderRejectsOversizedByteValue(t *testing.T) { + b := NewBuilder(testColumns(), QuestionPlaceholder) + if err := b.WhereEqual("email", bytes.Repeat([]byte("a"), maxValueLen+1)); err == nil { + t.Fatal("expected long byte value error") + } +} diff --git a/registry/database/src/go/transaction.go b/registry/database/src/go/transaction.go new file mode 100644 index 0000000..615bebf --- /dev/null +++ b/registry/database/src/go/transaction.go @@ -0,0 +1,62 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// DBTX is the common subset implemented by *sql.DB and *sql.Tx. Repository +// methods can accept this interface to run either inside or outside a +// transaction. +type DBTX interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) +} + +// Beginner is implemented by *sql.DB and allows tests or adapters to provide a +// transaction-capable database handle. +type Beginner interface { + BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) +} + +// WithinTx runs fn inside a transaction. It commits when fn returns nil, +// rolls back when fn returns an error, and also rolls back before re-panicking +// if fn panics. +func WithinTx(ctx context.Context, db Beginner, opts *sql.TxOptions, fn func(context.Context, *sql.Tx) error) (err error) { + if ctx == nil { + return errors.New("database: context is nil") + } + if db == nil { + return errors.New("database: transaction beginner is nil") + } + if fn == nil { + return errors.New("database: transaction function is nil") + } + + tx, err := db.BeginTx(ctx, opts) + if err != nil { + return errors.New("database: begin transaction failed") + } + + defer func() { + if recovered := recover(); recovered != nil { + _ = tx.Rollback() + panic(recovered) + } + }() + + if err := fn(ctx, tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("database: transaction failed: %w; rollback failed: %v", err, rbErr) + } + return err + } + if err := tx.Commit(); err != nil { + return errors.New("database: commit transaction failed") + } + return nil +} diff --git a/registry/database/src/go/transaction_test.go b/registry/database/src/go/transaction_test.go new file mode 100644 index 0000000..c3d27e6 --- /dev/null +++ b/registry/database/src/go/transaction_test.go @@ -0,0 +1,92 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "testing" +) + +func openTxTestDB(t *testing.T, dsn string) *sql.DB { + t.Helper() + ensureTestDriver() + db, err := sql.Open(testDriverName, dsn) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestWithinTxCommitsOnNilError(t *testing.T) { + resetTxCounters() + db := openTxTestDB(t, "memory") + err := WithinTx(context.Background(), db, nil, func(ctx context.Context, tx *sql.Tx) error { + if tx == nil { + t.Fatal("tx is nil") + } + return nil + }) + if err != nil { + t.Fatalf("WithinTx: %v", err) + } + if testTxCounters.commits.Load() != 1 || testTxCounters.rollbacks.Load() != 0 { + t.Fatalf("unexpected counters commits=%d rollbacks=%d", testTxCounters.commits.Load(), testTxCounters.rollbacks.Load()) + } +} + +func TestWithinTxRollsBackOnError(t *testing.T) { + resetTxCounters() + db := openTxTestDB(t, "memory") + want := errors.New("domain error") + err := WithinTx(context.Background(), db, nil, func(context.Context, *sql.Tx) error { + return want + }) + if !errors.Is(err, want) { + t.Fatalf("expected domain error, got %v", err) + } + if testTxCounters.commits.Load() != 0 || testTxCounters.rollbacks.Load() != 1 { + t.Fatalf("unexpected counters commits=%d rollbacks=%d", testTxCounters.commits.Load(), testTxCounters.rollbacks.Load()) + } +} + +func TestWithinTxRollsBackOnPanic(t *testing.T) { + resetTxCounters() + db := openTxTestDB(t, "memory") + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic") + } + if testTxCounters.commits.Load() != 0 || testTxCounters.rollbacks.Load() != 1 { + t.Fatalf("unexpected counters commits=%d rollbacks=%d", testTxCounters.commits.Load(), testTxCounters.rollbacks.Load()) + } + }() + _ = WithinTx(context.Background(), db, nil, func(context.Context, *sql.Tx) error { + panic("boom") + }) +} + +func TestWithinTxRejectsNilInputs(t *testing.T) { + db := openTxTestDB(t, "memory") + if err := WithinTx(nil, db, nil, func(context.Context, *sql.Tx) error { return nil }); err == nil { + t.Fatal("expected nil context error") + } + if err := WithinTx(context.Background(), nil, nil, func(context.Context, *sql.Tx) error { return nil }); err == nil { + t.Fatal("expected nil db error") + } + if err := WithinTx(context.Background(), db, nil, nil); err == nil { + t.Fatal("expected nil function error") + } +} + +func TestWithinTxBeginAndCommitErrorsAreSanitized(t *testing.T) { + db := openTxTestDB(t, "begin-error") + if err := WithinTx(context.Background(), db, nil, func(context.Context, *sql.Tx) error { return nil }); err == nil { + t.Fatal("expected begin error") + } + + db = openTxTestDB(t, "commit-error") + if err := WithinTx(context.Background(), db, nil, func(context.Context, *sql.Tx) error { return nil }); err == nil { + t.Fatal("expected commit error") + } +} diff --git a/registry/file-upload/src/go/storage.go b/registry/file-upload/src/go/storage.go index c19ac45..d9345b8 100644 --- a/registry/file-upload/src/go/storage.go +++ b/registry/file-upload/src/go/storage.go @@ -18,6 +18,8 @@ var ( ErrInvalidName = errors.New("fileupload: invalid filename") ) +const defaultMemoryStorageMaxFiles = 1024 + // Storage abstracts where and how uploaded files are persisted. Implementations // must reject any name that contains path separators or traversal segments so // that path-traversal attacks cannot escape the storage root. @@ -146,15 +148,18 @@ func (s *LocalStorage) Exists(ctx context.Context, name string) bool { // services, and deployments where persistence is handled elsewhere. type MemoryStorage struct { URLPrefix string + MaxFiles int - mu sync.RWMutex + mu sync.Mutex files map[string][]byte + order []string } // NewMemoryStorage creates an empty MemoryStorage. func NewMemoryStorage(urlPrefix string) *MemoryStorage { return &MemoryStorage{ URLPrefix: urlPrefix, + MaxFiles: defaultMemoryStorageMaxFiles, files: make(map[string][]byte), } } @@ -167,36 +172,106 @@ func (s *MemoryStorage) Save(ctx context.Context, name string, data []byte) (str cp := make([]byte, len(data)) copy(cp, data) s.mu.Lock() + defer s.mu.Unlock() + if s.files == nil { + s.files = make(map[string][]byte) + } + if _, exists := s.files[name]; exists { + s.files[name] = cp + s.touchLocked(name) + return strings.TrimRight(s.URLPrefix, "/") + "/" + name, nil + } + for len(s.files) >= s.maxFilesLocked() { + s.evictOldestLocked() + } s.files[name] = cp - s.mu.Unlock() + s.order = append(s.order, name) return strings.TrimRight(s.URLPrefix, "/") + "/" + name, nil } // Get implements Storage. func (s *MemoryStorage) Get(ctx context.Context, name string) ([]byte, error) { - s.mu.RLock() + if err := safeName(name); err != nil { + return nil, err + } + s.mu.Lock() data, ok := s.files[name] - s.mu.RUnlock() if !ok { + s.mu.Unlock() return nil, ErrFileNotFound } + s.touchLocked(name) out := make([]byte, len(data)) copy(out, data) + s.mu.Unlock() return out, nil } // Delete implements Storage. func (s *MemoryStorage) Delete(ctx context.Context, name string) error { + if err := safeName(name); err != nil { + return err + } s.mu.Lock() delete(s.files, name) + s.removeFromOrderLocked(name) s.mu.Unlock() return nil } // Exists implements Storage. func (s *MemoryStorage) Exists(ctx context.Context, name string) bool { - s.mu.RLock() + if err := safeName(name); err != nil { + return false + } + s.mu.Lock() _, ok := s.files[name] - s.mu.RUnlock() + if ok { + s.touchLocked(name) + } + s.mu.Unlock() return ok } + +func (s *MemoryStorage) maxFilesLocked() int { + if s.MaxFiles <= 0 { + return defaultMemoryStorageMaxFiles + } + return s.MaxFiles +} + +func (s *MemoryStorage) evictOldestLocked() { + for len(s.order) > 0 { + name := s.order[0] + s.order = s.order[1:] + if _, ok := s.files[name]; ok { + delete(s.files, name) + return + } + } + for name := range s.files { + delete(s.files, name) + return + } +} + +func (s *MemoryStorage) touchLocked(name string) { + for i, existing := range s.order { + if existing == name { + copy(s.order[i:], s.order[i+1:]) + s.order[len(s.order)-1] = name + return + } + } + s.order = append(s.order, name) +} + +func (s *MemoryStorage) removeFromOrderLocked(name string) { + for i, existing := range s.order { + if existing == name { + copy(s.order[i:], s.order[i+1:]) + s.order = s.order[:len(s.order)-1] + return + } + } +} diff --git a/registry/file-upload/src/go/storage_test.go b/registry/file-upload/src/go/storage_test.go index e561137..ef0b17a 100644 --- a/registry/file-upload/src/go/storage_test.go +++ b/registry/file-upload/src/go/storage_test.go @@ -49,3 +49,35 @@ func TestStorageMemoryCopiesData(t *testing.T) { t.Fatal("file should not exist after delete") } } + +func TestMemoryStorageEvictsLeastRecentlyUsed(t *testing.T) { + ctx := context.Background() + storage := NewMemoryStorage("/files") + storage.MaxFiles = 2 + + if _, err := storage.Save(ctx, "a.txt", []byte("a")); err != nil { + t.Fatalf("Save a: %v", err) + } + if _, err := storage.Save(ctx, "b.txt", []byte("b")); err != nil { + t.Fatalf("Save b: %v", err) + } + if _, err := storage.Get(ctx, "a.txt"); err != nil { + t.Fatalf("Get a: %v", err) + } + if _, err := storage.Save(ctx, "c.txt", []byte("c")); err != nil { + t.Fatalf("Save c: %v", err) + } + + if !storage.Exists(ctx, "a.txt") { + t.Fatal("recently used file should still exist") + } + if storage.Exists(ctx, "b.txt") { + t.Fatal("least recently used file should be evicted") + } + if !storage.Exists(ctx, "c.txt") { + t.Fatal("new file should exist") + } + if _, err := storage.Get(ctx, "b.txt"); !errors.Is(err, ErrFileNotFound) { + t.Fatalf("Get evicted file = %v, want ErrFileNotFound", err) + } +} diff --git a/registry/index.json b/registry/index.json index 2905080..193e6e0 100644 --- a/registry/index.json +++ b/registry/index.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "name": "Scion", - "version": "0.1.0", + "version": "0.2.0", "description": "Backend pattern registry", "patterns": [ { @@ -38,6 +38,23 @@ "standaloneInclude": ["go.mod", "go.sum"], "status": "ready" }, + { + "id": "database", + "name": "Database Helpers", + "description": "database/sql connection setup, transaction helper, and safe query fragments", + "path": "registry/database", + "languages": ["go"], + "frameworks": [], + "tags": ["database", "sql", "transactions", "connection-pool", "security"], + "version": "0.1.0", + "package": "database", + "source": "registry/database/src/go", + "defaultTarget": "internal/database", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" + }, { "id": "middleware", "name": "HTTP Middleware", diff --git a/registry/middleware/src/go/config.go b/registry/middleware/src/go/config.go index 69ee73a..1d8515d 100644 --- a/registry/middleware/src/go/config.go +++ b/registry/middleware/src/go/config.go @@ -114,7 +114,8 @@ type ProxyOptions struct { TrustedProxies []string // ProxyCount is the number of trusted proxy layers. 0 = trust none. - // If both set, TrustedProxies takes precedence. + // Forwarded headers are considered only when r.RemoteAddr is inside + // TrustedProxies; ProxyCount alone never makes arbitrary clients trusted. ProxyCount int } diff --git a/registry/middleware/src/go/proxy.go b/registry/middleware/src/go/proxy.go index 840365b..740b432 100644 --- a/registry/middleware/src/go/proxy.go +++ b/registry/middleware/src/go/proxy.go @@ -8,14 +8,12 @@ import ( ) const maxProxyCount = 10 +const maxProxyHeaderLen = 1024 // ClientIP extracts the client IP address from the request. -// It first checks the context (set by TrustedProxy middleware). -// If not found, it falls back to RemoteAddr. +// SECURITY: it always returns r.RemoteAddr (with any port stripped). It does +// not trust X-Forwarded-For, X-Real-IP, or values stored in request context. func ClientIP(r *http.Request) string { - if ip, ok := r.Context().Value(clientIPKey).(string); ok && ip != "" { - return ip - } return remoteAddrIP(r) } @@ -84,6 +82,10 @@ func clientIPFromConfig(r *http.Request, cfg proxyConfig) string { if cfg.proxyCount == 0 && len(cfg.nets) == 0 { return remoteAddrIP(r) } + remoteIP := remoteAddrIP(r) + if len(cfg.nets) == 0 || !isTrustedNets(remoteIP, cfg.nets) { + return remoteAddrIP(r) + } // Try X-Forwarded-For first. xff := r.Header.Get("X-Forwarded-For") @@ -94,9 +96,12 @@ func clientIPFromConfig(r *http.Request, cfg proxyConfig) string { if xff == "" { return remoteAddrIP(r) } + if len(xff) > maxProxyHeaderLen || strings.ContainsAny(xff, "\r\n\x00") { + return remoteAddrIP(r) + } // X-Forwarded-For: client, proxy1, proxy2, ..., proxyN - ips := strings.Split(xff, ",") + ips := strings.SplitN(xff, ",", maxProxyCount+2) // ProxyCount mode. if cfg.proxyCount > 0 { diff --git a/registry/middleware/src/go/proxy_test.go b/registry/middleware/src/go/proxy_test.go index a579978..edb082a 100644 --- a/registry/middleware/src/go/proxy_test.go +++ b/registry/middleware/src/go/proxy_test.go @@ -1,6 +1,7 @@ package middleware import ( + "context" "net" "net/http" "net/http/httptest" @@ -20,9 +21,10 @@ func TestClientIPRemoteAddr(t *testing.T) { func TestClientIPProxyCount(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:12345" req.Header.Set("X-Forwarded-For", " 1.1.1.1, 2.2.2.2, 3.3.3.3 ") - opts := ProxyOptions{ProxyCount: 2} + opts := ProxyOptions{TrustedProxies: []string{"10.0.0.0/8"}, ProxyCount: 2} ip := ClientIPWithOptions(req, opts) if ip != "1.1.1.1" { @@ -32,6 +34,7 @@ func TestClientIPProxyCount(t *testing.T) { func TestClientIPCIDRWhitelist(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:12345" req.Header.Set("X-Forwarded-For", "1.1.1.1, 10.0.0.1") opts := ProxyOptions{ @@ -60,10 +63,11 @@ func TestClientIPNoXFF(t *testing.T) { func TestClientIPRealIP(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:12345" req.Header.Set("X-Real-IP", "203.0.113.50") // No X-Forwarded-For, so X-Real-IP is used as fallback. - opts := ProxyOptions{ProxyCount: 1} + opts := ProxyOptions{TrustedProxies: []string{"10.0.0.0/8"}, ProxyCount: 1} ip := ClientIPWithOptions(req, opts) if ip != "203.0.113.50" { @@ -84,6 +88,7 @@ func TestTrustedProxyMiddleware(t *testing.T) { handler := mw(next) req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:12345" req.Header.Set("X-Forwarded-For", "5.5.5.5, 10.0.0.1") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -95,10 +100,11 @@ func TestTrustedProxyMiddleware(t *testing.T) { func TestProxyCountClamp(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:12345" req.Header.Set("X-Forwarded-For", "1.1.1.1, 2.2.2.2, 3.3.3.3, 4.4.4.4, 5.5.5.5") // ProxyCount=100 should be clamped to 10 (maxProxyCount). - opts := ProxyOptions{ProxyCount: 100} + opts := ProxyOptions{TrustedProxies: []string{"10.0.0.0/8"}, ProxyCount: 100} ip := ClientIPWithOptions(req, opts) // With 5 IPs and ProxyCount=10 (clamped): idx = 5 - 10 - 1 = -6 < 0, @@ -108,6 +114,28 @@ func TestProxyCountClamp(t *testing.T) { } } +func TestClientIPIgnoresTrustedProxyContext(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "5.6.7.8:12345" + ctx := context.WithValue(req.Context(), clientIPKey, "1.1.1.1") + req = req.WithContext(ctx) + + if got := ClientIP(req); got != "5.6.7.8" { + t.Fatalf("ClientIP = %q, want RemoteAddr", got) + } +} + +func TestClientIPWithOptionsRejectsUntrustedRemote(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "5.6.7.8:12345" + req.Header.Set("X-Forwarded-For", "1.1.1.1") + + ip := ClientIPWithOptions(req, ProxyOptions{TrustedProxies: []string{"10.0.0.0/8"}}) + if ip != "5.6.7.8" { + t.Fatalf("ClientIPWithOptions = %q, want RemoteAddr", ip) + } +} + func TestIsTrustedProxy(t *testing.T) { tests := []struct { ip string diff --git a/registry/rbac/src/go/manager.go b/registry/rbac/src/go/manager.go index 5cdfee3..93874d8 100644 --- a/registry/rbac/src/go/manager.go +++ b/registry/rbac/src/go/manager.go @@ -17,6 +17,17 @@ var ErrCircularDependency = errors.New("circular dependency detected in role hie // ErrInvalidName is returned when a name fails validation. var ErrInvalidName = errors.New("invalid name: must be non-empty, <= 128 chars, no CRLF or null bytes") +// ErrLimitExceeded is returned when an in-memory collection reaches its +// safety cap. +var ErrLimitExceeded = errors.New("capacity limit exceeded") + +const ( + maxRoles = 10000 + maxUsers = 10000 + maxRoleParents = 64 + maxRolePermissions = 256 +) + // Manager manages roles, users, and permission checks. // All methods are safe for concurrent use. type Manager struct { @@ -39,6 +50,9 @@ func (m *Manager) AddRole(role *Role) error { if role == nil || !validateName(role.Name) { return ErrInvalidName } + if len(role.Permissions) > maxRolePermissions || len(role.Parents) > maxRoleParents { + return ErrLimitExceeded + } for _, p := range role.Permissions { if !validateName(p.Resource) || !validateName(p.Action) { return ErrInvalidName @@ -56,6 +70,9 @@ func (m *Manager) AddRole(role *Role) error { if _, exists := m.roles[role.Name]; exists { return ErrRoleExists } + if len(m.roles) >= maxRoles { + return ErrLimitExceeded + } // Check for circular dependencies before adding. for _, parent := range role.Parents { @@ -150,6 +167,9 @@ func (m *Manager) AssignRole(userID, roleName string) error { user, exists := m.users[userID] if !exists { + if len(m.users) >= maxUsers { + return ErrLimitExceeded + } user = &User{ID: userID} m.users[userID] = user } diff --git a/registry/rbac/src/go/manager_test.go b/registry/rbac/src/go/manager_test.go index 1210ecc..dd17d61 100644 --- a/registry/rbac/src/go/manager_test.go +++ b/registry/rbac/src/go/manager_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "strconv" "strings" "sync" "testing" @@ -243,3 +244,48 @@ func TestConcurrentAccess(t *testing.T) { } wg.Wait() } + +func TestManagerCapacityLimits(t *testing.T) { + m := NewManager() + for i := 0; i < maxRoles; i++ { + name := "role" + strconv.Itoa(i) + if err := m.AddRole(&Role{Name: name}); err != nil { + t.Fatalf("AddRole %d: %v", i, err) + } + } + if err := m.AddRole(&Role{Name: "overflow"}); err != ErrLimitExceeded { + t.Fatalf("expected ErrLimitExceeded for role overflow, got %v", err) + } + + users := NewManager() + if err := users.AddRole(&Role{Name: "base"}); err != nil { + t.Fatalf("AddRole base: %v", err) + } + for i := 0; i < maxUsers; i++ { + if err := users.AssignRole("user"+strconv.Itoa(i), "base"); err != nil { + t.Fatalf("AssignRole %d: %v", i, err) + } + } + if err := users.AssignRole("overflow", "base"); err != ErrLimitExceeded { + t.Fatalf("expected ErrLimitExceeded for user overflow, got %v", err) + } +} + +func TestRoleSliceLimits(t *testing.T) { + m := NewManager() + perms := make([]Permission, maxRolePermissions+1) + for i := range perms { + perms[i] = Permission{Resource: "r", Action: "a"} + } + if err := m.AddRole(&Role{Name: "too-many-perms", Permissions: perms}); err != ErrLimitExceeded { + t.Fatalf("expected ErrLimitExceeded for permissions, got %v", err) + } + + parents := make([]string, maxRoleParents+1) + for i := range parents { + parents[i] = "parent" + strconv.Itoa(i) + } + if err := m.AddRole(&Role{Name: "too-many-parents", Parents: parents}); err != ErrLimitExceeded { + t.Fatalf("expected ErrLimitExceeded for parents, got %v", err) + } +}