Closes #24. Child of #18; based on current main (includes Postgres, Reader table, Discord OAuth).
## What
Each Reader's userscript credential is derived from `TOKEN_KEY`, their Discord id and a token epoch (HMAC-SHA256, hex); only its SHA-256 sits in `readers.token_sha256` (new `token_epoch` column, migration 0006). One credential authenticates the script download path and the API bearer header.
- `internal/token`: derivation + hashing; the seed refreshes the owner's epoch-0 hash only before first rotation, so a restart can never resurrect a rotated-away credential
- `httpmw.Auth`/`ResolveReader`: acting Reader resolved from the credential hash, stashed in request context; the retired global `API_TOKEN` resolves to the owner until `API_TOKEN_GRACE_UNTIL` (enforced in code, logged per use) on both the bearer and script-download paths
- Userscript handler renders the bindmounted file with the resolved Reader's credential substituted for `__API_TOKEN__`; a legacy-path request during grace serves the derived credential, so installed devices self-migrate on their next update poll
- Web UI: "Userscripts" panel — session-gated install endpoints render the script directly (credential never in markup, address bar, or a redirect), confirm-gated rotation with an atomic epoch bump + hash rewrite and a reinstall warning
- Both userscripts carry `__API_TOKEN__` placeholders; the committed global-token literal is removed
## Design note
Credentials are derived rather than stored-random because the server must rebuild install URLs after restarts while the DB holds only hashes. HMAC output is high-entropy and unbrute-forceable; the AC's intent (unguessable, DB-leak-proof) is met.
## Deploy (also in DEPLOY.md)
1. Add `TOKEN_KEY` (`openssl rand -hex 32`) — required; changing it later invalidates every credential.
2. Keep `API_TOKEN` + set `API_TOKEN_GRACE_UNTIL` for the 14-day window.
3. After deploy, sign in → Userscripts → reinstall both scripts on every device. This also retires the old global credential for real — its literal survives in git history (present since 0ef5286), so rotation is what kills it.
## Verification
- Full Go suite green against real Postgres per test; userscript JS suite 45/45
- New router-level tests: per-Reader isolation (read/write/delete), grace expiry on bearer + script path, self-migrating legacy path, install serving, rotation (old cred 401/404, new cred works, install renders new credential), app page leaks no credential
- Store tests: hash lookup, token info, atomic rotation with stale-epoch rejection, rotation survives restart
- Live smoke of the built binary: grace acceptance logged, derived auth, substitution, restart resilience, stored hash = SHA-256 of derived credential
Reviewed-on: #32
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #32.
This commit is contained in:
@@ -1,14 +1,25 @@
|
||||
package userscript
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/httpmw"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/token"
|
||||
)
|
||||
|
||||
// tokenPlaceholder is what the bindmounted userscript carries where the
|
||||
// Reader's credential goes: in the API_TOKEN constant and in the @downloadURL
|
||||
// and @updateURL metadata lines. The handler substitutes the requesting
|
||||
// Reader's credential for it at serve time, so no credential literal is ever
|
||||
// committed or deployed, and each Reader's copy carries exactly their own.
|
||||
var tokenPlaceholder = []byte("__API_TOKEN__")
|
||||
|
||||
// versionLine matches the userscript metadata block's @version directive.
|
||||
var versionLine = regexp.MustCompile(`(?m)^// @version[ \t]+.*$`)
|
||||
|
||||
@@ -26,35 +37,76 @@ func stampVersion(src []byte, mod time.Time) []byte {
|
||||
return versionLine.ReplaceAll(src, []byte("// @version "+mod.UTC().Format("2006.01.02.1504")))
|
||||
}
|
||||
|
||||
// userscriptHandler serves the userscript to Violentmonkey's updater.
|
||||
// substituteToken replaces every tokenPlaceholder with the Reader's
|
||||
// credential. A file without the placeholder is returned unchanged so Render
|
||||
// can warn about it rather than silently serving a credential-less script.
|
||||
func substituteToken(src []byte, credential string) []byte {
|
||||
return bytes.ReplaceAll(src, tokenPlaceholder, []byte(credential))
|
||||
}
|
||||
|
||||
// Render writes one userscript file with the credential substituted and the
|
||||
// mtime-derived version stamped. Shared by the download path (Handler) and
|
||||
// the web UI's install endpoints, so both serve byte-identical scripts.
|
||||
//
|
||||
// The token lives in the path because the update poll sends no Authorization
|
||||
// header, and the file embeds API_TOKEN in plain text, so an open path would
|
||||
// hand that token to anyone who guessed the URL. A mismatch answers 404 rather
|
||||
// than 401: a prober learns nothing about whether the route exists.
|
||||
// The file is read per request — that is what lets a bindmounted copy be
|
||||
// edited on the host without a restart. It is ~50 KB and polled about once a
|
||||
// day.
|
||||
func Render(w http.ResponseWriter, r *http.Request, path, credential string) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Printf("userscript: stat %s: %v", path, err)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("userscript: read %s: %v", path, err)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rendered := substituteToken(src, credential)
|
||||
if bytes.Equal(rendered, src) {
|
||||
// The bindmounted file was not built for per-Reader rendering. Serving
|
||||
// it as written is the operator's freedom, but a credential-less copy
|
||||
// is a deployment bug worth one log line — the symptom (silent 401s on
|
||||
// every device) is otherwise indistinguishable from a network fault.
|
||||
log.Printf("userscript: %s has no %s placeholder; serving as written", path, tokenPlaceholder)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write(stampVersion(rendered, info.ModTime()))
|
||||
}
|
||||
|
||||
// Handler serves the userscript to Violentmonkey's updater, rendered for the
|
||||
// Reader whose credential is in the path.
|
||||
//
|
||||
// The file is read per request — that is what lets a bindmounted copy be edited
|
||||
// on the host without a restart. It is ~50 KB and polled about once a day.
|
||||
func Handler(token, path string) http.HandlerFunc {
|
||||
// The credential lives in the path because the update poll sends no
|
||||
// Authorization header, and the rendered file embeds the credential in
|
||||
// plaintext, so an open path would hand it to anyone who guessed the URL. A
|
||||
// mismatch answers 404 rather than 401: a prober learns nothing about whether
|
||||
// the route exists. The same credential authenticates the API bearer header,
|
||||
// so the two are one secret with one blast radius.
|
||||
//
|
||||
// The credential substituted is the resolved Reader's derived one, not the
|
||||
// raw path segment: while the retired global token is still accepted during
|
||||
// the grace window (httpmw.ResolveReader), an already-installed script
|
||||
// polling its legacy URL is served a copy carrying the Reader's own
|
||||
// credential, so the next update poll migrates the device onto its per-Reader
|
||||
// path — the window empties itself instead of ending in a silent 401 for
|
||||
// every device that never visited the web UI.
|
||||
func Handler(s *store.Store, tokenKey []byte, legacy string, graceUntil time.Time, path string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 {
|
||||
readerID, ok := httpmw.ResolveReader(s, legacy, graceUntil, r.PathValue("token"))
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
discordID, epoch, err := s.ReaderTokenInfo(readerID)
|
||||
if err != nil {
|
||||
log.Printf("userscript: stat %s: %v", path, err)
|
||||
log.Printf("userscript: reader %d token info: %v", readerID, err)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("userscript: read %s: %v", path, err)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write(stampVersion(src, info.ModTime()))
|
||||
Render(w, r, path, token.Token(tokenKey, discordID, epoch))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +1,67 @@
|
||||
package userscript
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testToken = "s3cret-token"
|
||||
|
||||
// sampleScript is a stand-in for the real userscript: a metadata block with a
|
||||
// @version line, plus a body that must survive the rewrite untouched.
|
||||
// @version line, the credential placeholder in its metadata and body, plus
|
||||
// content that must survive the rewrites untouched.
|
||||
const sampleScript = `// ==UserScript==
|
||||
// @name Manga Bookmark Sync
|
||||
// @version 1.5.0
|
||||
// @downloadURL https://api.example/u/__API_TOKEN__/manga-bookmark.user.js
|
||||
// @match https://asurascans.com/*
|
||||
// ==/UserScript==
|
||||
(function () { "use strict"; })();
|
||||
(function () { "use strict";
|
||||
const API_TOKEN = "__API_TOKEN__";
|
||||
})();
|
||||
`
|
||||
|
||||
// writeScript drops a userscript in a temp dir with a known mtime and returns
|
||||
// its path plus the version string the handler is expected to stamp.
|
||||
func writeScript(t *testing.T, body string) (path, wantVersion string) {
|
||||
t.Helper()
|
||||
path = filepath.Join(t.TempDir(), "manga-bookmark.user.js")
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
func TestStampVersionReplacesVersionLineOnly(t *testing.T) {
|
||||
mod := time.Date(2026, 7, 28, 16, 42, 0, 0, time.UTC)
|
||||
if err := os.Chtimes(path, mod, mod); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
return path, "2026.07.28.1642"
|
||||
}
|
||||
got := string(stampVersion([]byte(sampleScript), mod))
|
||||
|
||||
// newTestMux registers Handler the same way main.go's router does, without
|
||||
// pulling in the store or the rest of the app.
|
||||
func newTestMux(token, path string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", Handler(token, path))
|
||||
return mux
|
||||
}
|
||||
|
||||
func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+token+"/manga-bookmark.user.js", nil))
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestUserscriptServedWithStampedVersion(t *testing.T) {
|
||||
path, wantVersion := writeScript(t, sampleScript)
|
||||
rr := getScript(t, newTestMux(testToken, path), testToken)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
if !strings.Contains(got, "// @version "+mod.UTC().Format("2006.01.02.1504")) {
|
||||
t.Errorf("body has no stamped version:\n%s", got)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
|
||||
t.Errorf("Content-Type = %q, want text/javascript", ct)
|
||||
if strings.Contains(got, "1.5.0") {
|
||||
t.Errorf("body still carries the file's own version:\n%s", got)
|
||||
}
|
||||
if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Errorf("Cache-Control = %q, want no-cache", cc)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "// @version "+wantVersion) {
|
||||
t.Errorf("body has no stamped version %q:\n%s", wantVersion, body)
|
||||
}
|
||||
if strings.Contains(body, "1.5.0") {
|
||||
t.Errorf("body still carries the file's own version:\n%s", body)
|
||||
}
|
||||
// Everything outside the @version line is served verbatim.
|
||||
if !strings.Contains(body, `(function () { "use strict"; })();`) {
|
||||
t.Errorf("body was altered beyond the version line:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "// @name Manga Bookmark Sync") {
|
||||
t.Errorf("metadata block was altered:\n%s", body)
|
||||
// Everything outside the @version line is served verbatim, including the
|
||||
// placeholder — stamping must not do the substitution's job.
|
||||
if !strings.Contains(got, `const API_TOKEN = "__API_TOKEN__";`) {
|
||||
t.Errorf("body was altered beyond the version line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The empty-token case ("/u//manga-bookmark.user.js") is covered at the
|
||||
// router level (see backend's guardEmptyUserscriptToken): ServeMux 307s it to
|
||||
// "/u/manga-bookmark.user.js" before this handler's own token check ever runs.
|
||||
func TestUserscriptWrongTokenIs404(t *testing.T) {
|
||||
path, _ := writeScript(t, sampleScript)
|
||||
srv := newTestMux(testToken, path)
|
||||
for _, tok := range []string{"wrong", testToken + "x", testToken[:3]} {
|
||||
if got := getScript(t, srv, tok).Code; got != http.StatusNotFound {
|
||||
t.Errorf("token %q: status = %d, want 404", tok, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserscriptMissingFileIs404(t *testing.T) {
|
||||
srv := newTestMux(testToken, filepath.Join(t.TempDir(), "absent.user.js"))
|
||||
if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
|
||||
func TestStampVersionWithoutVersionLineServedUnmodified(t *testing.T) {
|
||||
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
|
||||
path, _ := writeScript(t, noVersion)
|
||||
rr := getScript(t, newTestMux(testToken, path), testToken)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if rr.Body.String() != noVersion {
|
||||
t.Fatalf("body = %q, want it unmodified", rr.Body.String())
|
||||
if got := string(stampVersion([]byte(noVersion), time.Now())); got != noVersion {
|
||||
t.Errorf("stampVersion altered a file with no @version line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteTokenReplacesEveryPlaceholder(t *testing.T) {
|
||||
got := string(substituteToken([]byte(sampleScript), "abc123"))
|
||||
|
||||
if strings.Contains(got, "__API_TOKEN__") {
|
||||
t.Errorf("placeholder survived substitution:\n%s", got)
|
||||
}
|
||||
// The credential lands in the constant and in both metadata lines.
|
||||
if want := `const API_TOKEN = "abc123";`; !strings.Contains(got, want) {
|
||||
t.Errorf("no substituted constant %q:\n%s", want, got)
|
||||
}
|
||||
if want := "https://api.example/u/abc123/manga-bookmark.user.js"; !strings.Contains(got, want) {
|
||||
t.Errorf("no substituted download URL %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteTokenWithoutPlaceholderServedUnmodified(t *testing.T) {
|
||||
const noPlaceholder = "// ==UserScript==\n// @name x\n// ==/UserScript==\n"
|
||||
if got := string(substituteToken([]byte(noPlaceholder), "abc123")); got != noPlaceholder {
|
||||
t.Errorf("substituteToken altered a file without the placeholder:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user