feat(10-01): harden the admin cookie session and prefix boot guards
- refresh and logout read the Bearer header first, then the summer_admin cookie; a cookie refresh rotates the cookie without a token in the body and logout always expires the cookie - backend.cookie_secure (default true) may drop Secure outside production only - activation rejects controller vendor segments api, assets, login, settings - BuildRouter rejects non-cabana routes at or under the admin prefix - SPA single-flights refresh on 401, replays once, and refreshes proactively at 80 percent of expires_in; dist rebuilt - scripts/check-admin-dist.sh rebuilds the SPA and fails on dist drift - tests: TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix, TestPhase10AdminPrefixCollision, boardwalk serving and header tests
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
// paths (D-15). Every request carries X-Requested-With (the CSRF header the
|
||||
// admin API requires on state-changing cookie requests, D-19) and same-origin
|
||||
// credentials; the JWT lives in an HttpOnly cookie the SPA never reads.
|
||||
//
|
||||
// A 401 on any call other than login and refresh starts one shared refresh
|
||||
// (concurrent 401s wait for the same one); on success the original request is
|
||||
// replayed once from a clone taken before it was sent. A failed refresh or a
|
||||
// second 401 reports the session as gone.
|
||||
import createClient, { type Middleware } from 'openapi-fetch'
|
||||
import type { paths } from './schema'
|
||||
import { runtime } from '../app/runtime'
|
||||
@@ -9,28 +14,81 @@ import { runtime } from '../app/runtime'
|
||||
export const REQUESTED_WITH = 'XMLHttpRequest'
|
||||
|
||||
type UnauthorizedHandler = () => void
|
||||
type RefreshedHandler = (expiresIn: number | null) => void
|
||||
|
||||
let unauthorizedHandler: UnauthorizedHandler | null = null
|
||||
let refreshedHandler: RefreshedHandler | null = null
|
||||
let inflight: Promise<boolean> | null = null
|
||||
const replays = new WeakMap<Request, Request>()
|
||||
|
||||
/** Registers what happens when an API call other than login returns 401. */
|
||||
/** Registers what happens when the session cannot be recovered after a 401. */
|
||||
export function onUnauthorized(handler: UnauthorizedHandler | null): void {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
function isLoginRequest(request: Request): boolean {
|
||||
return new URL(request.url, 'http://local').pathname.endsWith('/auth/login')
|
||||
/** Registers a listener for successful refreshes (the new access lifetime). */
|
||||
export function onRefreshed(handler: RefreshedHandler | null): void {
|
||||
refreshedHandler = handler
|
||||
}
|
||||
|
||||
function pathOf(request: Request): string {
|
||||
return new URL(request.url, 'http://local').pathname
|
||||
}
|
||||
|
||||
function isAuthEndpoint(request: Request): boolean {
|
||||
const path = pathOf(request)
|
||||
return path.endsWith('/auth/login') || path.endsWith('/auth/refresh')
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the cookie session. Concurrent callers share one request; the
|
||||
* promise resolves to whether the server issued a new session.
|
||||
*/
|
||||
export function refreshSession(): Promise<boolean> {
|
||||
if (inflight) {
|
||||
return inflight
|
||||
}
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const { data, response } = await api.POST('/auth/refresh')
|
||||
if (!response.ok || !data) {
|
||||
return false
|
||||
}
|
||||
const expiresIn = data.data.expires_in
|
||||
refreshedHandler?.(typeof expiresIn === 'number' ? expiresIn : null)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
return inflight
|
||||
}
|
||||
|
||||
export const transport: Middleware = {
|
||||
onRequest({ request }) {
|
||||
request.headers.set('X-Requested-With', REQUESTED_WITH)
|
||||
if (!isAuthEndpoint(request)) {
|
||||
replays.set(request, request.clone())
|
||||
}
|
||||
return request
|
||||
},
|
||||
onResponse({ request, response }) {
|
||||
if (response.status === 401 && !isLoginRequest(request)) {
|
||||
async onResponse({ request, response }) {
|
||||
if (response.status !== 401 || isAuthEndpoint(request)) {
|
||||
return response
|
||||
}
|
||||
const replay = replays.get(request)
|
||||
replays.delete(request)
|
||||
if (!replay || !(await refreshSession())) {
|
||||
unauthorizedHandler?.()
|
||||
return response
|
||||
}
|
||||
const retried = await globalThis.fetch(replay)
|
||||
if (retried.status === 401) {
|
||||
unauthorizedHandler?.()
|
||||
}
|
||||
return response
|
||||
return retried
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
|
||||
// keeps only the profile and the access lifetime, never a token.
|
||||
import { readonly, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import { api, onRefreshed, refreshSession } from '../api/client'
|
||||
import type { AdminProfile } from '../api/types'
|
||||
|
||||
const user = ref<AdminProfile | null>(null)
|
||||
const expiresIn = ref<number | null>(null)
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** Refreshes proactively at 80 percent of the access token lifetime. */
|
||||
function scheduleRefresh(seconds: number | null): void {
|
||||
cancelRefresh()
|
||||
expiresIn.value = seconds
|
||||
if (seconds === null || seconds <= 0) {
|
||||
return
|
||||
}
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null
|
||||
void refreshSession()
|
||||
}, seconds * 800)
|
||||
}
|
||||
|
||||
function cancelRefresh(): void {
|
||||
if (refreshTimer !== null) {
|
||||
clearTimeout(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onRefreshed(scheduleRefresh)
|
||||
|
||||
export const currentUser = readonly(user)
|
||||
|
||||
@@ -17,7 +40,7 @@ export async function login(identifier: string, password: string): Promise<boole
|
||||
if (!response.ok || !data) {
|
||||
return false
|
||||
}
|
||||
expiresIn.value = typeof data.data.expires_in === 'number' ? data.data.expires_in : null
|
||||
scheduleRefresh(typeof data.data.expires_in === 'number' ? data.data.expires_in : null)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -29,6 +52,7 @@ export async function me(): Promise<AdminProfile | null> {
|
||||
}
|
||||
|
||||
export function clearUser(): void {
|
||||
cancelRefresh()
|
||||
user.value = null
|
||||
expiresIn.value = null
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import App from '../../src/App.vue'
|
||||
import LoginView from '../../src/views/LoginView.vue'
|
||||
import { createAdminRouter, safeRedirect } from '../../src/app/router'
|
||||
import { runtime } from '../../src/app/runtime'
|
||||
import { onUnauthorized } from '../../src/api/client'
|
||||
import { api, onUnauthorized } from '../../src/api/client'
|
||||
import { clearUser, me, useAuth } from '../../src/state/useAuth'
|
||||
import { loadNavigation, setNavigation } from '../../src/state/useNavigation'
|
||||
import navigation from '../fixtures/navigation.json'
|
||||
@@ -197,3 +197,106 @@ describe('navigation shell and list', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session refresh', () => {
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
}
|
||||
const unauthenticated = { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } }
|
||||
|
||||
it('single-flights one refresh for concurrent 401s and replays each request once', async () => {
|
||||
let refreshed = false
|
||||
const seen: Request[] = []
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const request = input as Request
|
||||
seen.push(request)
|
||||
const path = pathOf(request)
|
||||
if (path === `${API}/auth/refresh`) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
refreshed = true
|
||||
return json(200, { data: { token_type: 'cookie', expires_in: 3600 }, meta: {} })
|
||||
}
|
||||
if (!refreshed) {
|
||||
return json(401, unauthenticated)
|
||||
}
|
||||
if (path === `${API}/navigation`) {
|
||||
return json(200, navigation)
|
||||
}
|
||||
return json(200, profile)
|
||||
})
|
||||
const handler = vi.fn()
|
||||
onUnauthorized(handler)
|
||||
|
||||
const [nav, who] = await Promise.all([api.GET('/navigation'), api.GET('/auth/me')])
|
||||
|
||||
expect(nav.response.status).toBe(200)
|
||||
expect(who.response.status).toBe(200)
|
||||
expect(nav.data?.data).toHaveLength(3)
|
||||
expect(who.data?.data.login).toBe('dev')
|
||||
const refreshes = seen.filter((request) => pathOf(request) === `${API}/auth/refresh`)
|
||||
expect(refreshes).toHaveLength(1)
|
||||
expect(refreshes[0]!.method).toBe('POST')
|
||||
expect(refreshes[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(seen.filter((request) => pathOf(request) === `${API}/navigation`)).toHaveLength(2)
|
||||
expect(seen.filter((request) => pathOf(request) === `${API}/auth/me`)).toHaveLength(2)
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('goes to login when the refresh fails and never replays twice', async () => {
|
||||
const seen: Request[] = []
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const request = input as Request
|
||||
seen.push(request)
|
||||
return json(401, unauthenticated)
|
||||
})
|
||||
const handler = vi.fn()
|
||||
onUnauthorized(handler)
|
||||
|
||||
const result = await api.GET('/navigation')
|
||||
|
||||
expect(result.response.status).toBe(401)
|
||||
expect(seen.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(1)
|
||||
expect(seen.filter((request) => pathOf(request) === `${API}/navigation`)).toHaveLength(1)
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('goes to login when the replay is still unauthorized', async () => {
|
||||
const seen: Request[] = []
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const request = input as Request
|
||||
seen.push(request)
|
||||
if (pathOf(request) === `${API}/auth/refresh`) {
|
||||
return json(200, { data: { token_type: 'cookie', expires_in: 3600 }, meta: {} })
|
||||
}
|
||||
return json(401, unauthenticated)
|
||||
})
|
||||
const handler = vi.fn()
|
||||
onUnauthorized(handler)
|
||||
|
||||
const result = await api.GET('/navigation')
|
||||
|
||||
expect(result.response.status).toBe(401)
|
||||
expect(seen.filter((request) => pathOf(request) === `${API}/navigation`)).toHaveLength(2)
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('schedules a proactive refresh at 80 percent of expires_in', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const calls = mockApi({
|
||||
[`POST ${API}/auth/login`]: { body: { data: { token_type: 'cookie', expires_in: 100 }, meta: {} } },
|
||||
[`POST ${API}/auth/refresh`]: { body: { data: { token_type: 'cookie', expires_in: 100 }, meta: {} } },
|
||||
})
|
||||
expect(await useAuth().login('dev', 'secret')).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(79_000)
|
||||
expect(calls.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(0)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(calls.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(1)
|
||||
clearUser()
|
||||
await vi.advanceTimersByTimeAsync(200_000)
|
||||
expect(calls.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
317
boardwalk/boardwalk_test.go
Normal file
317
boardwalk/boardwalk_test.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package boardwalk
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
const testPrefix = "/acme-admin"
|
||||
|
||||
var (
|
||||
assetRef = regexp.MustCompile(`(?:src|href)="([^"]+)"`)
|
||||
scriptTag = regexp.MustCompile(`<script\b[^>]*>`)
|
||||
)
|
||||
|
||||
type apiSpy struct{ calls int }
|
||||
|
||||
func (s *apiSpy) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
s.calls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"Not found","details":{}}}`))
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T) (http.Handler, *apiSpy) {
|
||||
t.Helper()
|
||||
spy := &apiSpy{}
|
||||
h, err := Handler(testPrefix, spy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h, spy
|
||||
}
|
||||
|
||||
func get(h http.Handler, target string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestIndexRewrite(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
for _, target := range []string{testPrefix, testPrefix + "/", testPrefix + "/index.html"} {
|
||||
rec := get(h, target)
|
||||
body := rec.Body.String()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d", target, rec.Code)
|
||||
}
|
||||
if !strings.Contains(body, `<meta name="summer-admin-base" content="`+testPrefix+`"`) {
|
||||
t.Fatalf("%s index has no injected base: %s", target, body)
|
||||
}
|
||||
if strings.Contains(body, BaseToken) || strings.Contains(body, `="./`) {
|
||||
t.Fatalf("%s index was not rewritten: %s", target, body)
|
||||
}
|
||||
if !strings.Contains(body, `src="`+testPrefix+`/assets/`) {
|
||||
t.Fatalf("%s index script is not under the prefix: %s", target, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteIndexRequiresToken(t *testing.T) {
|
||||
if _, err := RewriteIndex([]byte(`<html><script src="./assets/a.js"></script></html>`), testPrefix); err == nil {
|
||||
t.Fatal("index without the base token was accepted")
|
||||
}
|
||||
root := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html></html>")}}
|
||||
if _, err := newHandler(root, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), BaseToken) {
|
||||
t.Fatalf("stale dist accepted: %v", err)
|
||||
}
|
||||
if _, err := newHandler(fstest.MapFS{}, testPrefix, &apiSpy{}); err == nil {
|
||||
t.Fatal("dist without index.html accepted")
|
||||
}
|
||||
if _, err := Handler(testPrefix, nil); err == nil {
|
||||
t.Fatal("nil API not-found handler accepted")
|
||||
}
|
||||
if _, err := Handler("acme-admin", &apiSpy{}); err == nil {
|
||||
t.Fatal("prefix without a leading slash accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteIndexEscapesPrefix(t *testing.T) {
|
||||
out, err := RewriteIndex([]byte(`<meta content="`+BaseToken+`"><script src="./a.js"></script>`), `/a"b`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), `"/a"b`) || !strings.Contains(string(out), `/a"b`) {
|
||||
t.Fatalf("prefix not escaped: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryReferencedAssetIsEmbedded(t *testing.T) {
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := fs.ReadFile(root, "index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index, err := RewriteIndex(raw, testPrefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs := assetRef.FindAllStringSubmatch(string(index), -1)
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("index references no assets")
|
||||
}
|
||||
h, _ := newTestHandler(t)
|
||||
for _, ref := range refs {
|
||||
target := ref[1]
|
||||
if !strings.HasPrefix(target, testPrefix+"/") {
|
||||
t.Fatalf("reference %q is not under the prefix", target)
|
||||
}
|
||||
name := strings.TrimPrefix(target, testPrefix+"/")
|
||||
if _, err := fs.Stat(root, name); err != nil {
|
||||
t.Fatalf("index references %s, missing from the embedded dist: %v", name, err)
|
||||
}
|
||||
if rec := get(h, target); rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status=%d", target, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoInlineScript(t *testing.T) {
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := fs.ReadFile(root, "index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tags := scriptTag.FindAllString(string(raw), -1)
|
||||
if len(tags) == 0 {
|
||||
t.Fatal("index has no module script")
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if !strings.Contains(tag, " src=") {
|
||||
t.Fatalf("inline script in index.html: %s", tag)
|
||||
}
|
||||
}
|
||||
if strings.Contains(strings.ToLower(string(raw)), "javascript:") {
|
||||
t.Fatal("index.html contains a javascript: URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPathsAreDelegated(t *testing.T) {
|
||||
h, spy := newTestHandler(t)
|
||||
for _, target := range []string{testPrefix + "/api", testPrefix + "/api/", testPrefix + "/api/v1/nope", testPrefix + "/api/v1/auth/login"} {
|
||||
before := spy.calls
|
||||
rec := get(h, target)
|
||||
if spy.calls != before+1 || rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
|
||||
t.Fatalf("%s was not delegated: status=%d body=%s", target, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
before := spy.calls
|
||||
if rec := get(h, testPrefix+"/apiary"); rec.Code != http.StatusOK || spy.calls != before {
|
||||
t.Fatalf("/apiary is a client route, status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingFileWithExtensionIs404(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
for _, target := range []string{testPrefix + "/assets/missing.js", testPrefix + "/favicon.ico", testPrefix + "/golem/missing.css"} {
|
||||
rec := get(h, target)
|
||||
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "summer-admin-base") {
|
||||
t.Fatalf("%s status=%d body=%s", target, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, target := range []string{testPrefix + "/acme/demo/widgets", testPrefix + "/acme/demo/widgets/12", testPrefix + "/login"} {
|
||||
rec := get(h, target)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "summer-admin-base") {
|
||||
t.Fatalf("client route %s status=%d", target, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraversalIsCleaned(t *testing.T) {
|
||||
root := fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte(`<meta content="` + BaseToken + `">`)},
|
||||
"assets/app.js": &fstest.MapFile{Data: []byte("console.log(1)")},
|
||||
}
|
||||
h, err := newHandler(root, testPrefix, &apiSpy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, target := range []string{
|
||||
testPrefix + "/../../../go.mod",
|
||||
testPrefix + "/assets/../../boardwalk.go",
|
||||
testPrefix + "/%2e%2e/%2e%2e/etc/passwd.txt",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.URL.Path = target
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "module ") || strings.Contains(rec.Body.String(), "package ") {
|
||||
t.Fatalf("%s status=%d body=%s", target, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.URL.Path = testPrefix + "/assets/../index.html"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), BaseToken) {
|
||||
t.Fatalf("cleaned index path served the raw index: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryIsNeverListed(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := fs.ReadDir(root, "assets")
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("embedded assets: %v", err)
|
||||
}
|
||||
for _, target := range []string{testPrefix + "/assets", testPrefix + "/assets/"} {
|
||||
rec := get(h, target)
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, entries[0].Name()) || !strings.Contains(body, "summer-admin-base") {
|
||||
t.Fatalf("%s listed the directory or skipped the shell: %s", target, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentTypesAndCaching(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
err = fs.WalkDir(root, "assets", func(name string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
ext := path.Ext(name)
|
||||
ct, ok := want[ext]
|
||||
if !ok || seen[ext] {
|
||||
return nil
|
||||
}
|
||||
seen[ext] = true
|
||||
rec := get(h, testPrefix+"/"+name)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != ct {
|
||||
t.Fatalf("%s status=%d type=%q, want %q", name, rec.Code, rec.Header().Get("Content-Type"), ct)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
|
||||
t.Fatalf("%s Cache-Control=%q", name, cc)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for ext := range want {
|
||||
if !seen[ext] {
|
||||
t.Fatalf("embedded dist has no %s asset", ext)
|
||||
}
|
||||
}
|
||||
index := get(h, testPrefix)
|
||||
if index.Header().Get("Cache-Control") != "no-store" || !strings.HasPrefix(index.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("index headers = %v", index.Header())
|
||||
}
|
||||
if got := contentType("x.svg"); got != "image/svg+xml" {
|
||||
t.Fatalf("svg type %q", got)
|
||||
}
|
||||
if got := contentType("x.json"); got != "application/json" {
|
||||
t.Fatalf("json type %q", got)
|
||||
}
|
||||
if got := contentType("x.unknown-ext"); got != "application/octet-stream" {
|
||||
t.Fatalf("unknown type %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersOnEveryResponse(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := fs.ReadDir(root, "assets")
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("embedded assets: %v", err)
|
||||
}
|
||||
for _, target := range []string{
|
||||
testPrefix,
|
||||
testPrefix + "/acme/demo/widgets",
|
||||
testPrefix + "/assets/" + entries[0].Name(),
|
||||
testPrefix + "/missing.js",
|
||||
testPrefix + "/api/v1/nope",
|
||||
} {
|
||||
rec := get(h, target)
|
||||
for header, want := range map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "same-origin",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'",
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
} {
|
||||
if got := rec.Header().Get(header); got != want {
|
||||
t.Fatalf("%s %s=%q, want %q", target, header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
boardwalk/dist/assets/index-BD2QIjGC.js
vendored
Normal file
1
boardwalk/dist/assets/index-BD2QIjGC.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-ZNCn30hM.js
vendored
1
boardwalk/dist/assets/index-ZNCn30hM.js
vendored
File diff suppressed because one or more lines are too long
2
boardwalk/dist/index.html
vendored
2
boardwalk/dist/index.html
vendored
@@ -6,7 +6,7 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-ZNCn30hM.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-BD2QIjGC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-UTAit0wB.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -194,19 +194,28 @@ func cookieLoginData(ttl time.Duration) AdminLoginData {
|
||||
// Max-Age is the refresh window, because refresh accepts an expired access
|
||||
// token until iat plus refresh_ttl.
|
||||
func (s *service) writeSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
http.SetCookie(w, s.sessionCookie(token, int(s.refreshTTL/time.Second)))
|
||||
}
|
||||
|
||||
// expireSessionCookie tells the browser to drop the admin cookie.
|
||||
func (s *service) expireSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, s.sessionCookie("", -1))
|
||||
}
|
||||
|
||||
func (s *service) sessionCookie(value string, maxAge int) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: AdminCookieName,
|
||||
Value: token,
|
||||
Value: value,
|
||||
Path: s.adminPrefix(),
|
||||
MaxAge: int(s.refreshTTL / time.Second),
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
Secure: !s.insecureCookie,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
raw := bearerToken(r)
|
||||
raw, fromCookie := sessionToken(r)
|
||||
if raw == "" {
|
||||
s.logAuth(r, "failed", 0)
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
@@ -219,14 +228,22 @@ func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.logAuth(r, "success", 0)
|
||||
if fromCookie {
|
||||
// A cookie-authenticated request never receives a token in its body.
|
||||
s.writeSessionCookie(w, next)
|
||||
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, map[string]string{
|
||||
"access_token": next,
|
||||
"token_type": "bearer",
|
||||
}, map[string]any{})
|
||||
}
|
||||
|
||||
// logout blacklists the presented token's jti and always expires the admin
|
||||
// cookie, so a browser session ends even when only the Bearer was revoked.
|
||||
func (s *service) logout(w http.ResponseWriter, r *http.Request) {
|
||||
raw := bearerToken(r)
|
||||
raw, _ := sessionToken(r)
|
||||
_, iat, exp, jti, err := bouncer.VerifyClaimsAudience(raw, s.secret, bouncer.AudienceBackend)
|
||||
if err != nil || jti == "" {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
@@ -247,9 +264,27 @@ func (s *service) logout(w http.ResponseWriter, r *http.Request) {
|
||||
id = principal.ID
|
||||
}
|
||||
s.logAuth(r, "success", id)
|
||||
s.expireSessionCookie(w)
|
||||
WriteData(w, http.StatusOK, map[string]any{"status": "logged_out"}, map[string]any{})
|
||||
}
|
||||
|
||||
// sessionToken returns the admin JWT the same way the backend guard reads it:
|
||||
// the Authorization Bearer header first, then the summer_admin cookie.
|
||||
func sessionToken(r *http.Request) (token string, fromCookie bool) {
|
||||
if raw := bearerToken(r); raw != "" {
|
||||
return raw, false
|
||||
}
|
||||
if r == nil {
|
||||
return "", false
|
||||
}
|
||||
if c, err := r.Cookie(AdminCookieName); err == nil {
|
||||
if raw := strings.TrimSpace(c.Value); raw != "" {
|
||||
return raw, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (s *service) me(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil {
|
||||
@@ -425,6 +460,22 @@ func adminLoginWindow(app *backpack.App) (int, int) {
|
||||
return maxAttempts, decayMinutes
|
||||
}
|
||||
|
||||
// adminCookieSecure reads backend.cookie_secure (default true). false drops
|
||||
// the Secure attribute for plain-http development and is refused in the
|
||||
// production environment.
|
||||
func adminCookieSecure(app *backpack.App) (bool, error) {
|
||||
if app == nil || app.Config == nil || !app.Config.Has("backend.cookie_secure") {
|
||||
return true, nil
|
||||
}
|
||||
if app.Config.Bool("backend.cookie_secure") {
|
||||
return true, nil
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(app.Config.Environment()), "production") {
|
||||
return false, errors.New("cabana: backend.cookie_secure: false is not allowed in the production environment")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// adminIssuer is app.url plus the admin API login path. JWT verification does
|
||||
// not check iss, so tokens minted under an earlier prefix stay valid until
|
||||
// they expire.
|
||||
|
||||
@@ -43,6 +43,9 @@ type service struct {
|
||||
bl bouncer.BlacklistStore
|
||||
prefix string
|
||||
spa http.Handler
|
||||
// insecureCookie drops Secure from the admin cookie (backend.cookie_secure
|
||||
// false, development only); the zero value keeps the cookie Secure.
|
||||
insecureCookie bool
|
||||
}
|
||||
|
||||
// adminPrefix returns the mount path; a zero service uses the default.
|
||||
@@ -68,6 +71,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := checkReservedSegments(items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := adminSecret(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -76,6 +82,10 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secureCookie, err := adminCookieSecure(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reg, err := compileRegistry(items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -118,6 +128,8 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
issuer: adminIssuer(app, prefix),
|
||||
bl: bl,
|
||||
prefix: prefix,
|
||||
|
||||
insecureCookie: !secureCookie,
|
||||
}
|
||||
spa, err := boardwalk.Handler(prefix, http.HandlerFunc(writeNotFound))
|
||||
if err != nil {
|
||||
|
||||
352
cabana/phase10_auth_test.go
Normal file
352
cabana/phase10_auth_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/cabana"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
)
|
||||
|
||||
// TestPhase10CookieAuth pins the D-19 session transport: cookie login and
|
||||
// refresh never put the JWT in a body, Bearer clients keep the Phase 9 body,
|
||||
// a cookie refresh needs the CSRF header, and logout blacklists the jti and
|
||||
// expires the cookie.
|
||||
func TestPhase10CookieAuth(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "p10cookie", "p10cookie@example.test", adminTestPassword, true, false)
|
||||
creds := map[string]string{"login": "p10cookie", "password": adminTestPassword}
|
||||
|
||||
login := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), creds, nil, true)
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("cookie login status=%d body=%s", login.Code, login.Body.String())
|
||||
}
|
||||
first := phase10Cookie(t, login, cabana.DefaultAdminPrefix)
|
||||
phase10AssertCookieBody(t, login, first.Value)
|
||||
|
||||
bearer := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), creds, nil, false)
|
||||
if bearer.Code != http.StatusOK {
|
||||
t.Fatalf("bearer login status=%d body=%s", bearer.Code, bearer.Body.String())
|
||||
}
|
||||
phase10AssertBearerBody(t, bearer)
|
||||
if got := bearer.Header().Values("Set-Cookie"); len(got) != 0 {
|
||||
t.Fatalf("bearer login set cookies: %q", got)
|
||||
}
|
||||
bearerToken := accessToken(t, bearer.Body.Bytes())
|
||||
|
||||
noHeader := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, first, false)
|
||||
if noHeader.Code != http.StatusForbidden || phase10ErrorCode(t, noHeader) != "forbidden" {
|
||||
t.Fatalf("cookie refresh without header status=%d body=%s", noHeader.Code, noHeader.Body.String())
|
||||
}
|
||||
if got := noHeader.Header().Values("Set-Cookie"); len(got) != 0 {
|
||||
t.Fatalf("refused refresh set cookies: %q", got)
|
||||
}
|
||||
|
||||
refreshed := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, first, true)
|
||||
if refreshed.Code != http.StatusOK {
|
||||
t.Fatalf("cookie refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
|
||||
}
|
||||
second := phase10Cookie(t, refreshed, cabana.DefaultAdminPrefix)
|
||||
if second.Value == first.Value {
|
||||
t.Fatal("cookie refresh did not rotate the token")
|
||||
}
|
||||
phase10AssertCookieBody(t, refreshed, second.Value)
|
||||
if stale := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, first, true); stale.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("rotated-out cookie status=%d body=%s", stale.Code, stale.Body.String())
|
||||
}
|
||||
if me := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, second, true); me.Code != http.StatusOK {
|
||||
t.Fatalf("rotated cookie /auth/me status=%d body=%s", me.Code, me.Body.String())
|
||||
}
|
||||
|
||||
bearerRefresh := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), bearerToken, nil)
|
||||
if bearerRefresh.Code != http.StatusOK {
|
||||
t.Fatalf("bearer refresh status=%d body=%s", bearerRefresh.Code, bearerRefresh.Body.String())
|
||||
}
|
||||
phase10AssertBearerBody(t, bearerRefresh)
|
||||
if got := bearerRefresh.Header().Values("Set-Cookie"); len(got) != 0 {
|
||||
t.Fatalf("bearer refresh set cookies: %q", got)
|
||||
}
|
||||
|
||||
logout := phase10Send(t, h, http.MethodPost, adminAPI("/auth/logout"), nil, second, true)
|
||||
if logout.Code != http.StatusOK {
|
||||
t.Fatalf("cookie logout status=%d body=%s", logout.Code, logout.Body.String())
|
||||
}
|
||||
var expired *http.Cookie
|
||||
for _, c := range logout.Result().Cookies() {
|
||||
if c.Name == cabana.AdminCookieName {
|
||||
expired = c
|
||||
}
|
||||
}
|
||||
if expired == nil || expired.MaxAge >= 0 || expired.Value != "" || expired.Path != cabana.DefaultAdminPrefix {
|
||||
t.Fatalf("logout cookie = %+v, want an expiring %s with Path %s", expired, cabana.AdminCookieName, cabana.DefaultAdminPrefix)
|
||||
}
|
||||
if after := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, second, true); after.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("logged-out cookie status=%d body=%s", after.Code, after.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhase10Prefix pins backend.uri normalization and validation, moves the
|
||||
// whole admin surface with a custom prefix, rejects reserved controller
|
||||
// vendor segments and refuses an insecure admin cookie in production.
|
||||
func TestPhase10Prefix(t *testing.T) {
|
||||
t.Run("normalization", func(t *testing.T) {
|
||||
for raw, want := range map[string]string{
|
||||
" /acme-admin/ ": "/acme-admin",
|
||||
"": "/backend",
|
||||
"acme": "/acme",
|
||||
"/a/b_c/": "/a/b_c",
|
||||
} {
|
||||
got, err := cabana.AdminPrefix(phase10App(t, "development", map[string]any{"backend.uri": raw}))
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("AdminPrefix(%q) = %q, %v; want %q", raw, got, err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid values fail activation", func(t *testing.T) {
|
||||
for _, raw := range []string{"/", "/Admin", "/a b", "/../x", "//", "/-x"} {
|
||||
app := phase10App(t, "development", map[string]any{"backend.uri": raw})
|
||||
_, err := cabana.Activate(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
|
||||
if err == nil || !strings.Contains(err.Error(), "backend.uri") {
|
||||
t.Fatalf("backend.uri %q: err=%v, want an activation error naming backend.uri", raw, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom prefix moves the surface", func(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, func(cfg *compass.Config) {
|
||||
phase10Set(t, cfg, "backend.uri", "/acme-admin")
|
||||
phase10Set(t, cfg, "app.url", "https://app.test")
|
||||
})
|
||||
insertAdmin(t, gdb, "p10prefix", "p10prefix@example.test", adminTestPassword, true, false)
|
||||
creds := map[string]string{"login": "p10prefix", "password": adminTestPassword}
|
||||
api := "/acme-admin/api/v1"
|
||||
|
||||
login := phase10Send(t, h, http.MethodPost, api+"/auth/login", creds, nil, true)
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("custom prefix login status=%d body=%s", login.Code, login.Body.String())
|
||||
}
|
||||
phase10Cookie(t, login, "/acme-admin")
|
||||
|
||||
bearer := phase10Send(t, h, http.MethodPost, api+"/auth/login", creds, nil, false)
|
||||
token := accessToken(t, bearer.Body.Bytes())
|
||||
if iss, _ := jwtClaims(t, token)["iss"].(string); iss != "https://app.test/acme-admin/api/v1/auth/login" {
|
||||
t.Fatalf("issuer = %q", iss)
|
||||
}
|
||||
if me := getAuth(t, h, api+"/auth/me", token); me.Code != http.StatusOK {
|
||||
t.Fatalf("custom prefix /auth/me status=%d", me.Code)
|
||||
}
|
||||
|
||||
shell := getAuth(t, h, "/acme-admin", "")
|
||||
if shell.Code != http.StatusOK || !strings.Contains(shell.Body.String(), `content="/acme-admin"`) {
|
||||
t.Fatalf("custom prefix shell status=%d body=%s", shell.Code, shell.Body.String())
|
||||
}
|
||||
deep := getAuth(t, h, "/acme-admin/acme/demo/widgets", "")
|
||||
if deep.Code != http.StatusOK || !strings.Contains(deep.Body.String(), `content="/acme-admin"`) {
|
||||
t.Fatalf("custom prefix deep link status=%d", deep.Code)
|
||||
}
|
||||
missing := getAuth(t, h, api+"/nope", "")
|
||||
if missing.Code != http.StatusNotFound || phase10ErrorCode(t, missing) != "not_found" {
|
||||
t.Fatalf("custom prefix API miss status=%d body=%s", missing.Code, missing.Body.String())
|
||||
}
|
||||
old := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), creds, nil, false)
|
||||
if old.Code == http.StatusOK {
|
||||
t.Fatalf("default prefix still answers under a custom backend.uri: %s", old.Body.String())
|
||||
}
|
||||
if shell := getAuth(t, h, cabana.DefaultAdminPrefix, ""); shell.Code == http.StatusOK {
|
||||
t.Fatal("default prefix still serves the SPA under a custom backend.uri")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reserved controller vendor segments", func(t *testing.T) {
|
||||
for _, vendor := range []string{"api", "assets", "login", "settings"} {
|
||||
app := phase10App(t, "development", nil)
|
||||
_, err := cabana.Activate(app, []party.Plugin{reservedPlugin{vendor: vendor}})
|
||||
if err == nil || !strings.Contains(err.Error(), vendor+".demo.widgets") || !strings.Contains(err.Error(), "reserved") {
|
||||
t.Fatalf("vendor %q: err=%v, want a reserved-segment activation error", vendor, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cookie_secure", func(t *testing.T) {
|
||||
prod := phase10App(t, "production", map[string]any{"backend.cookie_secure": false})
|
||||
if _, err := cabana.Activate(prod, []party.Plugin{demoPlugin{fsys: demoFS()}}); err == nil || !strings.Contains(err.Error(), "backend.cookie_secure") {
|
||||
t.Fatalf("production cookie_secure=false: err=%v", err)
|
||||
}
|
||||
if _, err := cabana.Activate(phase10App(t, "production", nil), []party.Plugin{demoPlugin{fsys: demoFS()}}); err != nil {
|
||||
t.Fatalf("production default cookie_secure: %v", err)
|
||||
}
|
||||
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, func(cfg *compass.Config) {
|
||||
phase10Set(t, cfg, "backend.cookie_secure", false)
|
||||
})
|
||||
insertAdmin(t, gdb, "p10insecure", "p10insecure@example.test", adminTestPassword, true, false)
|
||||
login := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"), map[string]string{"login": "p10insecure", "password": adminTestPassword}, nil, true)
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("insecure-cookie login status=%d body=%s", login.Code, login.Body.String())
|
||||
}
|
||||
for _, c := range login.Result().Cookies() {
|
||||
if c.Name == cabana.AdminCookieName && (c.Secure || !c.HttpOnly || c.SameSite != http.SameSiteStrictMode) {
|
||||
t.Fatalf("development cookie_secure=false cookie = %+v, want HttpOnly SameSite=Strict without Secure", c)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func phase10Send(t *testing.T, h http.Handler, method, path string, body any, cookie *http.Cookie, ajax bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if ajax {
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
}
|
||||
if cookie != nil {
|
||||
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func phase10Cookie(t *testing.T, rec *httptest.ResponseRecorder, path string) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name != cabana.AdminCookieName {
|
||||
continue
|
||||
}
|
||||
if c.Value == "" || !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != path || c.MaxAge <= 0 {
|
||||
t.Fatalf("session cookie = %+v, want HttpOnly Secure SameSite=Strict Path=%s with a Max-Age", c, path)
|
||||
}
|
||||
return c
|
||||
}
|
||||
t.Fatalf("no %s cookie; Set-Cookie=%q body=%s", cabana.AdminCookieName, rec.Header().Values("Set-Cookie"), rec.Body.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func phase10AssertCookieBody(t *testing.T, rec *httptest.ResponseRecorder, token string) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Data["token_type"] != "cookie" {
|
||||
t.Fatalf("cookie body = %s", rec.Body.String())
|
||||
}
|
||||
if n, ok := body.Data["expires_in"].(float64); !ok || n <= 0 {
|
||||
t.Fatalf("cookie body expires_in = %v", body.Data["expires_in"])
|
||||
}
|
||||
if _, ok := body.Data["access_token"]; ok || strings.Contains(rec.Body.String(), token) || strings.Contains(rec.Body.String(), "eyJ") {
|
||||
t.Fatalf("cookie body carries a token: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func phase10AssertBearerBody(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Data map[string]any `json:"data"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := body.Data["access_token"].(string)
|
||||
if len(body.Data) != 2 || token == "" || body.Data["token_type"] != "bearer" || len(body.Meta) != 0 {
|
||||
t.Fatalf("bearer body = %s, want the Phase 9 {access_token, token_type: bearer} shape", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func phase10ErrorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("error json: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
return body.Error.Code
|
||||
}
|
||||
|
||||
func phase10Set(t *testing.T, cfg *compass.Config, key string, value any) {
|
||||
t.Helper()
|
||||
if err := cfg.Set(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// phase10App is a database-free app for activation checks.
|
||||
func phase10App(t *testing.T, env string, values map[string]any) *backpack.App {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-phase10\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_ENV=" + env,
|
||||
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range values {
|
||||
phase10Set(t, cfg, key, value)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
// reservedPlugin owns a controller whose vendor segment collides with an SPA
|
||||
// or API segment under the admin prefix.
|
||||
type reservedPlugin struct{ vendor string }
|
||||
|
||||
func (p reservedPlugin) ID() string { return p.vendor + ".demo" }
|
||||
func (p reservedPlugin) Requires() []string { return nil }
|
||||
func (p reservedPlugin) Register(*backpack.App) error { return nil }
|
||||
func (p reservedPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p reservedPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{reservedController{vendor: p.vendor}}
|
||||
}
|
||||
func (p reservedPlugin) AdminFS() fs.FS {
|
||||
return fstest.MapFS{
|
||||
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte(fmt.Sprintf("list: ~/plugins/%s/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\n", p.vendor))},
|
||||
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")},
|
||||
}
|
||||
}
|
||||
|
||||
type reservedController struct{ vendor string }
|
||||
|
||||
func (c reservedController) ID() string { return c.vendor + ".demo.widgets" }
|
||||
func (reservedController) ModelName() string { return "Widget" }
|
||||
func (reservedController) ConfigDir() string { return "controllers/widgets" }
|
||||
154
cabana/phase10_csrf_test.go
Normal file
154
cabana/phase10_csrf_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
)
|
||||
|
||||
// TestPhase10CSRF walks every state-changing route service.mount registers.
|
||||
// Except login, a request carrying only the admin cookie and no
|
||||
// X-Requested-With header is refused with 403 forbidden before the handler
|
||||
// runs: the body spy is never read and the service, which has no database,
|
||||
// never answers with a database error. The same call with the header or with
|
||||
// a Bearer token reaches the handler.
|
||||
func TestPhase10CSRF(t *testing.T) {
|
||||
router := &handlerRouter{handlers: map[string]http.HandlerFunc{}}
|
||||
svc := phase09DeniedService()
|
||||
svc.mount(router)
|
||||
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
|
||||
login := http.MethodPost + " " + adminAPI("/auth/login")
|
||||
|
||||
unsafe := 0
|
||||
for _, key := range router.order {
|
||||
method, path, _ := strings.Cut(key, " ")
|
||||
if method != http.MethodPost && method != http.MethodPut && method != http.MethodDelete {
|
||||
continue
|
||||
}
|
||||
if key == login {
|
||||
continue
|
||||
}
|
||||
unsafe++
|
||||
handler := router.handlers[key]
|
||||
t.Run(key, func(t *testing.T) {
|
||||
refused, spy := csrfRequest(method, path, super)
|
||||
refused.AddCookie(&http.Cookie{Name: AdminCookieName, Value: "cookie-only-session"})
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, refused)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("cookie-only status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
|
||||
if spy.reads != 0 {
|
||||
t.Fatalf("refused request body was read %d times", spy.reads)
|
||||
}
|
||||
|
||||
withHeader, _ := csrfRequest(method, path, super)
|
||||
withHeader.AddCookie(&http.Cookie{Name: AdminCookieName, Value: "cookie-only-session"})
|
||||
withHeader.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
rec = httptest.NewRecorder()
|
||||
handler(rec, withHeader)
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Fatalf("request with X-Requested-With was refused: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
withBearer, _ := csrfRequest(method, path, super)
|
||||
withBearer.Header.Set("Authorization", "Bearer not-a-real-token")
|
||||
rec = httptest.NewRecorder()
|
||||
handler(rec, withBearer)
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Fatalf("Bearer request was refused: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
// refresh, logout, settings put, create, bulk-delete, update, delete, link, unlink
|
||||
if unsafe != 9 {
|
||||
t.Fatalf("walked %d state-changing routes, want 9: %v", unsafe, router.order)
|
||||
}
|
||||
|
||||
loginHandler := router.handlers[login]
|
||||
req, _ := csrfRequest(http.MethodPost, adminAPI("/auth/login"), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
loginHandler(rec, req)
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Fatalf("login without the header was refused: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type readSpy struct {
|
||||
r io.Reader
|
||||
reads int
|
||||
}
|
||||
|
||||
func (s *readSpy) Read(p []byte) (int, error) {
|
||||
s.reads++
|
||||
return s.r.Read(p)
|
||||
}
|
||||
|
||||
func csrfRequest(method, path string, principal *bouncer.Principal) (*http.Request, *readSpy) {
|
||||
spy := &readSpy{r: strings.NewReader(`{"ids":[1],"name":"csrf"}`)}
|
||||
req := httptest.NewRequest(method, path, spy)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "widgets")
|
||||
req.SetPathValue("id", "1")
|
||||
req.SetPathValue("name", "editors")
|
||||
req.SetPathValue("code", "demo")
|
||||
if principal != nil {
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
||||
}
|
||||
return req, spy
|
||||
}
|
||||
|
||||
// handlerRouter records the handler mounted for every route, so tests can
|
||||
// call exactly what service.mount registered.
|
||||
type handlerRouter struct {
|
||||
prefix string
|
||||
handlers map[string]http.HandlerFunc
|
||||
order []string
|
||||
}
|
||||
|
||||
func (h *handlerRouter) Group(prefix string, middleware []string, fn func(pact.Router)) {
|
||||
h.GroupRaw(prefix, middleware, fn)
|
||||
}
|
||||
|
||||
func (h *handlerRouter) GroupRaw(prefix string, _ []string, fn func(pact.Router)) {
|
||||
child := &handlerRouter{prefix: h.prefix + prefix, handlers: h.handlers}
|
||||
fn(child)
|
||||
h.order = append(h.order, child.order...)
|
||||
}
|
||||
|
||||
func (h *handlerRouter) Get(path string, fn http.HandlerFunc, _ ...string) {
|
||||
h.add(http.MethodGet, path, fn)
|
||||
}
|
||||
|
||||
func (h *handlerRouter) Post(path string, fn http.HandlerFunc, _ ...string) {
|
||||
h.add(http.MethodPost, path, fn)
|
||||
}
|
||||
|
||||
func (h *handlerRouter) Put(path string, fn http.HandlerFunc, _ ...string) {
|
||||
h.add(http.MethodPut, path, fn)
|
||||
}
|
||||
|
||||
func (h *handlerRouter) Patch(path string, fn http.HandlerFunc, _ ...string) {
|
||||
h.add(http.MethodPatch, path, fn)
|
||||
}
|
||||
|
||||
func (h *handlerRouter) Delete(path string, fn http.HandlerFunc, _ ...string) {
|
||||
h.add(http.MethodDelete, path, fn)
|
||||
}
|
||||
|
||||
func (h *handlerRouter) Where(string, string) {}
|
||||
func (h *handlerRouter) WhereIn(string, ...string) {}
|
||||
|
||||
func (h *handlerRouter) add(method, path string, fn http.HandlerFunc) {
|
||||
key := method + " " + h.prefix + path
|
||||
h.handlers[key] = fn
|
||||
h.order = append(h.order, key)
|
||||
}
|
||||
@@ -34,6 +34,22 @@ func collectControllers(plugins []party.Plugin) ([]controllerRef, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// reservedVendorSegments are the first path segments under the admin prefix
|
||||
// that the SPA and API own. A controller ID maps to /{vendor}/{plugin}/..., so
|
||||
// a vendor with one of these names would collide with them.
|
||||
var reservedVendorSegments = map[string]bool{"api": true, "assets": true, "login": true, "settings": true}
|
||||
|
||||
func checkReservedSegments(items []controllerRef) error {
|
||||
for _, item := range items {
|
||||
id := item.ctl.ID()
|
||||
vendor, _, _ := strings.Cut(id, ".")
|
||||
if reservedVendorSegments[vendor] {
|
||||
return fmt.Errorf("cabana: controller %s uses the reserved admin path segment %q (reserved: api, assets, login, settings)", id, vendor)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||
byID := make(map[string]*CompiledController, len(items))
|
||||
for _, item := range items {
|
||||
|
||||
26
scripts/check-admin-dist.sh
Executable file
26
scripts/check-admin-dist.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Drift gate for the committed admin SPA build (D-04). Rebuilds admin/ from
|
||||
# the committed lockfile into a temporary directory and fails when the result
|
||||
# differs from boardwalk/dist, the tree the Go binary embeds. After changing
|
||||
# the SPA, rebuild with `npm --prefix admin run build` and commit the result.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ ! -d admin/node_modules ]]; then
|
||||
npm --prefix admin ci
|
||||
fi
|
||||
|
||||
npm --prefix admin run typecheck
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
(cd admin && node_modules/.bin/vite build --outDir "$TMP/dist" --emptyOutDir --logLevel warn)
|
||||
|
||||
if ! diff -r "$TMP/dist" boardwalk/dist; then
|
||||
echo "check-admin-dist: boardwalk/dist is stale; run npm --prefix admin run build and commit boardwalk/dist" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "check-admin-dist: boardwalk/dist matches a fresh build"
|
||||
132
surf/admin_prefix_test.go
Normal file
132
surf/admin_prefix_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
)
|
||||
|
||||
// TestPhase10AdminPrefixCollision fails boot when a plugin other than cabana
|
||||
// registers a route at or under the admin prefix (backend.uri), which would
|
||||
// otherwise shadow or be shadowed by the admin SPA and API.
|
||||
func TestPhase10AdminPrefixCollision(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
raw bool
|
||||
}{
|
||||
{"exact prefix", http.MethodGet, "/acme-admin", false},
|
||||
{"under prefix", http.MethodPost, "/acme-admin/hook", false},
|
||||
{"raw under api", http.MethodGet, "/acme-admin/api/v1/extra", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app := adminPrefixApp(t)
|
||||
plugins := []party.Plugin{
|
||||
adminPrefixPlugin{},
|
||||
prefixRoutePlugin{id: "acme.intruder", method: tc.method, path: tc.path, raw: tc.raw},
|
||||
}
|
||||
_, err := BuildRouter(app, plugins)
|
||||
if err == nil {
|
||||
t.Fatalf("BuildRouter accepted %s %s under the admin prefix", tc.method, tc.path)
|
||||
}
|
||||
for _, want := range []string{tc.method, tc.path, "acme.intruder", "/acme-admin"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %q does not name %q", err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("sibling path is allowed", func(t *testing.T) {
|
||||
app := adminPrefixApp(t)
|
||||
plugins := []party.Plugin{
|
||||
adminPrefixPlugin{},
|
||||
prefixRoutePlugin{id: "acme.neighbour", method: http.MethodGet, path: "/acme-adminx"},
|
||||
}
|
||||
if _, err := BuildRouter(app, plugins); err != nil {
|
||||
t.Fatalf("sibling path rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func adminPrefixApp(t *testing.T) *backpack.App {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: admin-prefix\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "backend.yaml"), []byte("uri: /acme-admin\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=summercms-test-only-admin-hs256-secret"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
type adminPrefixPlugin struct{}
|
||||
|
||||
func (adminPrefixPlugin) ID() string { return "acme.demo" }
|
||||
func (adminPrefixPlugin) Requires() []string { return nil }
|
||||
func (adminPrefixPlugin) Register(*backpack.App) error { return nil }
|
||||
func (adminPrefixPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (adminPrefixPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{adminPrefixController{}}
|
||||
}
|
||||
func (adminPrefixPlugin) AdminFS() fs.FS {
|
||||
return fstest.MapFS{
|
||||
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\n")},
|
||||
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")},
|
||||
}
|
||||
}
|
||||
|
||||
type adminPrefixController struct{}
|
||||
|
||||
func (adminPrefixController) ID() string { return "acme.demo.widgets" }
|
||||
func (adminPrefixController) ModelName() string { return "Widget" }
|
||||
func (adminPrefixController) ConfigDir() string { return "controllers/widgets" }
|
||||
|
||||
type prefixRoutePlugin struct {
|
||||
id string
|
||||
method string
|
||||
path string
|
||||
raw bool
|
||||
}
|
||||
|
||||
func (p prefixRoutePlugin) ID() string { return p.id }
|
||||
func (p prefixRoutePlugin) Requires() []string { return nil }
|
||||
func (p prefixRoutePlugin) Register(*backpack.App) error { return nil }
|
||||
func (p prefixRoutePlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p prefixRoutePlugin) Routes(r pact.Router) error {
|
||||
h := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
|
||||
open := r.Group
|
||||
if p.raw {
|
||||
open = r.GroupRaw
|
||||
}
|
||||
open("", nil, func(g pact.Router) {
|
||||
switch p.method {
|
||||
case http.MethodPost:
|
||||
g.Post(p.path, h)
|
||||
default:
|
||||
g.Get(p.path, h)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -529,6 +529,9 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
}
|
||||
r.BindPlugin("summercms.cabana")
|
||||
admin.Mount(r)
|
||||
if err := r.checkAdminPrefix(admin.Prefix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, rt := range r.routes {
|
||||
if _, err := r.wrap(rt); err != nil {
|
||||
@@ -538,6 +541,23 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// checkAdminPrefix fails boot when a plugin other than cabana owns a route at
|
||||
// or under the admin prefix: the admin SPA and API own that whole subtree.
|
||||
func (r *Router) checkAdminPrefix(prefix string) error {
|
||||
if prefix == "" {
|
||||
return nil
|
||||
}
|
||||
for _, rt := range r.routes {
|
||||
if rt.pluginID == "summercms.cabana" {
|
||||
continue
|
||||
}
|
||||
if rt.path == prefix || strings.HasPrefix(rt.path, prefix+"/") {
|
||||
return fmt.Errorf("surf: route %s %s (plugin %q) is under the admin prefix backend.uri %s", rt.method, rt.path, rt.pluginID, prefix)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredBytes(app *backpack.App, key string) (int64, error) {
|
||||
raw, ok := app.Config.Lookup(key)
|
||||
if !ok {
|
||||
|
||||
Reference in New Issue
Block a user