Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
32259f9
fix(security): prevent path traversal in script execution
thbkrkr Jun 2, 2026
5d4ad6f
fix(security): gate /docker behind -enableDocker flag
thbkrkr Jun 2, 2026
cd3a949
fix(security): drop invalid wildcard+credentials CORS combo
thbkrkr Jun 2, 2026
d8e4b37
fix(security): compare API key in constant time
thbkrkr Jun 2, 2026
ca898bf
fix(security): warn when running without authentication
thbkrkr Jun 2, 2026
c064f08
docs(example): build JSON safely with jq in example scripts
thbkrkr Jun 2, 2026
56245fc
fix: handle script execution errors in POST handler
thbkrkr Jun 2, 2026
4f03acd
fix: return 204 No Content for favicon
thbkrkr Jun 2, 2026
467766d
fix: treat any stat error as missing index in indexExists
thbkrkr Jun 2, 2026
8a95b79
fix: log and exit on server error instead of busy-looping
thbkrkr Jun 2, 2026
81f8cb7
refactor: expose /version without authentication
thbkrkr Jun 2, 2026
b833b7d
refactor: deduplicate GET/POST script execution handlers
thbkrkr Jun 2, 2026
ae9e4e9
refactor: unify logging on logrus
thbkrkr Jun 2, 2026
67f1701
refactor: pass apiDir to handlers by value
thbkrkr Jun 2, 2026
334173c
refactor: single static walk and real error propagation in ListResources
thbkrkr Jun 2, 2026
ad69a4b
fix: derive listed URL scheme from the request
thbkrkr Jun 2, 2026
a77bedd
fix(security): configurable basic-auth user, no default API key
thbkrkr Jun 2, 2026
ecb72f2
build: modernize Docker build with multi-stage Go 1.25
thbkrkr Jun 2, 2026
49bd455
build: drop release target referencing missing release.sh
thbkrkr Jun 2, 2026
b1b0db2
docs: document flags, endpoints, auth and security in README
thbkrkr Jun 2, 2026
f1c7222
test: cover path traversal, POST, invalid JSON and /ls
thbkrkr Jun 2, 2026
06a9052
test: use keyed struct literals and drop dead reader var
thbkrkr Jun 2, 2026
074ec6e
feat: remove the /docker endpoint
thbkrkr Jun 2, 2026
25b663a
refactor: remove redundant ConfigRuntime
thbkrkr Jun 2, 2026
17bbcbf
test: simplify HTTP test helpers
thbkrkr Jun 2, 2026
c17a135
docker
thbkrkr Jun 2, 2026
8489c7b
refactor: drop favicon route and slim CORS headers
thbkrkr Jun 2, 2026
4541c2a
docs(example): remove inception self-calling demo
thbkrkr Jun 2, 2026
9e54589
test: fold test/ helper package into main_test.go
thbkrkr Jun 2, 2026
72349c7
docs(example): fix dead Ractive CDN URL in date.html
thbkrkr Jun 2, 2026
cacfab6
refactor: flatten handlers and middlewares into the main package
thbkrkr Jun 2, 2026
215ec32
refactor: move source into app/ subpackage and flatten example/
thbkrkr Jun 2, 2026
8807d56
fix: align paths with flattened example/ layout
thbkrkr Jun 2, 2026
39dfd2b
feat: add helm chart and helm Makefile targets
thbkrkr Jun 5, 2026
0315f8d
build: bump base images, wire example image and helm values
thbkrkr Jun 6, 2026
aeaa7ca
docs: update PR description
thbkrkr Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
FROM alpine:3.7
# Build stage
FROM golang:1.26.4-alpine AS build

WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .

ARG GIT_COMMIT=undefined
ARG BUILD_DATE=undefined
RUN CGO_ENABLED=0 go build \
-ldflags "-X main.gitCommit=${GIT_COMMIT} -X main.buildDate=${BUILD_DATE}" \
-o /go-apish ./app

# Runtime stage
FROM alpine:3.23

RUN apk --no-cache add bash jq curl
COPY go-apish /go-apish
CMD ["/go-apish"]
COPY --from=build /go-apish /go-apish

EXPOSE 4242
ENTRYPOINT ["/go-apish"]
46 changes: 28 additions & 18 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,32 +1,42 @@
GIT_COMMIT = $(shell git rev-parse --short HEAD)
BUILD_DATE = $(shell date '+%Y%m%d-%H%M%S')
LDFLAGS = -X main.gitCommit=$(GIT_COMMIT) -X main.buildDate=$(BUILD_DATE)

build: build-binary build-image
binary:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o go-apish ./app

build-binary:
docker run --rm \
-w /go/src/github.com/thbkrkr/go-apish \
-v $(shell pwd):/go/src/github.com/thbkrkr/go-apish \
-e CGO_ENABLED=0 -e GOOS=linux \
-ti golang:1.6.2 \
go build -a -installsuffix cgo \
-ldflags "-X=main.gitCommit=$(GIT_COMMIT) -X=main.buildDate=$(BUILD_DATE)"

build-image:
@docker build --rm -t krkr/apish .

release:
./release.sh $(GIT_COMMIT)
test:
go test ./...

push:
docker push krkr/apish

build:
docker build --rm \
--build-arg GIT_COMMIT=$(GIT_COMMIT) \
--build-arg BUILD_DATE=$(BUILD_DATE) \
-t krkr/apish .

HELM_RELEASE = apish

helm-package:
helm package helm

helm-install:
helm install $(HELM_RELEASE) helm

helm-upgrade:
helm upgrade $(HELM_RELEASE) helm

helm-uninstall:
helm uninstall $(HELM_RELEASE)

helm-render:
helm template $(HELM_RELEASE) helm

run:
docker run -d \
-v $$(pwd)/example:/api \
-p 80:4242 \
krkr/apish

golive:
gohere
golive -apiDir=example/api
18 changes: 18 additions & 0 deletions PR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## Summary

- **Security:** prevent path traversal in script execution; fix wildcard+credentials CORS combo; compare API key in constant time; gate `/docker` behind a flag then remove it entirely; configurable basic-auth user with no default API key; warn when running unauthenticated
- **Fixes:** handle errors in POST handler; return 204 for favicon; treat any stat error as missing index; log and exit on server error; derive `/ls` URL scheme from the request
- **Refactor:** expose `/version` without auth; deduplicate GET/POST exec handlers; unify logging on logrus; single static walk with real error propagation; drop favicon route and slim CORS headers
- **Build:** multi-stage Dockerfile with Go 1.25; drop dead `release` target
- **Docs:** KISS README rewrite with inline flag comments, real curl output, layout tree; example scripts build JSON safely with `jq`
- **Tests:** add coverage for path traversal, POST, invalid JSON, `/ls`, script failure, wrong credentials; fix broken `apiDir` path after restructuring; simplify HTTP helpers
- **Helm:** minimal chart (Deployment + Service) with configurable image, port, auth flags; `helm-*` Makefile targets using `apish` as release name
- **Build:** bump Go 1.25 → 1.26.4 and alpine 3.20 → 3.23; tag example image as `krkr/apish:example`; add `push` and `port-forward` targets to example Makefile

## Test plan

- [ ] `go test ./app/...` passes
- [ ] `helm template` renders without errors (`make helm-render`)
- [ ] `./go-apish -apiDir=example` serves scripts and static files
- [ ] Auth is required when `-password` is set; server warns when it is not
- [ ] `make -C example build && make -C example run` serves the example image on port 80
78 changes: 71 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,77 @@
# apish - Rest API for shell scripts
# apish — REST API for shell scripts

Write shell scripts that return JSON ([example](example/api/time/date.sh)).
Write shell scripts that return JSON ([example](example/time/date.sh)).

Serve static files from [api/_static](example/api/_static) directory.
Serve static files from [_static](example/_static) directory.

Build
## Run

make build
```sh
./go-apish \
-port=4242 \ # HTTP port
-apiDir=example \ # directory of .sh scripts and _static files
-user=zuperadmin \ # basic-auth username
-password=secret \ # basic-auth password (empty = no auth)
-apiKey=mykey # X-Auth header key (empty = disabled)
```

Run
```sh
make binary # build ./go-apish
make build # build krkr/apish Docker image
make run # run image, mounting ./example as /api on port 80
```

make run
## Example

### Layout

```
<apiDir>/
time/date.sh → GET /api/time/date
test/param.sh → GET /api/test/param?q=<value>
test/post.sh → POST /api/test/post
_static/index.html → GET /s/
```

### Endpoints

```sh
# build info (no auth)
❯ curl localhost:4242/version
{"build_date":"20260602-233836","git_commit":"cacfab6"}

# list available API URLs
❯ curl -s localhost:4242/ls | jq '.api[]' -r
http://localhost:4242/api/test/invalid-json
http://localhost:4242/api/test/param
http://localhost:4242/api/test/post
http://localhost:4242/api/time/date

# run a script (GET)
❯ curl localhost:4242/api/time/date
{"date":1780435972,"human_date":"Tue Jun 2 23:32:52 CEST 2026"}

# run a script (GET, optional ?q= passed as $1)
❯ curl localhost:4242/api/test/param?q=hello
{"param":"hello"}

# run a script (POST, request body piped to stdin)
❯ curl -d '{"key":"42"}' localhost:4242/api/test/post
{"jackpot": "42"}

# serve static files from <apiDir>/_static
❯ curl localhost:4242/s/ -s | head -1
<!doctype html>
```

Invalid JSON from a script → `400`. Non-zero exit → `500`.

```sh
❯ curl localhost:4242/api/test/invalid-json
< HTTP/1.1 400 Bad Request
{"error":"Invalid JSON"}

❯ curl localhost:4242/api/test/fail
< HTTP/1.1 500 Internal Server Error
{"error":"exit status 1"}
```
27 changes: 27 additions & 0 deletions app/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package main

import (
"crypto/subtle"

"github.com/gin-gonic/gin"
)

var AuthHeaderKey = "X-Auth"

func AuthMiddleware(apiKey string, accounts gin.Accounts) gin.HandlerFunc {
basicAuth := gin.BasicAuthForRealm(accounts, "")

return func(c *gin.Context) {
// Try header auth when a key is configured, comparing in constant time
// to avoid leaking the key through response-timing differences. An
// empty key disables header auth (so it can't match a missing header).
if apiKey != "" {
got := c.Request.Header.Get(AuthHeaderKey)
if subtle.ConstantTimeCompare([]byte(got), []byte(apiKey)) == 1 {
return
}
}
// Fall back to basic auth
basicAuth(c)
}
}
20 changes: 20 additions & 0 deletions app/cors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package main

import "github.com/gin-gonic/gin"

func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Open API: wildcard origin (no credentials, which browsers would
// reject alongside "*"). Only the methods and headers this API
// actually uses are advertised.
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Auth")

if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(200)
} else {
c.Next()
}
}
}
102 changes: 102 additions & 0 deletions app/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)

type ExecHandler struct {
ApiDir string
}

// scriptPath resolves the wildcard request path to an absolute `.sh` path and
// guarantees it stays within ApiDir, preventing path-traversal escapes such as
// `/api/../../../tmp/evil`. It returns false when the path escapes ApiDir.
func (h *ExecHandler) scriptPath(reqPath string) (string, bool) {
base, err := filepath.Abs(h.ApiDir)
if err != nil {
return "", false
}
// filepath.Join cleans the result, collapsing any `..` segments.
script := filepath.Join(base, reqPath+".sh")
if script != base && !strings.HasPrefix(script, base+string(os.PathSeparator)) {
return "", false
}
return script, true
}

// resolve validates the request path and ensures the target script exists,
// writing a 404 and returning ok=false when it cannot be served.
func (h *ExecHandler) resolve(c *gin.Context) (string, bool) {
path := c.Param("path")
script, ok := h.scriptPath(path)
if !ok {
c.JSON(404, gin.H{"error": "Resource not found"})
logrus.Errorf("invalid resource path: %s", path)
return "", false
}
if _, err := os.Stat(script); os.IsNotExist(err) {
c.JSON(404, gin.H{"error": "Resource not found"})
logrus.Errorf("resource not found: %s", script)
return "", false
}
return script, true
}

// run executes cmd, which is expected to print JSON to stdout, and writes the
// parsed JSON (or an appropriate error) to the response.
func (h *ExecHandler) run(c *gin.Context, script string, cmd *exec.Cmd) {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
logrus.Errorf("executing `%s`: %s: %s", script, err, stderr.String())
return
}

var payload any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
c.JSON(400, gin.H{"error": "Invalid JSON"})
logrus.Errorf("invalid JSON for `%s`: %s", script, stdout.Bytes())
return
}

c.JSON(200, payload)
}

// ExecScript runs the script for a GET request, optionally passing the `q`
// query parameter as the script's first argument.
func (h *ExecHandler) ExecScript(c *gin.Context) {
script, ok := h.resolve(c)
if !ok {
return
}

cmd := exec.Command(script)
if q, isParam := c.Request.URL.Query()["q"]; isParam {
cmd = exec.Command(script, q[0])
}
h.run(c, script, cmd)
}

// PostExecScript runs the script for a POST request, piping the request body
// to the script's stdin.
func (h *ExecHandler) PostExecScript(c *gin.Context) {
script, ok := h.resolve(c)
if !ok {
return
}

cmd := exec.Command(script)
cmd.Stdin = c.Request.Body
h.run(c, script, cmd)
}
21 changes: 21 additions & 0 deletions app/exec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package main

import "testing"

func TestScriptPathStaysWithinApiDir(t *testing.T) {
h := &ExecHandler{ApiDir: "../example"}

cases := map[string]bool{
"/time/date": true, // normal script
"/test/param": true, // nested script
"/../secret": false, // climbs out of ApiDir
"/../../../etc/passwd": false, // deep traversal
"/time/../../../tmp/x": false, // traversal after a valid segment
}

for reqPath, wantOK := range cases {
if _, ok := h.scriptPath(reqPath); ok != wantOK {
t.Errorf("scriptPath(%q) ok = %v, want %v", reqPath, ok, wantOK)
}
}
}
Loading