3c4ab82be15c

accept plain-http admin forms when the domain is localhost, so local saves stop being refused (v3.0.139)

time
2026-09-30 13:37 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
admin repo settings
parent
58d7fe07cda0
3 files package.json+1 −1src/server/login.ts+12 −3tests/admin.test.ts+16 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.138",3+	"version": "3.0.139",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/server/login.ts+12 −3
@@ -45,8 +45,18 @@4545 	return sessionToken(ctx, c) !== null;4646 }4747 48+/**49+ * Whether a request came from this site. A localhost domain also accepts plain http, since a50+ * local run has no TLS in front of it; browsers keep Secure cookies on http://localhost.51+ */52+function fromSite(c: Context, domain: string): boolean {53+	const origin = c.req.header("origin");54+	if (origin === `https://${domain}`) return true;55+	return origin === `http://${domain}` && /^localhost(:\d+)?$/.test(domain);56+}57+4858 export async function adminPostRefusal(ctx: PageContext, c: Context): Promise<string> {49-	if (c.req.header("origin") !== `https://${ctx.domain}`) return "Refused: wrong origin.";59+	if (!fromSite(c, ctx.domain)) return "Refused: wrong origin.";5060 	const form = (await c.req.parseBody()).form;5161 	return typeof form !== "string" || !sameText(form, formToken(c))5262 		? "Refused: this form expired. Reload and try again."@@ -81,7 +91,6 @@8191  * `/admin` route: a live session, and for anything but GET a matching `Origin` and form token.8292  */8393 export function registerLogin(app: Hono, ctx: PageContext, passwordHash: string): void {84-	const origin = `https://${ctx.domain}`;8594 	const refuse = (c: Context, status: 401 | 403 | 429, text: string) =>8695 		adminRefusal(c, ctx, status, text);8796 @@ -103,7 +112,7 @@103112 		const next = nextPath(fields.next);104113 		const cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";105114 		const form = typeof fields.form === "string" ? fields.form : "";106-		if (c.req.header("origin") !== origin || !HEX64.test(cookie) || !sameText(form, cookie)) {115+		if (!fromSite(c, ctx.domain) || !HEX64.test(cookie) || !sameText(form, cookie)) {107116 			return refuse(c, 403, "Refused: this form expired. Reload and try again.");108117 		}109118 		const now = Date.now();

view file

tests/admin.test.ts+16 −0
@@ -1,5 +1,6 @@11 import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";22 import type { Hono } from "hono";3+import { createApp } from "../src/server/app.ts";34 import { Browser, type Instance, instance, PASSWORD } from "./browser.ts";45 import { makeRepos, writeRelease } from "./fixture.ts";56 @@ -88,6 +89,21 @@8889 		expect((await browser.post("/sign-in", { password: PASSWORD }, "")).status).toBe(403);8990 		browser.cookies.delete("kepr_sign_in");9091 		expect((await browser.post("/sign-in", { password: PASSWORD })).status).toBe(403);92+	});93+94+	test("plain http is the site's origin only when the domain is localhost", async () => {95+		const plain = new Browser(inst.app, "198.51.100.4");96+		await plain.get("/sign-in");97+		const http = "http://kepr.example";98+		expect((await plain.post("/sign-in", { password: PASSWORD }, http)).status).toBe(403);99+		const local = createApp({ ...inst.config, domain: "localhost:3000" }, inst.kepr);100+		const browser = new Browser(local, "198.51.100.4");101+		const origin = "http://localhost:3000";102+		await browser.get("/sign-in");103+		expect((await browser.post("/sign-in", { password: PASSWORD }, origin)).status).toBe(303);104+		await browser.get("/admin");105+		expect((await browser.post("/admin/sync", {}, origin)).status).toBe(303);106+		expect((await browser.post("/admin/sync", {}, "http://localhost:4000")).status).toBe(403);91107 	});92108 93109 	test("five failures lock one address out for 15 minutes, even with the right password", async () => {

view file