From 4079cc31bcf0c06cc67b5a69d278e1303cab5da6 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 22:17:26 +0900 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20security=20hardening=20=E2=80=94?= =?UTF-8?q?=20XSS,=20SQL=20injection,=20root=20containers,=20pinned=20acti?= =?UTF-8?q?ons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prevent XSS by sanitizing Content-Type in API gateway responses - Add SQL identifier validation in shared ResourceStore constructor - Run containers as non-root user in Dockerfile and Dockerfile.dev - Pin all GitHub Actions to commit SHAs in cd, lint, release-drafter, smithy-sync - Bump Next.js from 16.2.3 to 16.2.4 --- .github/workflows/cd.yml | 16 ++--- .github/workflows/lint.yml | 6 +- .github/workflows/release-drafter.yml | 2 +- .github/workflows/smithy-sync.yml | 6 +- docker/Dockerfile | 3 + docker/Dockerfile.dev | 3 + internal/gateway/router.go | 13 +++- internal/shared/store.go | 19 ++++++ web/package-lock.json | 92 +++++++++++++++------------ web/package.json | 2 +- 10 files changed, 104 insertions(+), 58 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index df0076a..c16f611 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -25,13 +25,13 @@ jobs: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: ref: ${{ github.event.workflow_run.head_sha }} - name: Docker metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf with: images: ghcr.io/${{ github.repository }} tags: | @@ -39,17 +39,17 @@ jobs: type=raw,value=${{ github.event.workflow_run.head_branch }},enable=${{ startsWith(github.event.workflow_run.head_branch, 'v') }},suffix=-${{ matrix.arch }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f with: context: . file: docker/Dockerfile @@ -69,7 +69,7 @@ jobs: steps: - name: Docker metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf with: images: ghcr.io/${{ github.repository }} tags: | @@ -77,10 +77,10 @@ jobs: type=raw,value=${{ github.event.workflow_run.head_branch }},enable=${{ startsWith(github.event.workflow_run.head_branch, 'v') }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2e395e7..4fc093f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,10 +14,10 @@ jobs: name: golangci-lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c with: go-version: "1.26" @@ -25,7 +25,7 @@ jobs: run: sudo apt-get install -y libsqlite3-dev - name: Run golangci-lint - uses: golangci/golangci-lint-action@v8 + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 with: version: latest args: --timeout=5m diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 9391388..ba1d6d0 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: release-drafter/release-drafter@v6 + - uses: release-drafter/release-drafter@67e173cadb2fbd3de94f4a861e0c48c913b462ae with: config-name: release-drafter.yml env: diff --git a/.github/workflows/smithy-sync.yml b/.github/workflows/smithy-sync.yml index b372256..19a2954 100644 --- a/.github/workflows/smithy-sync.yml +++ b/.github/workflows/smithy-sync.yml @@ -13,10 +13,10 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c with: go-version: "1.26" @@ -52,7 +52,7 @@ jobs: - name: Create Pull Request if: steps.changes.outputs.changed == 'true' - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 with: commit-message: "chore: sync Smithy models and regenerate code" title: "chore: weekly Smithy model sync" diff --git a/docker/Dockerfile b/docker/Dockerfile index 2457088..8ccb9e3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -21,6 +21,7 @@ RUN go build -o /codegen ./cmd/codegen # Stage 3: Runtime FROM alpine:3.20 RUN apk add --no-cache sqlite-libs ca-certificates +RUN adduser -D -H -h /app appuser WORKDIR /app COPY --from=go-builder /devcloud /app/devcloud COPY --from=go-builder /codegen /app/codegen @@ -28,6 +29,8 @@ COPY --from=web-builder /app/web/out /app/web/out COPY devcloud.yaml /app/devcloud.yaml COPY smithy-models/ /app/smithy-models/ COPY internal/codegen/templates/ /app/templates/ +RUN chown -R appuser:appuser /app +USER appuser EXPOSE 4747 VOLUME /app/data diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 76a34e1..ba5322c 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,9 +1,12 @@ FROM golang:1.26-alpine RUN apk add --no-cache gcc musl-dev sqlite-dev +RUN adduser -D -H -h /app appuser WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . +RUN chown -R appuser:appuser /app +USER appuser ENV CGO_ENABLED=1 RUN go build -o /app/devcloud ./cmd/devcloud EXPOSE 4747 diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 8d250b3..a68e32d 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -45,9 +45,18 @@ func (sr *ServiceRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { for k, v := range resp.Headers { w.Header().Set(k, v) } - if resp.ContentType != "" { - w.Header().Set("Content-Type", resp.ContentType) + ct := resp.ContentType + if ct == "" { + ct = w.Header().Get("Content-Type") } + if ct == "" { + ct = "application/octet-stream" + } + // Prevent XSS: never serve API responses as text/html. + if strings.HasPrefix(strings.ToLower(ct), "text/html") { + ct = "text/plain; charset=utf-8" + } + w.Header().Set("Content-Type", ct) w.WriteHeader(resp.StatusCode) _, _ = w.Write(resp.Body) } diff --git a/internal/shared/store.go b/internal/shared/store.go index b82078d..d67fc4d 100644 --- a/internal/shared/store.go +++ b/internal/shared/store.go @@ -3,6 +3,9 @@ package shared import ( + "fmt" + "strings" + "github.com/skyoo2003/devcloud/internal/storage/sqlite" ) @@ -19,9 +22,25 @@ type ResourceStore[T any] struct { } func NewResourceStore[T any](db *sqlite.Store, table, idCol, cols string, scanner func(Scanner) (T, error)) *ResourceStore[T] { + validateIdentifier(table, "table") + validateIdentifier(idCol, "idCol") + for _, c := range strings.Split(cols, ",") { + validateIdentifier(strings.TrimSpace(c), "col") + } return &ResourceStore[T]{db: db, table: table, idCol: idCol, cols: cols, scanner: scanner} } +func validateIdentifier(s, kind string) { + if len(s) == 0 { + panic(fmt.Sprintf("shared: empty %s identifier", kind)) + } + for _, r := range s { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') { + panic(fmt.Sprintf("shared: invalid %s identifier: %q", kind, s)) + } + } +} + func (s *ResourceStore[T]) DB() *sqlite.Store { return s.db } func (s *ResourceStore[T]) Get(id string) (T, error) { diff --git a/web/package-lock.json b/web/package-lock.json index 6cfefca..6d5729a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,7 +13,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.7.0", - "next": "16.2.3", + "next": "^16.2.4", "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.1.2", @@ -1637,9 +1637,9 @@ } }, "node_modules/@next/env": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.3.tgz", - "integrity": "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1653,9 +1653,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz", - "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", "cpu": [ "arm64" ], @@ -1669,9 +1669,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz", - "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", "cpu": [ "x64" ], @@ -1685,12 +1685,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz", - "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1701,12 +1704,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz", - "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1717,12 +1723,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz", - "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1733,12 +1742,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz", - "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1749,9 +1761,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz", - "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", "cpu": [ "arm64" ], @@ -1765,9 +1777,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz", - "integrity": "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", "cpu": [ "x64" ], @@ -7021,12 +7033,12 @@ } }, "node_modules/next": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz", - "integrity": "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", "dependencies": { - "@next/env": "16.2.3", + "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -7040,14 +7052,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.3", - "@next/swc-darwin-x64": "16.2.3", - "@next/swc-linux-arm64-gnu": "16.2.3", - "@next/swc-linux-arm64-musl": "16.2.3", - "@next/swc-linux-x64-gnu": "16.2.3", - "@next/swc-linux-x64-musl": "16.2.3", - "@next/swc-win32-arm64-msvc": "16.2.3", - "@next/swc-win32-x64-msvc": "16.2.3", + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { diff --git a/web/package.json b/web/package.json index a7eb7d1..0794f62 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.7.0", - "next": "16.2.3", + "next": "^16.2.4", "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.1.2", From 27e935a174dfdf634363b2c4bb34e6e65edbeab5 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 22:21:32 +0900 Subject: [PATCH 02/10] fix: address code review feedback on security hardening - Change validateIdentifier from panic to returning error in NewResourceStore - Add TrimSpace to Content-Type before HTML check to prevent whitespace bypass - Revert Next.js to exact version pin (16.2.4) instead of caret range --- internal/gateway/router.go | 1 + internal/shared/store.go | 23 +++++++++++++++-------- internal/shared/store_test.go | 4 +++- web/package.json | 2 +- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/gateway/router.go b/internal/gateway/router.go index a68e32d..6487e01 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -53,6 +53,7 @@ func (sr *ServiceRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { ct = "application/octet-stream" } // Prevent XSS: never serve API responses as text/html. + ct = strings.TrimSpace(ct) if strings.HasPrefix(strings.ToLower(ct), "text/html") { ct = "text/plain; charset=utf-8" } diff --git a/internal/shared/store.go b/internal/shared/store.go index d67fc4d..6735030 100644 --- a/internal/shared/store.go +++ b/internal/shared/store.go @@ -21,24 +21,31 @@ type ResourceStore[T any] struct { scanner func(Scanner) (T, error) } -func NewResourceStore[T any](db *sqlite.Store, table, idCol, cols string, scanner func(Scanner) (T, error)) *ResourceStore[T] { - validateIdentifier(table, "table") - validateIdentifier(idCol, "idCol") +func NewResourceStore[T any](db *sqlite.Store, table, idCol, cols string, scanner func(Scanner) (T, error)) (*ResourceStore[T], error) { + if err := validateIdentifier(table, "table"); err != nil { + return nil, err + } + if err := validateIdentifier(idCol, "idCol"); err != nil { + return nil, err + } for _, c := range strings.Split(cols, ",") { - validateIdentifier(strings.TrimSpace(c), "col") + if err := validateIdentifier(strings.TrimSpace(c), "col"); err != nil { + return nil, err + } } - return &ResourceStore[T]{db: db, table: table, idCol: idCol, cols: cols, scanner: scanner} + return &ResourceStore[T]{db: db, table: table, idCol: idCol, cols: cols, scanner: scanner}, nil } -func validateIdentifier(s, kind string) { +func validateIdentifier(s, kind string) error { if len(s) == 0 { - panic(fmt.Sprintf("shared: empty %s identifier", kind)) + return fmt.Errorf("shared: empty %s identifier", kind) } for _, r := range s { if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') { - panic(fmt.Sprintf("shared: invalid %s identifier: %q", kind, s)) + return fmt.Errorf("shared: invalid %s identifier: %q", kind, s) } } + return nil } func (s *ResourceStore[T]) DB() *sqlite.Store { return s.db } diff --git a/internal/shared/store_test.go b/internal/shared/store_test.go index 61a8235..7c38756 100644 --- a/internal/shared/store_test.go +++ b/internal/shared/store_test.go @@ -38,7 +38,9 @@ func newTestResourceStore(t *testing.T) *ResourceStore[testItem] { db, err := sqlite.Open(dbPath, testMigrations) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) - return NewResourceStore[testItem](db, "items", "id", "id, name", testScanner) + rs, err := NewResourceStore[testItem](db, "items", "id", "id, name", testScanner) + require.NoError(t, err) + return rs } func TestResourceStore_GetNotFound(t *testing.T) { diff --git a/web/package.json b/web/package.json index 0794f62..f7c6d00 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.7.0", - "next": "^16.2.4", + "next": "16.2.4", "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.1.2", From 5c6a148ca093b6da28af2f92c70598962d9b6c65 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 22:22:16 +0900 Subject: [PATCH 03/10] style: refactor validateIdentifier to satisfy staticcheck QF1001 --- internal/shared/store.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/shared/store.go b/internal/shared/store.go index 6735030..ba5ba02 100644 --- a/internal/shared/store.go +++ b/internal/shared/store.go @@ -41,7 +41,11 @@ func validateIdentifier(s, kind string) error { return fmt.Errorf("shared: empty %s identifier", kind) } for _, r := range s { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') { + isLower := r >= 'a' && r <= 'z' + isUpper := r >= 'A' && r <= 'Z' + isDigit := r >= '0' && r <= '9' + isUnderscore := r == '_' + if !(isLower || isUpper || isDigit || isUnderscore) { return fmt.Errorf("shared: invalid %s identifier: %q", kind, s) } } From d56ec2a5b3a4dafe2212bea8239ab1674b75532d Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 22:25:28 +0900 Subject: [PATCH 04/10] style: apply De Morgan's law to satisfy staticcheck QF1001 --- internal/shared/store.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/shared/store.go b/internal/shared/store.go index ba5ba02..ead65f3 100644 --- a/internal/shared/store.go +++ b/internal/shared/store.go @@ -45,7 +45,7 @@ func validateIdentifier(s, kind string) error { isUpper := r >= 'A' && r <= 'Z' isDigit := r >= '0' && r <= '9' isUnderscore := r == '_' - if !(isLower || isUpper || isDigit || isUnderscore) { + if !isLower && !isUpper && !isDigit && !isUnderscore { return fmt.Errorf("shared: invalid %s identifier: %q", kind, s) } } From b1912541b89c211b31b5ec7e0c9a26db45113da3 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 22:33:16 +0900 Subject: [PATCH 05/10] fix: address second round of code review feedback - Use mime.ParseMediaType for robust text/html detection in gateway - Add entrypoint.sh to handle /app/data volume permissions at runtime - Skip empty segments in validateIdentifier to tolerate trailing commas - Add negative tests for NewResourceStore with invalid identifiers --- docker/Dockerfile | 8 +++++++ docker/entrypoint.sh | 8 +++++++ internal/gateway/router.go | 3 ++- internal/shared/store.go | 6 +++++- internal/shared/store_test.go | 40 +++++++++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) create mode 100755 docker/entrypoint.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index 8ccb9e3..7ff1983 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,10 +29,18 @@ COPY --from=web-builder /app/web/out /app/web/out COPY devcloud.yaml /app/devcloud.yaml COPY smithy-models/ /app/smithy-models/ COPY internal/codegen/templates/ /app/templates/ +COPY --from=go-builder /devcloud /app/devcloud +COPY --from=go-builder /codegen /app/codegen +COPY --from=web-builder /app/web/out /app/web/out +COPY devcloud.yaml /app/devcloud.yaml +COPY smithy-models/ /app/smithy-models/ +COPY internal/codegen/templates/ /app/templates/ +COPY docker/entrypoint.sh /app/entrypoint.sh RUN chown -R appuser:appuser /app USER appuser EXPOSE 4747 VOLUME /app/data +ENTRYPOINT ["/app/entrypoint.sh"] CMD ["/app/devcloud", "-config", "/app/devcloud.yaml"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..36e7f2d --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +# Ensure the data directory exists and is writable by appuser. +mkdir -p /app/data +chown appuser:appuser /app/data + +exec "$@" diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 6487e01..655ed5a 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -5,6 +5,7 @@ package gateway import ( "encoding/json" "encoding/xml" + "mime" "net/http" "strings" @@ -54,7 +55,7 @@ func (sr *ServiceRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Prevent XSS: never serve API responses as text/html. ct = strings.TrimSpace(ct) - if strings.HasPrefix(strings.ToLower(ct), "text/html") { + if mediaType, _, parseErr := mime.ParseMediaType(ct); parseErr == nil && strings.EqualFold(mediaType, "text/html") { ct = "text/plain; charset=utf-8" } w.Header().Set("Content-Type", ct) diff --git a/internal/shared/store.go b/internal/shared/store.go index ead65f3..9483d33 100644 --- a/internal/shared/store.go +++ b/internal/shared/store.go @@ -29,7 +29,11 @@ func NewResourceStore[T any](db *sqlite.Store, table, idCol, cols string, scanne return nil, err } for _, c := range strings.Split(cols, ",") { - if err := validateIdentifier(strings.TrimSpace(c), "col"); err != nil { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if err := validateIdentifier(c, "col"); err != nil { return nil, err } } diff --git a/internal/shared/store_test.go b/internal/shared/store_test.go index 7c38756..1bde1a2 100644 --- a/internal/shared/store_test.go +++ b/internal/shared/store_test.go @@ -112,3 +112,43 @@ func TestResourceStore_Count(t *testing.T) { require.NoError(t, err) assert.Equal(t, 1, n) } + +func newTestDB(t *testing.T) *sqlite.Store { + t.Helper() + dbPath := t.TempDir() + "/test.db" + db, err := sqlite.Open(dbPath, testMigrations) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestNewResourceStore_InvalidTable(t *testing.T) { + db := newTestDB(t) + _, err := NewResourceStore[testItem](db, "DROP TABLE items; --", "id", "id", testScanner) + assert.ErrorContains(t, err, "invalid table identifier") +} + +func TestNewResourceStore_InvalidIdCol(t *testing.T) { + db := newTestDB(t) + _, err := NewResourceStore[testItem](db, "items", "id; --", "id", testScanner) + assert.ErrorContains(t, err, "invalid idCol identifier") +} + +func TestNewResourceStore_InvalidCol(t *testing.T) { + db := newTestDB(t) + _, err := NewResourceStore[testItem](db, "items", "id", "id, name; --", testScanner) + assert.ErrorContains(t, err, "invalid col identifier") +} + +func TestNewResourceStore_EmptyTable(t *testing.T) { + db := newTestDB(t) + _, err := NewResourceStore[testItem](db, "", "id", "id", testScanner) + assert.ErrorContains(t, err, "empty table identifier") +} + +func TestNewResourceStore_TrailingComma(t *testing.T) { + db := newTestDB(t) + rs, err := NewResourceStore[testItem](db, "items", "id", "id, name,", testScanner) + require.NoError(t, err) + require.NotNil(t, rs) +} From 3d0d5f5c1fac7da608428c1f2b5fe56c60e7087f Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 22:45:15 +0900 Subject: [PATCH 06/10] fix: address third round of code review feedback - Remove duplicate COPY instructions in Dockerfile - Fix entrypoint to run as root with su-exec for privilege drop - Add positive identifier validation tests (underscores, digits, trailing comma) --- docker/Dockerfile | 9 +-------- docker/entrypoint.sh | 2 +- internal/shared/store_test.go | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7ff1983..18c1056 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -20,7 +20,7 @@ RUN go build -o /codegen ./cmd/codegen # Stage 3: Runtime FROM alpine:3.20 -RUN apk add --no-cache sqlite-libs ca-certificates +RUN apk add --no-cache sqlite-libs ca-certificates su-exec RUN adduser -D -H -h /app appuser WORKDIR /app COPY --from=go-builder /devcloud /app/devcloud @@ -29,15 +29,8 @@ COPY --from=web-builder /app/web/out /app/web/out COPY devcloud.yaml /app/devcloud.yaml COPY smithy-models/ /app/smithy-models/ COPY internal/codegen/templates/ /app/templates/ -COPY --from=go-builder /devcloud /app/devcloud -COPY --from=go-builder /codegen /app/codegen -COPY --from=web-builder /app/web/out /app/web/out -COPY devcloud.yaml /app/devcloud.yaml -COPY smithy-models/ /app/smithy-models/ -COPY internal/codegen/templates/ /app/templates/ COPY docker/entrypoint.sh /app/entrypoint.sh RUN chown -R appuser:appuser /app -USER appuser EXPOSE 4747 VOLUME /app/data diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 36e7f2d..b141d0e 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,4 +5,4 @@ set -e mkdir -p /app/data chown appuser:appuser /app/data -exec "$@" +exec su-exec appuser "$@" diff --git a/internal/shared/store_test.go b/internal/shared/store_test.go index 1bde1a2..797663d 100644 --- a/internal/shared/store_test.go +++ b/internal/shared/store_test.go @@ -152,3 +152,41 @@ func TestNewResourceStore_TrailingComma(t *testing.T) { require.NoError(t, err) require.NotNil(t, rs) } + +func TestNewResourceStore_ValidIdentifiers(t *testing.T) { + db := newTestDB(t) + + tests := []struct { + name string + tableName string + primary string + cols string + }{ + { + name: "underscores", + tableName: "items", + primary: "id", + cols: "id, item_name, created_at", + }, + { + name: "digits_in_identifiers", + tableName: "items", + primary: "id", + cols: "id, name2, col3_v1", + }, + { + name: "trailing_comma", + tableName: "items", + primary: "id", + cols: "id, name,", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rs, err := NewResourceStore[testItem](db, tc.tableName, tc.primary, tc.cols, testScanner) + require.NoError(t, err) + require.NotNil(t, rs) + }) + } +} From d9f20884a43744d6f4d729810c1435855e8bbbbf Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 22:51:51 +0900 Subject: [PATCH 07/10] fix: harden Content-Type XSS guard against comma-separated media types Split on commas and inspect each entry individually so that values like "text/html, text/plain" cannot bypass the HTML sanitization. --- internal/gateway/router.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 655ed5a..e8c7501 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -53,9 +53,21 @@ func (sr *ServiceRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { if ct == "" { ct = "application/octet-stream" } - // Prevent XSS: never serve API responses as text/html. + // Prevent XSS: this gateway serves AWS API responses only (JSON/XML), + // never user-facing HTML. Sanitize any attempt to serve text/html. ct = strings.TrimSpace(ct) - if mediaType, _, parseErr := mime.ParseMediaType(ct); parseErr == nil && strings.EqualFold(mediaType, "text/html") { + htmlLike := false + for _, p := range strings.Split(ct, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if mediaType, _, parseErr := mime.ParseMediaType(p); parseErr == nil && strings.EqualFold(mediaType, "text/html") { + htmlLike = true + break + } + } + if htmlLike { ct = "text/plain; charset=utf-8" } w.Header().Set("Content-Type", ct) From ddac90eff56619790ffe314de5ef4c7138b620a2 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 22:58:16 +0900 Subject: [PATCH 08/10] fix: trim whitespace on table/idCol before validation; gate entrypoint chown - TrimSpace table and idCol in NewResourceStore to match cols behavior - Only chown /app/data if not already owned by appuser --- docker/entrypoint.sh | 4 +++- internal/shared/store.go | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b141d0e..6e88765 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -3,6 +3,8 @@ set -e # Ensure the data directory exists and is writable by appuser. mkdir -p /app/data -chown appuser:appuser /app/data +if [ "$(stat -c %U /app/data 2>/dev/null)" != "appuser" ]; then + chown appuser:appuser /app/data +fi exec su-exec appuser "$@" diff --git a/internal/shared/store.go b/internal/shared/store.go index 9483d33..f0a06bf 100644 --- a/internal/shared/store.go +++ b/internal/shared/store.go @@ -22,6 +22,8 @@ type ResourceStore[T any] struct { } func NewResourceStore[T any](db *sqlite.Store, table, idCol, cols string, scanner func(Scanner) (T, error)) (*ResourceStore[T], error) { + table = strings.TrimSpace(table) + idCol = strings.TrimSpace(idCol) if err := validateIdentifier(table, "table"); err != nil { return nil, err } From 66298c5cacf7a5d7995732b32446aaec53da321e Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 23:03:29 +0900 Subject: [PATCH 09/10] fix: make entrypoint stat fail-open; add whitespace/leading-comma test cases - Use || echo "" fallback for stat to prevent set -e from aborting - Add test cases for whitespace-padded identifiers and leading commas in cols --- docker/entrypoint.sh | 3 ++- internal/shared/store_test.go | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 6e88765..a0ecfb5 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -3,7 +3,8 @@ set -e # Ensure the data directory exists and is writable by appuser. mkdir -p /app/data -if [ "$(stat -c %U /app/data 2>/dev/null)" != "appuser" ]; then +owner=$(stat -c %U /app/data 2>/dev/null || echo "") +if [ "$owner" != "appuser" ]; then chown appuser:appuser /app/data fi diff --git a/internal/shared/store_test.go b/internal/shared/store_test.go index 797663d..31b9d78 100644 --- a/internal/shared/store_test.go +++ b/internal/shared/store_test.go @@ -180,6 +180,18 @@ func TestNewResourceStore_ValidIdentifiers(t *testing.T) { primary: "id", cols: "id, name,", }, + { + name: "whitespace_in_identifiers", + tableName: " items ", + primary: " id ", + cols: " id, name ", + }, + { + name: "leading_comma_in_cols", + tableName: "items", + primary: "id", + cols: ", id, name", + }, } for _, tc := range tests { From 23db896fb865604617f2e1f1c61052c30da451cd Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 23:12:57 +0900 Subject: [PATCH 10/10] fix: normalize cols, extend HTML detection, add version tag comments - Reconstruct cols string from validated identifiers to strip whitespace/commas - Extend Content-Type XSS guard to cover application/xhtml+xml and *+html suffixes - Add inline version tag comments (e.g. # v6) to all pinned action SHAs --- .github/workflows/cd.yml | 16 ++++++++-------- .github/workflows/lint.yml | 6 +++--- .github/workflows/release-drafter.yml | 2 +- .github/workflows/smithy-sync.yml | 6 +++--- internal/gateway/router.go | 9 +++++++-- internal/shared/store.go | 5 ++++- 6 files changed, 26 insertions(+), 18 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index c16f611..bafd73e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -25,13 +25,13 @@ jobs: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.event.workflow_run.head_sha }} - name: Docker metadata id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 with: images: ghcr.io/${{ github.repository }} tags: | @@ -39,17 +39,17 @@ jobs: type=raw,value=${{ github.event.workflow_run.head_branch }},enable=${{ startsWith(github.event.workflow_run.head_branch, 'v') }},suffix=-${{ matrix.arch }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Login to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 with: context: . file: docker/Dockerfile @@ -69,7 +69,7 @@ jobs: steps: - name: Docker metadata id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 with: images: ghcr.io/${{ github.repository }} tags: | @@ -77,10 +77,10 @@ jobs: type=raw,value=${{ github.event.workflow_run.head_branch }},enable=${{ startsWith(github.event.workflow_run.head_branch, 'v') }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Login to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4fc093f..5dd260d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,10 +14,10 @@ jobs: name: golangci-lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: "1.26" @@ -25,7 +25,7 @@ jobs: run: sudo apt-get install -y libsqlite3-dev - name: Run golangci-lint - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8 with: version: latest args: --timeout=5m diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index ba1d6d0..3fb5d3f 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: release-drafter/release-drafter@67e173cadb2fbd3de94f4a861e0c48c913b462ae + - uses: release-drafter/release-drafter@67e173cadb2fbd3de94f4a861e0c48c913b462ae # v6 with: config-name: release-drafter.yml env: diff --git a/.github/workflows/smithy-sync.yml b/.github/workflows/smithy-sync.yml index 19a2954..cff900f 100644 --- a/.github/workflows/smithy-sync.yml +++ b/.github/workflows/smithy-sync.yml @@ -13,10 +13,10 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: "1.26" @@ -52,7 +52,7 @@ jobs: - name: Create Pull Request if: steps.changes.outputs.changed == 'true' - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 with: commit-message: "chore: sync Smithy models and regenerate code" title: "chore: weekly Smithy model sync" diff --git a/internal/gateway/router.go b/internal/gateway/router.go index e8c7501..4175882 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -54,7 +54,7 @@ func (sr *ServiceRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { ct = "application/octet-stream" } // Prevent XSS: this gateway serves AWS API responses only (JSON/XML), - // never user-facing HTML. Sanitize any attempt to serve text/html. + // never user-facing HTML. Sanitize any attempt to serve HTML-like content. ct = strings.TrimSpace(ct) htmlLike := false for _, p := range strings.Split(ct, ",") { @@ -62,7 +62,12 @@ func (sr *ServiceRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { if p == "" { continue } - if mediaType, _, parseErr := mime.ParseMediaType(p); parseErr == nil && strings.EqualFold(mediaType, "text/html") { + mediaType, _, parseErr := mime.ParseMediaType(p) + if parseErr != nil { + continue + } + mtLower := strings.ToLower(mediaType) + if mtLower == "text/html" || mtLower == "application/xhtml+xml" || strings.HasSuffix(mtLower, "+html") { htmlLike = true break } diff --git a/internal/shared/store.go b/internal/shared/store.go index f0a06bf..3692fb7 100644 --- a/internal/shared/store.go +++ b/internal/shared/store.go @@ -30,6 +30,7 @@ func NewResourceStore[T any](db *sqlite.Store, table, idCol, cols string, scanne if err := validateIdentifier(idCol, "idCol"); err != nil { return nil, err } + var validCols []string for _, c := range strings.Split(cols, ",") { c = strings.TrimSpace(c) if c == "" { @@ -38,8 +39,10 @@ func NewResourceStore[T any](db *sqlite.Store, table, idCol, cols string, scanne if err := validateIdentifier(c, "col"); err != nil { return nil, err } + validCols = append(validCols, c) } - return &ResourceStore[T]{db: db, table: table, idCol: idCol, cols: cols, scanner: scanner}, nil + normalizedCols := strings.Join(validCols, ", ") + return &ResourceStore[T]{db: db, table: table, idCol: idCol, cols: normalizedCols, scanner: scanner}, nil } func validateIdentifier(s, kind string) error {