180ee78b1f
Tracks read progress on comix.to and kagane.to alongside asura and demonic, in both the userscript and the backend. Implements `docs/superpowers/plans/2026-08-03-comix-kagane-support.md`. ## Userscript - `comix` adapter — `/title/<id>-<slug>`; only the id prefix is identity (the slug follows the title). No `og:image`, so the cover is matched by `alt`. - `kagane` adapter — reader URLs are uuids with no chapter number, so it comes out of `og:title`; anchor scanning is structurally impossible, replaced by `latestChapterFromApi` against kagane's same-origin JSON API. - `seriesId` threaded through `latestChapterFromAnchors` so comix can scope its scan to its own series and a recommendation strip cannot win the maximum. - `@match` for both hosts, panel chips, v1.6.0. ## Backend - `latestChapterFrom` cases: comix parses the SSR JSON state blob (`latestChapterUrl`, scoped to the series id); kagane parses API JSON (`chapter_no`). - Poller allowlist extended; `Poller.BrowserFetch` with `fetcherFor(site)` routes kagane to a browser fetcher. Nil means kagane is not polled at all — never a fallback to the TLS fetcher, which would only ever retrieve a challenge page. - `BrowserFetcher`: chromedp against a `headless-shell` sidecar. kagane sits behind a Cloudflare JS challenge that no TLS fingerprint clears, and the request is made inside the page rather than by replaying `cf_clearance`. - `BROWSER_WS_URL` wiring, sidecar in both compose files (no `ports:`, dedicated non-external network), Dockerfile on `golang:1.26-alpine` — chromedp requires go 1.26. - Web UI `--comix` / `--kagane` tokens in both colour branches. ## Notes for review - `series_url` is client-supplied and a headless browser is a strong SSRF primitive, so kagane's host is pinned twice: in `fetchableSeriesURL` and again in `kaganeAPIURL`. - Three chained defects found during verification made the browser path dead under Compose (sidecar flag collision, Chrome's Host-header DNS-rebinding check, the wrong chromedp option). Fixed; the compose comments record the wrong configurations too, so they don't get "simplified" back. - `ALLOWED_ORIGINS` now includes both new origins. Without it every write from comix/kagane silently fails CORS preflight, parks in the retry queue, and drops at the cap. ## Verification 221 backend tests, 32 userscript tests, static `CGO_ENABLED=0` build, both compose configs. Two gaps, both real: 1. The userscript on live pages via Violentmonkey needs a human browser profile — not run. Check: comix series page (title/cover, no chapter), comix chapter page (records the number; an *older* chapter must not regress it), comix SPA navigation without reload, kagane series page (og:image cover), kagane reader (number from `og:title`), both chips opening the right sites. 2. The kagane browser path has not completed end-to-end anywhere. Dial/navigate/fetch is confirmed, but Cloudflare 403'd headless-shell's Chrome on every attempt from the dev sandbox, and comix's poll-through-Docker was blocked by that environment's TLS interception. Both environment-dependent rather than branch defects — the first real deploy is the actual verification. Reviewed-on: #13 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
233 lines
6.0 KiB
Go
233 lines
6.0 KiB
Go
package session
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSessionRoundTrip(t *testing.T) {
|
|
key := Key("token-abc", "pw-abc")
|
|
now := time.Now().UnixMilli()
|
|
value := Sign(key, now+60_000)
|
|
if !Verify(key, value, now) {
|
|
t.Fatal("Verify = false for a freshly signed cookie, want true")
|
|
}
|
|
}
|
|
|
|
func TestSessionRejects(t *testing.T) {
|
|
key := Key("token-abc", "pw-abc")
|
|
now := time.Now().UnixMilli()
|
|
valid := Sign(key, now+60_000)
|
|
payload, sig, _ := strings.Cut(valid, ".")
|
|
|
|
cases := []struct {
|
|
name string
|
|
value string
|
|
}{
|
|
{"empty", ""},
|
|
{"no separator", payload + sig},
|
|
{"unparseable expiry", "notanumber." + sig},
|
|
{"expired", Sign(key, now-1)},
|
|
{"tampered signature", payload + "." + flipLastChar(sig)},
|
|
{"tampered expiry", "99999999999999." + sig},
|
|
{"signed with another key", Sign(Key("other-token", "pw-abc"), now+60_000)},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if Verify(key, tc.value, now) {
|
|
t.Fatalf("Verify(%q) = true, want false", tc.value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func flipLastChar(s string) string {
|
|
if s == "" {
|
|
return "x"
|
|
}
|
|
last := s[len(s)-1]
|
|
if last == 'A' {
|
|
return s[:len(s)-1] + "B"
|
|
}
|
|
return s[:len(s)-1] + "A"
|
|
}
|
|
|
|
func TestSessionKeyDependsOnToken(t *testing.T) {
|
|
a := Key("token-a", "pw-abc")
|
|
b := Key("token-b", "pw-abc")
|
|
if string(a) == string(b) {
|
|
t.Fatal("Key collided for different API tokens")
|
|
}
|
|
}
|
|
|
|
func TestSessionKeyDependsOnWebPassword(t *testing.T) {
|
|
a := Key("token-abc", "pw-a")
|
|
b := Key("token-abc", "pw-b")
|
|
if string(a) == string(b) {
|
|
t.Fatal("Key collided for different web passwords with the same API token")
|
|
}
|
|
}
|
|
|
|
func TestSetSessionCookieAttributes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
tls bool
|
|
forwarded string
|
|
wantSecure bool
|
|
}{
|
|
{"plain http dev", false, "", false},
|
|
{"direct tls", true, "", true},
|
|
{"behind https proxy", false, "https", true},
|
|
{"behind http proxy", false, "http", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPost, "/login", nil)
|
|
if tc.tls {
|
|
r.TLS = &tls.ConnectionState{}
|
|
}
|
|
if tc.forwarded != "" {
|
|
r.Header.Set("X-Forwarded-Proto", tc.forwarded)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
SetCookie(rr, r, Key("token-abc", "pw-abc"))
|
|
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
|
}
|
|
c := cookies[0]
|
|
if c.Name != CookieName {
|
|
t.Fatalf("cookie name = %q, want %q", c.Name, CookieName)
|
|
}
|
|
if !c.HttpOnly {
|
|
t.Fatal("cookie HttpOnly = false, want true")
|
|
}
|
|
if c.SameSite != http.SameSiteLaxMode {
|
|
t.Fatalf("cookie SameSite = %v, want Lax", c.SameSite)
|
|
}
|
|
if c.Path != "/" {
|
|
t.Fatalf("cookie Path = %q, want /", c.Path)
|
|
}
|
|
if c.Secure != tc.wantSecure {
|
|
t.Fatalf("cookie Secure = %v, want %v", c.Secure, tc.wantSecure)
|
|
}
|
|
if c.MaxAge != int(sessionTTL/time.Second) {
|
|
t.Fatalf("cookie MaxAge = %d, want %d", c.MaxAge, int(sessionTTL/time.Second))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClearSessionCookie(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
|
rr := httptest.NewRecorder()
|
|
ClearCookie(rr, r)
|
|
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
|
}
|
|
if cookies[0].MaxAge >= 0 {
|
|
t.Fatalf("cleared cookie MaxAge = %d, want negative", cookies[0].MaxAge)
|
|
}
|
|
}
|
|
|
|
func TestClientIP(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
remoteAddr string
|
|
xff []string
|
|
want string
|
|
}{
|
|
{"no header falls back to remote addr", "203.0.113.9:5555", nil, "203.0.113.9"},
|
|
{"single proxy hop", "10.0.0.1:5555", []string{"203.0.113.9"}, "203.0.113.9"},
|
|
{
|
|
// The client sent "1.2.3.4" itself; Traefik appended the address it
|
|
// actually saw. Only the rightmost entry is trustworthy.
|
|
name: "spoofed left entry is ignored",
|
|
remoteAddr: "10.0.0.1:5555",
|
|
xff: []string{"1.2.3.4, 203.0.113.9"},
|
|
want: "203.0.113.9",
|
|
},
|
|
{
|
|
name: "spoofed separate header line is ignored",
|
|
remoteAddr: "10.0.0.1:5555",
|
|
xff: []string{"1.2.3.4", "203.0.113.9"},
|
|
want: "203.0.113.9",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPost, "/login", nil)
|
|
r.RemoteAddr = tc.remoteAddr
|
|
for _, v := range tc.xff {
|
|
r.Header.Add("X-Forwarded-For", v)
|
|
}
|
|
if got := ClientIP(r); got != tc.want {
|
|
t.Fatalf("ClientIP() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) {
|
|
l := NewLoginLimiter()
|
|
now := time.Now()
|
|
for i := 0; i < MaxFailures; i++ {
|
|
if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
|
|
t.Fatalf("blocked after %d failures, want block only after %d", i, MaxFailures)
|
|
}
|
|
l.Fail("1.2.3.4", now)
|
|
}
|
|
wait := l.RetryAfter("1.2.3.4", now)
|
|
if wait <= 0 {
|
|
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, MaxFailures)
|
|
}
|
|
if wait > Window {
|
|
t.Fatalf("retryAfter = %v, want <= %v", wait, Window)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterWindowExpires(t *testing.T) {
|
|
l := NewLoginLimiter()
|
|
start := time.Now()
|
|
for i := 0; i < MaxFailures; i++ {
|
|
l.Fail("1.2.3.4", start)
|
|
}
|
|
if l.RetryAfter("1.2.3.4", start) == 0 {
|
|
t.Fatal("expected block immediately after the failures")
|
|
}
|
|
later := start.Add(Window + time.Second)
|
|
if wait := l.RetryAfter("1.2.3.4", later); wait != 0 {
|
|
t.Fatalf("retryAfter = %v once the window passed, want 0", wait)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterResetClearsCounter(t *testing.T) {
|
|
l := NewLoginLimiter()
|
|
now := time.Now()
|
|
for i := 0; i < MaxFailures; i++ {
|
|
l.Fail("1.2.3.4", now)
|
|
}
|
|
l.Reset("1.2.3.4")
|
|
if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
|
|
t.Fatalf("retryAfter = %v after reset, want 0", wait)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterIsPerIP(t *testing.T) {
|
|
l := NewLoginLimiter()
|
|
now := time.Now()
|
|
for i := 0; i < MaxFailures; i++ {
|
|
l.Fail("1.2.3.4", now)
|
|
}
|
|
if wait := l.RetryAfter("5.6.7.8", now); wait != 0 {
|
|
t.Fatalf("retryAfter for a different IP = %v, want 0", wait)
|
|
}
|
|
}
|