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
↓ kepr-3c4ab82b.tar.gzatlas at this save
files changed3 files
package.json+1 −1src/server/login.ts+12 −3tests/admin.test.ts+16 −0package.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": {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();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 () => {