Skip to content

Commit 1abe1f1

Browse files
Merge branch 'master' into fix/api-stacks-optional-auth-strict
2 parents 6e27b6c + 66f4d85 commit 1abe1f1

8 files changed

Lines changed: 389 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,87 @@ jobs:
213213
echo "Configure services, secrets, and port-forwards, then run e.g.:"
214214
echo " go test ./e2e/... -tags e2e -count=1 -timeout 180s"
215215
echo "See CLAUDE.md (Full-stack E2E) for required env vars."
216+
217+
# Cross-repo Layer-1 auth-contract gate. The api owns the CORS allowlist
218+
# and the /auth/exchange + /auth/email/start endpoints — an api-side
219+
# change that drops access-control-allow-credentials would not trigger
220+
# the instanode-web CI on its own, so the browser-level regression
221+
# (2026-05-29 → 2026-05-30) could ship despite green api unit tests.
222+
#
223+
# This job fires a repository_dispatch on instanode-web; instanode-web's
224+
# .github/workflows/auth-contract-e2e.yml listens for the matching
225+
# `auth-contract-e2e-from-api` type and runs the Chromium smoke against
226+
# the same prod targets. The dispatch result will not gate this PR
227+
# mechanically (cross-repo status checks aren't wired here yet — see
228+
# follow-up issue), but it surfaces the failure in the instanode-web
229+
# Actions tab so anyone reviewing the api PR can click through.
230+
#
231+
# Auth: REPO_ACCESS_TOKEN must have `repo` scope on instanode-web. If the
232+
# secret is missing the step soft-skips (warn, don't fail) so the api CI
233+
# stays green during initial rollout — flip the soft-skip to `exit 1`
234+
# once the secret is provisioned on all relevant environments.
235+
dispatch-auth-contract-e2e:
236+
name: Trigger instanode-web auth-contract smoke
237+
runs-on: ubuntu-latest
238+
needs: build-and-test
239+
if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/master')
240+
steps:
241+
- name: Fire repository_dispatch on instanode-web
242+
env:
243+
DISPATCH_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
244+
# SECURITY: avoid interpolating untrusted github.event.* fields
245+
# into the shell. Only stable repo-controlled identifiers are
246+
# exposed and the payload is constructed via printf with
247+
# parameter expansion (no string concatenation of attacker
248+
# input).
249+
SHA: ${{ github.sha }}
250+
PR_NUMBER: ${{ github.event.pull_request.number }}
251+
TRIGGER: ${{ github.event_name }}
252+
run: |
253+
set -euo pipefail
254+
if [ -z "${DISPATCH_TOKEN:-}" ]; then
255+
echo "::warning::REPO_ACCESS_TOKEN not set; skipping cross-repo auth-contract dispatch. " \
256+
"Provision the secret on the api repo (with `repo` scope on instanode-web) to enable Layer-1 gate."
257+
exit 0
258+
fi
259+
# PR_NUMBER may be empty on push events; default to "main".
260+
# Defense-in-depth: enforce numeric PR number even though
261+
# github.event.pull_request.number is an integer assigned by
262+
# GitHub, never user-controlled.
263+
pr="${PR_NUMBER:-main}"
264+
case "$pr" in
265+
main|[0-9]*) ;;
266+
*) echo "::error::unexpected PR_NUMBER value: $pr"; exit 1 ;;
267+
esac
268+
# SHA is a 40-char hex from github.sha — repo-controlled. Validate
269+
# shape to keep the JSON payload trivially-injection-proof.
270+
case "$SHA" in
271+
[0-9a-f]*) ;;
272+
*) echo "::error::unexpected SHA shape: $SHA"; exit 1 ;;
273+
esac
274+
# TRIGGER is github.event_name — a GitHub-controlled enum
275+
# (push|pull_request|schedule|workflow_dispatch|...). Allowlist
276+
# the values this job is reachable from.
277+
case "$TRIGGER" in
278+
push|pull_request) ;;
279+
*) echo "::error::unexpected TRIGGER: $TRIGGER"; exit 1 ;;
280+
esac
281+
payload=$(printf '{"event_type":"auth-contract-e2e-from-api","client_payload":{"api_sha":"%s","api_pr":"%s","trigger":"%s","api_url":"https://api.instanode.dev","web_origin":"https://instanode.dev"}}' \
282+
"$SHA" "$pr" "$TRIGGER")
283+
echo "Dispatching to InstaNode-dev/instanode-web: $payload"
284+
http_code=$(curl -sS -o /tmp/dispatch.out -w '%{http_code}' \
285+
-X POST \
286+
-H "Accept: application/vnd.github+json" \
287+
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
288+
-H "X-GitHub-Api-Version: 2022-11-28" \
289+
https://api.github.com/repos/InstaNode-dev/instanode-web/dispatches \
290+
-d "$payload")
291+
echo "dispatch response: HTTP $http_code"
292+
cat /tmp/dispatch.out || true
293+
# GitHub returns 204 on success. Treat anything else as a soft
294+
# failure during the rollout window — log and pass so a transient
295+
# cross-repo hiccup doesn't red the api PR. Tighten to `exit 1`
296+
# once we have a week of clean runs.
297+
if [ "$http_code" != "204" ]; then
298+
echo "::warning::cross-repo dispatch returned $http_code (expected 204). Not failing the api PR yet."
299+
fi

internal/handlers/openapi.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1623,6 +1623,19 @@ const openAPISpec = `{
16231623
}
16241624
}
16251625
},
1626+
"/auth/exchange": {
1627+
"post": {
1628+
"summary": "Exchange the AUTH-004 bridge cookie for a session JWT",
1629+
"description": "Final leg of the AUTH-004 cross-origin sign-in handshake. The /auth/email/callback and /auth/github/callback handlers set a short-lived HttpOnly auth_exchange_cookie and 302 to https://instanode.dev/login/callback?signed_in=1. The dashboard then makes a credentials:include POST to this endpoint. CORS contract: response MUST include Access-Control-Allow-Origin: https://instanode.dev AND Access-Control-Allow-Credentials: true — the browser blocks the read otherwise. Request MUST be a CORS-simple POST (no custom headers like Accept: application/json), since adding one forces a preflight that PreflightAllowlist may reject. Returns 200 + the bearer JWT (24h, HS256, aud=https://api.instanode.dev) on success. The 2026-05-29 to 2026-05-30 prod-login outage chained three failures along this exact endpoint — documenting it here so any future regression is catchable by the cross-stack contract gate (api PR #202).",
1630+
"requestBody": { "required": false, "description": "No body. The bridge cookie travels in the Cookie header via credentials:include." },
1631+
"responses": {
1632+
"200": { "description": "Cookie verified; JWT minted", "content": { "application/json": { "schema": { "type": "object", "required": ["ok", "token"], "properties": { "ok": { "type": "boolean" }, "token": { "type": "string", "description": "Session JWT — store in localStorage and send as Authorization: Bearer for /api/v1/* calls" } } } } } },
1633+
"400": { "description": "Bridge cookie missing / expired (canonical envelope with error code cookie_missing_or_expired)" },
1634+
"401": { "description": "Cookie present but signature invalid or aud mismatch" },
1635+
"503": { "description": "JWT signing failed (downstream)" }
1636+
}
1637+
}
1638+
},
16261639
"/auth/email/callback": {
16271640
"get": {
16281641
"summary": "Consume a magic link, mint a session JWT, 302 to <return_to>",
@@ -3090,7 +3103,7 @@ const openAPISpec = `{
30903103
"notify_webhook": { "type": "string", "description": "Optional https:// URL fired by POST when the deploy reaches a terminal state (status='healthy' or 'failed'). Lets callers subscribe instead of polling GET /deploy/:id. Rejected with 400 + agent_action if the URL is not https, the hostname is unresolvable, or resolves to a private/loopback/link-local/CGNAT IP (SSRF protection). Payload shape: { event: 'deploy.healthy' | 'deploy.failed', deploy_id, app_id, url, commit_id, build_time, duration_s, error_message? }. 2xx → notify_state='sent'; 4xx → 'failed' (no retry — user URL is broken); 5xx/network → up to 3 retries, then 'failed'." },
30913104
"notify_webhook_secret": { "type": "string", "description": "Optional HMAC-SHA256 signing key. When set, every dispatch includes an X-InstaNode-Signature: sha256=<hex(hmac(secret, body))> header. Stored AES-256-GCM encrypted; plaintext never leaves the request. Omit to dispatch without a signature header." },
30923105
"ttl_policy": { "type": "string", "enum": ["auto_24h", "permanent"], "description": "Wave FIX-J. Sets the deploy's lifecycle. 'auto_24h' (default for new deploys) means the deploy auto-expires 24h from creation; the response's agent_action sentence tells the LLM the three explicit routes to keep it permanent. 'permanent' opts the deploy out of TTL up front — useful for production deploys where the agent already knows the user wants it kept. Anonymous tier is FORCED to auto_24h regardless of caller intent. Team-wide default can be flipped via PATCH /api/v1/team/settings." },
3093-
"redeploy": { "type": "boolean", "default": false, "description": "When true with a matching 'name', replace the existing deployment in place (same app_id + URL, same provider_id) instead of minting a fresh one. The platform looks up the team's most-recent non-terminal deployment whose env_vars._name matches the supplied 'name' (scoped to the resolved 'env'), then routes through the same compute path as POST /deploy/:id/redeploy. Closes the agent-UX gap (2026-05-30): multiple /deploy/new calls for the same logical app used to fan out into N distinct URLs because there was no way to upsert by name. Truthy values: 'true', '1', 'yes' (case-insensitive); anything else is false. Errors: 400 redeploy_requires_name when 'name' is empty; 404 no_existing_deployment_to_redeploy when no live row matches (omit 'redeploy' to create a new deployment, or call GET /api/v1/deployments first to discover the id); 409 not_ready when the matching row exists but has no provider_id yet (initial build still running). Default false: leaving the field absent keeps the legacy fan-out behaviour." }
3106+
"redeploy": { "type": "boolean", "default": false, "description": "When true with a matching 'name', replace the existing deployment in place (same app_id + URL, same provider_id) instead of minting a fresh one. The platform looks up the team's most-recent non-terminal deployment whose env_vars._name matches the supplied 'name' (scoped to the resolved 'env'), then routes through the same compute path as POST /deploy/:id/redeploy. Closes the agent-UX gap (2026-05-30): multiple /deploy/new calls for the same logical app used to fan out into N distinct URLs because there was no way to upsert by name. Truthy values: 'true', '1', 'yes' (case-insensitive); anything else is false. Errors: 404 no_existing_deployment_to_redeploy when no live row matches (note: an empty 'name' is rejected upstream by the standard name_required check, before this flag is even consulted) (omit 'redeploy' to create a new deployment, or call GET /api/v1/deployments first to discover the id); 409 not_ready when the matching row exists but has no provider_id yet (initial build still running). Default false: leaving the field absent keeps the legacy fan-out behaviour." }
30943107
},
30953108
"required": ["tarball", "name"]
30963109
},

internal/handlers/openapi_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,15 @@ func TestOpenAPI_CoversAllRegisteredRoutes(t *testing.T) {
693693
// thinking cookies are a valid auth mechanism — they're not
694694
// (CLAUDE.md "Live API surface" + auth_beareronly_authp0_test.go).
695695
"POST /auth/exchange": true,
696+
// BUG-API-411 (QA 2026-05-29): RFC 9116 security.txt is a
697+
// security-researcher disclosure surface, not an agent-facing
698+
// API. The body is hand-crafted text/plain matching RFC §2.3,
699+
// not JSON, so it has no OpenAPI schema. Both the canonical
700+
// .well-known path and the apex fallback are excluded from the
701+
// public spec on the same rationale. See security_txt.go for
702+
// the builder and security_txt_test.go for the wire contract.
703+
"GET /.well-known/security.txt": true,
704+
"GET /security.txt": true,
696705
}
697706

698707
var missing []string

internal/router/export_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package router
2+
3+
// export_test.go — re-export package-private symbols for the
4+
// _test.go siblings that live in `router_test` (external test
5+
// package). Keeping these in a `_test.go` file means they're
6+
// compiled only during `go test` and never leak into the
7+
// distributed binary.
8+
9+
// ExportedMakeSecurityTxtHandler is the unit-test-facing alias for
10+
// makeSecurityTxtHandler. The handler builder is package-private in
11+
// production because the only legitimate consumer is router.New; the
12+
// alias exists so the patch-coverage gate (100% of changed lines)
13+
// can directly cover the closure body without standing up the full
14+
// router New(...) wiring (which needs Postgres + Redis + gRPC).
15+
var ExportedMakeSecurityTxtHandler = makeSecurityTxtHandler

internal/router/router.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,31 @@ func NewWithHooks(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *mid
546546
// MCP authorization profile — RFC 8414 / OAuth 2.0 Protected Resource Metadata.
547547
app.Get("/.well-known/oauth-protected-resource", handlers.ServeOAuthProtectedResourceMetadata)
548548

549+
// BUG-API-411 (QA 2026-05-29): RFC 9116 — security researchers reach for
550+
// /.well-known/security.txt to find a responsible-disclosure contact
551+
// before filing a public vulnerability report. Pre-fix both api and
552+
// apex returned 404 for both /.well-known/security.txt and /security.txt
553+
// which made the disclosure surface effectively unreachable. We serve
554+
// the same body from BOTH paths so a researcher's first guess works
555+
// regardless of which convention they hit, and the body validates
556+
// cleanly against https://securitytxt.org/ — Contact + Expires + the
557+
// Preferred-Languages and Canonical fields the standard recommends.
558+
//
559+
// Expires is set 1 year from the build_time stamp so the file stays
560+
// fresh as long as the binary is redeployed regularly (each new
561+
// image pushes the window forward). When the binary stalls past its
562+
// expiry the file silently becomes stale-but-still-served — that's
563+
// the right call vs returning 410, which would lock out researchers
564+
// during a deploy freeze.
565+
serveSecurityTxt := makeSecurityTxtHandler(time.Now())
566+
app.Get("/.well-known/security.txt", serveSecurityTxt)
567+
// Some scanners + older guidance hit /security.txt at the root. RFC
568+
// 9116 §3 names the .well-known path as canonical (the file itself
569+
// declares it via the Canonical: field above) but the apex path is
570+
// a documented fallback — serving the same body avoids a needless
571+
// 404 on the legacy path.
572+
app.Get("/security.txt", serveSecurityTxt)
573+
549574
// Prometheus metrics — gated by METRICS_TOKEN when set (open in local dev).
550575
app.Get("/metrics", func(c *fiber.Ctx) error {
551576
if cfg.MetricsToken != "" {

internal/router/security_txt.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package router
2+
3+
// security_txt.go — RFC 9116 /.well-known/security.txt handler builder.
4+
//
5+
// Extracted from router.go's inline closure so the handler stays
6+
// directly addressable from package_test.go (the New(...) wiring path
7+
// is heavyweight to bring up in a test — needs Postgres + Redis + gRPC
8+
// — and the 100%-of-changed-lines patch coverage gate trips on lines
9+
// only reachable through that path). Keeping the body builder + the
10+
// handler closure here makes the unit test cover both via a direct
11+
// call.
12+
//
13+
// The handler is deliberately stateless. It captures `now` at builder
14+
// time so the Expires field round-trips through `time.Time` (and tests
15+
// can inject a known time without relying on time.Now() drift).
16+
17+
import (
18+
"time"
19+
20+
"github.com/gofiber/fiber/v2"
21+
)
22+
23+
// makeSecurityTxtHandler returns a fiber handler that serves the RFC
24+
// 9116 security.txt body. The body's Expires field is set to 1 year
25+
// after `now` (RFC 9116 §2.5.5 SHOULD-NOT exceed 1 year), so each
26+
// fresh deploy pushes the window forward — the file stays valid as
27+
// long as the binary is redeployed regularly.
28+
//
29+
// Body content is constant across handler instances except for the
30+
// Expires field, which is the only time-varying line.
31+
func makeSecurityTxtHandler(now time.Time) fiber.Handler {
32+
expiresAt := now.UTC().AddDate(1, 0, 0).Format("2006-01-02T15:04:05Z")
33+
body := buildSecurityTxtBody(expiresAt)
34+
return func(c *fiber.Ctx) error {
35+
c.Set(fiber.HeaderContentType, "text/plain; charset=utf-8")
36+
return c.SendString(body)
37+
}
38+
}
39+
40+
// buildSecurityTxtBody assembles the RFC 9116 body. Split from
41+
// makeSecurityTxtHandler so the body shape is testable without
42+
// instantiating a fiber.Handler closure.
43+
//
44+
// Field order matches the RFC's example: Contact (mandatory, ×2 for
45+
// channel redundancy), Expires (mandatory), then the recommended
46+
// fields. Trailing newline on the final field per §2.3 line-format
47+
// (every field MUST be CRLF-terminated; LF-only is widely accepted
48+
// and is what every other instanode file uses).
49+
func buildSecurityTxtBody(expiresAt string) string {
50+
return "Contact: mailto:security@instanode.dev\n" +
51+
"Contact: https://instanode.dev/security\n" +
52+
"Expires: " + expiresAt + "\n" +
53+
"Preferred-Languages: en\n" +
54+
"Canonical: https://api.instanode.dev/.well-known/security.txt\n" +
55+
"Policy: https://instanode.dev/security\n"
56+
}

0 commit comments

Comments
 (0)