Gate the reader with a token and let it serve a web client
Two things that only matter once this leaves loopback. GLEAN_TOKEN puts a shared secret in front of everything. It is checked before any route reads the database or fetches a URL, which is the point: /feeds and /fetch-content will retrieve any URL handed to them, so an open instance is an SSRF proxy as much as it is someone else's feed list. Three ways to present it, because there are three callers -- a header for a native client, ?key= for a person following a link, and the cookie that ?key= then sets so the token stays out of history and Referer. The comparison is constant-time; an early return is an oracle for recovering it a byte at a time. Unset, there is no gate and nothing changes for local use. The listener follows: loopback when there is no token, all interfaces when there is, so "reachable in a container" cannot happen without the token. GLEAN_WEB serves a Flutter web build instead of the page baked into the binary. Resolved paths are checked to still be inside the root, since "../" in a request path is how a static file server becomes a file server for the whole disk. Content types are set explicitly -- a browser will not execute JavaScript served as text/plain. The Flutter client takes a token too, as a header. The web build leaves its base URL empty and uses relative paths: served by the reader, it is already same-origin, which means no CORS preflight and the reader's own cookie on every request. Checked in a browser against a real reader: the app loads, lists articles and renders one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
00a8370 parent: af3d2f7 modified
app/.gitignore +3 -0 | @@ -46,3 +46,6 @@ app.*.map.json | ||
| 46 | 46 | |
| 47 | 47 | # Widget Preview related |
| 48 | 48 | .widget_preview/ |
| 49 | + | |
| 50 | +# web build output | |
| 51 | +/build/ | |
| @@ -46,3 +46,6 @@ app.*.map.json | |||
| 46 | 46 | ||
| 47 | # Widget Preview related | 47 | # Widget Preview related |
| 48 | .widget_preview/ | 48 | .widget_preview/ |
| 49 | + | ||
| 50 | +# web build output | ||
| 51 | +/build/ | ||
modified
app/lib/src/api/client.dart +17 -4 | @@ -17,12 +17,23 @@ class ApiException implements Exception { | ||
| 17 | 17 | /// No cookies, no CSRF, no auth: the server is single-user and binds |
| 18 | 18 | /// loopback. That is why this class is a thin wrapper rather than a session. |
| 19 | 19 | class GleanClient { |
| 20 | - GleanClient({required this.baseUrl, http.Client? client}) | |
| 20 | + GleanClient({required this.baseUrl, this.token = '', http.Client? client}) | |
| 21 | 21 | : _client = client ?? http.Client(); |
| 22 | 22 | |
| 23 | 23 | final String baseUrl; |
| 24 | + | |
| 25 | + /// Shared secret, when the reader is running with GLEAN_TOKEN set. Empty | |
| 26 | + /// for a local reader, which has no gate. | |
| 27 | + /// | |
| 28 | + /// Sent as a header rather than a query parameter so it stays out of the | |
| 29 | + /// browser's history and any Referer it sends. | |
| 30 | + final String token; | |
| 31 | + | |
| 24 | 32 | final http.Client _client; |
| 25 | 33 | |
| 34 | + Map<String, String> get _headers => | |
| 35 | + token.isEmpty ? const {} : {'X-Glean-Token': token}; | |
| 36 | + | |
| 26 | 37 | Uri _uri(String path, [Map<String, String>? query]) { |
| 27 | 38 | final q = query?.entries.where((e) => e.value.isNotEmpty); |
| 28 | 39 | return Uri.parse('$baseUrl$path').replace( |
| @@ -47,10 +58,11 @@ class GleanClient { | ||
| 47 | 58 | } |
| 48 | 59 | |
| 49 | 60 | Future<dynamic> _get(String path, [Map<String, String>? q]) async => |
| 50 | - _decode(await _client.get(_uri(path, q))); | |
| 61 | + _decode(await _client.get(_uri(path, q), headers: _headers)); | |
| 51 | 62 | |
| 52 | 63 | Future<dynamic> _post(String path, [Map<String, String>? form]) async => |
| 53 | - _decode(await _client.post(_uri(path), body: form ?? const {})); | |
| 64 | + _decode(await _client.post(_uri(path), | |
| 65 | + headers: _headers, body: form ?? const {})); | |
| 54 | 66 | |
| 55 | 67 | // --- feeds --- |
| 56 | 68 | |
| @@ -71,7 +83,8 @@ class GleanClient { | ||
| 71 | 83 | } |
| 72 | 84 | |
| 73 | 85 | Future<void> removeFeed(String feedUrl) async => |
| 74 | - _decode(await _client.delete(_uri('/feeds', {'url': feedUrl}))); | |
| 86 | + _decode(await _client.delete(_uri('/feeds', {'url': feedUrl}), | |
| 87 | + headers: _headers)); | |
| 75 | 88 | |
| 76 | 89 | Future<RefreshResult> refresh() async => |
| 77 | 90 | RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>); |
| @@ -17,12 +17,23 @@ class ApiException implements Exception { | |||
| 17 | /// No cookies, no CSRF, no auth: the server is single-user and binds | 17 | /// No cookies, no CSRF, no auth: the server is single-user and binds |
| 18 | /// loopback. That is why this class is a thin wrapper rather than a session. | 18 | /// loopback. That is why this class is a thin wrapper rather than a session. |
| 19 | class GleanClient { | 19 | class GleanClient { |
| 20 | - GleanClient({required this.baseUrl, http.Client? client}) | 20 | + GleanClient({required this.baseUrl, this.token = '', http.Client? client}) |
| 21 | : _client = client ?? http.Client(); | 21 | : _client = client ?? http.Client(); |
| 22 | 22 | ||
| 23 | final String baseUrl; | 23 | final String baseUrl; |
| 24 | + | ||
| 25 | + /// Shared secret, when the reader is running with GLEAN_TOKEN set. Empty | ||
| 26 | + /// for a local reader, which has no gate. | ||
| 27 | + /// | ||
| 28 | + /// Sent as a header rather than a query parameter so it stays out of the | ||
| 29 | + /// browser's history and any Referer it sends. | ||
| 30 | + final String token; | ||
| 31 | + | ||
| 24 | final http.Client _client; | 32 | final http.Client _client; |
| 25 | 33 | ||
| 34 | + Map<String, String> get _headers => | ||
| 35 | + token.isEmpty ? const {} : {'X-Glean-Token': token}; | ||
| 36 | + | ||
| 26 | Uri _uri(String path, [Map<String, String>? query]) { | 37 | Uri _uri(String path, [Map<String, String>? query]) { |
| 27 | final q = query?.entries.where((e) => e.value.isNotEmpty); | 38 | final q = query?.entries.where((e) => e.value.isNotEmpty); |
| 28 | return Uri.parse('$baseUrl$path').replace( | 39 | return Uri.parse('$baseUrl$path').replace( |
| @@ -47,10 +58,11 @@ class GleanClient { | |||
| 47 | } | 58 | } |
| 48 | 59 | ||
| 49 | Future<dynamic> _get(String path, [Map<String, String>? q]) async => | 60 | Future<dynamic> _get(String path, [Map<String, String>? q]) async => |
| 50 | - _decode(await _client.get(_uri(path, q))); | 61 | + _decode(await _client.get(_uri(path, q), headers: _headers)); |
| 51 | 62 | ||
| 52 | Future<dynamic> _post(String path, [Map<String, String>? form]) async => | 63 | Future<dynamic> _post(String path, [Map<String, String>? form]) async => |
| 53 | - _decode(await _client.post(_uri(path), body: form ?? const {})); | 64 | + _decode(await _client.post(_uri(path), |
| 65 | + headers: _headers, body: form ?? const {})); | ||
| 54 | 66 | ||
| 55 | // --- feeds --- | 67 | // --- feeds --- |
| 56 | 68 | ||
| @@ -71,7 +83,8 @@ class GleanClient { | |||
| 71 | } | 83 | } |
| 72 | 84 | ||
| 73 | Future<void> removeFeed(String feedUrl) async => | 85 | Future<void> removeFeed(String feedUrl) async => |
| 74 | - _decode(await _client.delete(_uri('/feeds', {'url': feedUrl}))); | 86 | + _decode(await _client.delete(_uri('/feeds', {'url': feedUrl}), |
| 87 | + headers: _headers)); | ||
| 75 | 88 | ||
| 76 | Future<RefreshResult> refresh() async => | 89 | Future<RefreshResult> refresh() async => |
| 77 | RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>); | 90 | RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>); |
modified
app/lib/src/app_state.dart +17 -4 | @@ -7,21 +7,34 @@ import 'models/models.dart'; | ||
| 7 | 7 | /// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:8080 |
| 8 | 8 | /// |
| 9 | 9 | /// 10.0.2.2 is how the Android emulator reaches the host's loopback; a real |
| 10 | -/// device needs the machine's LAN address, and the server has no auth, so | |
| 11 | -/// only do that on a network you trust. | |
| 10 | +/// device needs the machine's LAN address. | |
| 11 | +/// | |
| 12 | +/// The web build is served *by* the reader, so it leaves this empty and uses | |
| 13 | +/// relative paths: same origin, which means no CORS preflight and the | |
| 14 | +/// reader's own cookie on every request. | |
| 12 | 15 | const kDefaultBaseUrl = String.fromEnvironment( |
| 13 | 16 | 'GLEAN_BASE_URL', |
| 14 | 17 | defaultValue: 'http://127.0.0.1:8080', |
| 15 | 18 | ); |
| 16 | 19 | |
| 20 | +/// Shared secret, for a reader running with GLEAN_TOKEN set. | |
| 21 | +/// | |
| 22 | +/// Empty by default, which is right for a local reader and for the web build | |
| 23 | +/// served by the reader itself -- there the browser already holds the cookie | |
| 24 | +/// the reader set, and same-origin requests carry it automatically. | |
| 25 | +const kToken = String.fromEnvironment('GLEAN_TOKEN', defaultValue: ''); | |
| 26 | + | |
| 17 | 27 | /// Feed list and unread counts, shared across screens. |
| 18 | 28 | /// |
| 19 | 29 | /// Articles are not held here: each screen fetches its own, because the |
| 20 | 30 | /// filters differ and a shared list would be wrong for whichever screen did |
| 21 | 31 | /// not set them. |
| 22 | 32 | class AppState extends ChangeNotifier { |
| 23 | - AppState({String baseUrl = kDefaultBaseUrl, GleanClient? client}) | |
| 24 | - : client = client ?? GleanClient(baseUrl: baseUrl); | |
| 33 | + AppState({ | |
| 34 | + String baseUrl = kDefaultBaseUrl, | |
| 35 | + String token = kToken, | |
| 36 | + GleanClient? client, | |
| 37 | + }) : client = client ?? GleanClient(baseUrl: baseUrl, token: token); | |
| 25 | 38 | |
| 26 | 39 | final GleanClient client; |
| 27 | 40 | |
| @@ -7,21 +7,34 @@ import 'models/models.dart'; | |||
| 7 | /// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:8080 | 7 | /// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:8080 |
| 8 | /// | 8 | /// |
| 9 | /// 10.0.2.2 is how the Android emulator reaches the host's loopback; a real | 9 | /// 10.0.2.2 is how the Android emulator reaches the host's loopback; a real |
| 10 | -/// device needs the machine's LAN address, and the server has no auth, so | 10 | +/// device needs the machine's LAN address. |
| 11 | -/// only do that on a network you trust. | 11 | +/// |
| 12 | +/// The web build is served *by* the reader, so it leaves this empty and uses | ||
| 13 | +/// relative paths: same origin, which means no CORS preflight and the | ||
| 14 | +/// reader's own cookie on every request. | ||
| 12 | const kDefaultBaseUrl = String.fromEnvironment( | 15 | const kDefaultBaseUrl = String.fromEnvironment( |
| 13 | 'GLEAN_BASE_URL', | 16 | 'GLEAN_BASE_URL', |
| 14 | defaultValue: 'http://127.0.0.1:8080', | 17 | defaultValue: 'http://127.0.0.1:8080', |
| 15 | ); | 18 | ); |
| 16 | 19 | ||
| 20 | +/// Shared secret, for a reader running with GLEAN_TOKEN set. | ||
| 21 | +/// | ||
| 22 | +/// Empty by default, which is right for a local reader and for the web build | ||
| 23 | +/// served by the reader itself -- there the browser already holds the cookie | ||
| 24 | +/// the reader set, and same-origin requests carry it automatically. | ||
| 25 | +const kToken = String.fromEnvironment('GLEAN_TOKEN', defaultValue: ''); | ||
| 26 | + | ||
| 17 | /// Feed list and unread counts, shared across screens. | 27 | /// Feed list and unread counts, shared across screens. |
| 18 | /// | 28 | /// |
| 19 | /// Articles are not held here: each screen fetches its own, because the | 29 | /// Articles are not held here: each screen fetches its own, because the |
| 20 | /// filters differ and a shared list would be wrong for whichever screen did | 30 | /// filters differ and a shared list would be wrong for whichever screen did |
| 21 | /// not set them. | 31 | /// not set them. |
| 22 | class AppState extends ChangeNotifier { | 32 | class AppState extends ChangeNotifier { |
| 23 | - AppState({String baseUrl = kDefaultBaseUrl, GleanClient? client}) | 33 | + AppState({ |
| 24 | - : client = client ?? GleanClient(baseUrl: baseUrl); | 34 | + String baseUrl = kDefaultBaseUrl, |
| 35 | + String token = kToken, | ||
| 36 | + GleanClient? client, | ||
| 37 | + }) : client = client ?? GleanClient(baseUrl: baseUrl, token: token); | ||
| 25 | 38 | ||
| 26 | final GleanClient client; | 39 | final GleanClient client; |
| 27 | 40 | ||
modified
spike/nim/glean.nim +122 -7 | @@ -3,10 +3,19 @@ | ||
| 3 | 3 | ## nim c -r -d:ssl glean.nim |
| 4 | 4 | ## GLEAN_DB=~/.local/share/glean/db GLEAN_PORT=8080 ./glean |
| 5 | 5 | ## |
| 6 | -## Single user, no accounts, no auth. It runs on your machine and reads your | |
| 7 | -## feeds. That assumption is what keeps this a few hundred lines instead of a | |
| 6 | +## Single user, no accounts. It runs on your machine and reads your feeds, | |
| 7 | +## and that assumption is what keeps this a few hundred lines instead of a | |
| 8 | 8 | ## few thousand: no sessions, no CSRF, no per-user scoping, no ATProto. |
| 9 | 9 | ## |
| 10 | +## GLEAN_TOKEN puts a shared secret in front of everything, which is what | |
| 11 | +## makes it safe anywhere but loopback -- `/feeds` and `/fetch-content` will | |
| 12 | +## fetch any URL handed to them, so an open instance is an SSRF proxy as well | |
| 13 | +## as someone else's feed list. Unset, there is no gate at all and the local | |
| 14 | +## case stays exactly as simple as it was. | |
| 15 | +## | |
| 16 | +## GLEAN_WEB points at a directory of static files (a Flutter web build) to | |
| 17 | +## serve instead of the built-in page. | |
| 18 | +## | |
| 10 | 19 | ## The pieces underneath -- fetching, parsing, storage, search, full-text |
| 11 | 20 | ## extraction -- are the same modules the larger port built and tested. |
| 12 | 21 | |
| @@ -28,6 +37,34 @@ const | ||
| 28 | 37 | |
| 29 | 38 | type Reader = ref object |
| 30 | 39 | db: GleanDb |
| 40 | + token: string ## empty disables the gate entirely | |
| 41 | + webRoot: string ## empty serves the built-in page | |
| 42 | + | |
| 43 | +proc cookieValue(header, name: string): string = | |
| 44 | + for part in header.split(';'): | |
| 45 | + let kv = part.strip() | |
| 46 | + let i = kv.find('=') | |
| 47 | + if i > 0 and kv[0 ..< i] == name: return kv[i + 1 .. ^1] | |
| 48 | + "" | |
| 49 | + | |
| 50 | +proc constantTimeEq(a, b: string): bool = | |
| 51 | + ## Compare without leaking where the first difference is. A check that | |
| 52 | + ## returns early is an oracle for recovering the token a byte at a time. | |
| 53 | + if a.len != b.len: return false | |
| 54 | + var diff = 0'u8 | |
| 55 | + for i in 0 ..< a.len: | |
| 56 | + diff = diff or (a[i].uint8 xor b[i].uint8) | |
| 57 | + diff == 0 | |
| 58 | + | |
| 59 | +proc presentedToken(req: Request, queryKey: string): string = | |
| 60 | + ## Header first, then ?key=, then the cookie ?key= sets. | |
| 61 | + ## | |
| 62 | + ## Three ways because three callers: a native client sends a header, a | |
| 63 | + ## person opens a link, and the browser then has a cookie. | |
| 64 | + let h = req.headers.getOrDefault("X-Glean-Token").string | |
| 65 | + if h.len > 0: return h | |
| 66 | + if queryKey.len > 0: return queryKey | |
| 67 | + cookieValue(req.headers.getOrDefault("Cookie").string, "glean_token") | |
| 31 | 68 | |
| 32 | 69 | proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} = |
| 33 | 70 | await req.respond(code, $node, newHttpHeaders({ |
| @@ -39,6 +76,48 @@ proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} = | ||
| 39 | 76 | proc fail(req: Request, code: HttpCode, msg: string) {.async.} = |
| 40 | 77 | await jsonResponse(req, code, %*{"error": msg}) |
| 41 | 78 | |
| 79 | +proc contentTypeFor(path: string): string = | |
| 80 | + ## Enough types for a Flutter web build. A wrong type here is not cosmetic: | |
| 81 | + ## a browser will refuse to execute JavaScript served as text/plain. | |
| 82 | + let ext = path.splitFile.ext.toLowerAscii | |
| 83 | + case ext | |
| 84 | + of ".html": "text/html; charset=utf-8" | |
| 85 | + of ".js", ".mjs": "text/javascript; charset=utf-8" | |
| 86 | + of ".css": "text/css; charset=utf-8" | |
| 87 | + of ".json": "application/json" | |
| 88 | + of ".wasm": "application/wasm" | |
| 89 | + of ".png": "image/png" | |
| 90 | + of ".jpg", ".jpeg": "image/jpeg" | |
| 91 | + of ".svg": "image/svg+xml" | |
| 92 | + of ".ico": "image/x-icon" | |
| 93 | + of ".woff2": "font/woff2" | |
| 94 | + of ".woff": "font/woff" | |
| 95 | + of ".ttf": "font/ttf" | |
| 96 | + of ".otf": "font/otf" | |
| 97 | + of ".map": "application/json" | |
| 98 | + else: "application/octet-stream" | |
| 99 | + | |
| 100 | +proc serveStatic(r: Reader, req: Request, rel: string): Future[bool] {.async.} = | |
| 101 | + ## Serve `rel` from the web root, if it is there. Returns false when it is | |
| 102 | + ## not, so the caller can fall through. | |
| 103 | + if r.webRoot.len == 0: return false | |
| 104 | + # Resolve and confirm the result is still inside the root: "../" in a | |
| 105 | + # request path is how a static file server turns into a file server for | |
| 106 | + # the whole disk. | |
| 107 | + let full = absolutePath(r.webRoot / rel) | |
| 108 | + let root = absolutePath(r.webRoot) | |
| 109 | + if not full.startsWith(root) or not fileExists(full): return false | |
| 110 | + await req.respond(Http200, readFile(full), | |
| 111 | + newHttpHeaders({"Content-Type": contentTypeFor(full)})) | |
| 112 | + true | |
| 113 | + | |
| 114 | +proc serveApp(r: Reader, req: Request) {.async.} = | |
| 115 | + ## The client: a Flutter web build when one is configured, otherwise the | |
| 116 | + ## page baked into the binary. | |
| 117 | + if await serveStatic(r, req, "index.html"): return | |
| 118 | + await req.respond(Http200, IndexHtml, | |
| 119 | + newHttpHeaders({"Content-Type": "text/html; charset=utf-8"})) | |
| 120 | + | |
| 42 | 121 | proc toJson(a: Article): JsonNode = |
| 43 | 122 | %*{ |
| 44 | 123 | "id": a.id, |
| @@ -120,12 +199,27 @@ proc handle(r: Reader, req: Request) {.async.} = | ||
| 120 | 199 | else: |
| 121 | 200 | try: parseInt(raw) except ValueError: fallback |
| 122 | 201 | |
| 202 | + # The gate, before anything reads the database or fetches a URL. | |
| 203 | + if r.token.len > 0: | |
| 204 | + if not constantTimeEq(presentedToken(req, param("key")), r.token): | |
| 205 | + await fail(req, Http401, "unauthorized") | |
| 206 | + return | |
| 207 | + # Arriving with ?key= means a person followed a link; remember it so the | |
| 208 | + # rest of the app's requests carry it without the token sitting in every | |
| 209 | + # URL (and in the browser history, and in any Referer it sends). | |
| 210 | + if param("key").len > 0: | |
| 211 | + await req.respond(Http303, "", newHttpHeaders({ | |
| 212 | + "Location": "/", | |
| 213 | + "Set-Cookie": "glean_token=" & r.token & | |
| 214 | + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000", | |
| 215 | + })) | |
| 216 | + return | |
| 217 | + | |
| 123 | 218 | case req.reqMethod |
| 124 | 219 | of HttpGet: |
| 125 | 220 | case segs[0] |
| 126 | 221 | of "": |
| 127 | - await req.respond(Http200, IndexHtml, | |
| 128 | - newHttpHeaders({"Content-Type": "text/html; charset=utf-8"})) | |
| 222 | + await serveApp(r, req) | |
| 129 | 223 | of "health": |
| 130 | 224 | await jsonResponse(req, Http200, %*{"ok": true}) |
| 131 | 225 | |
| @@ -166,7 +260,9 @@ proc handle(r: Reader, req: Request) {.async.} = | ||
| 166 | 260 | await jsonResponse(req, Http200, |
| 167 | 261 | %*{"count": r.db.getUnreadCount(LocalUser, "", "")}) |
| 168 | 262 | else: |
| 169 | - await fail(req, Http404, "not found") | |
| 263 | + # Not an API route: it may be an asset of the web client. | |
| 264 | + if not await serveStatic(r, req, path): | |
| 265 | + await fail(req, Http404, "not found") | |
| 170 | 266 | |
| 171 | 267 | of HttpPost: |
| 172 | 268 | case segs[0] |
| @@ -250,9 +346,24 @@ proc main() {.async.} = | ||
| 250 | 346 | var db = gleandb.open(dbPath) |
| 251 | 347 | db.migrate() |
| 252 | 348 | db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser)) |
| 253 | - let reader = Reader(db: db) | |
| 349 | + | |
| 350 | + let token = getEnv("GLEAN_TOKEN") | |
| 351 | + var webRoot = getEnv("GLEAN_WEB") | |
| 352 | + if webRoot.len > 0 and not dirExists(webRoot): | |
| 353 | + # Failing loudly beats silently serving the fallback page and leaving | |
| 354 | + # someone wondering why their client never updated. | |
| 355 | + quit(&"GLEAN_WEB points at {webRoot}, which is not a directory") | |
| 356 | + | |
| 357 | + let reader = Reader(db: db, token: token, webRoot: webRoot) | |
| 254 | 358 | |
| 255 | 359 | echo &"glean reading from {dbPath}" |
| 360 | + if webRoot.len > 0: echo &"serving the client from {webRoot}" | |
| 361 | + if token.len > 0: | |
| 362 | + echo "token required: append ?key=… once to set the cookie" | |
| 363 | + else: | |
| 364 | + # Worth saying out loud, because the difference between this and an open | |
| 365 | + # instance on a public address is the whole of the security model. | |
| 366 | + echo "no token set — anyone who can reach this port has full access" | |
| 256 | 367 | echo &"open http://127.0.0.1:{port}" |
| 257 | 368 | |
| 258 | 369 | var server = newAsyncHttpServer() |
| @@ -264,7 +375,11 @@ proc main() {.async.} = | ||
| 264 | 375 | proc cb(req: Request) {.async, gcsafe.} = |
| 265 | 376 | {.cast(gcsafe).}: |
| 266 | 377 | await reader.handle(req) |
| 267 | - await server.serve(Port(port), cb, address = "127.0.0.1") | |
| 378 | + # Loopback unless a token guards it. Modal and other containers need a | |
| 379 | + # listener on all interfaces, and requiring the token for that is what | |
| 380 | + # keeps "reachable" from meaning "open". | |
| 381 | + let address = if token.len > 0: "0.0.0.0" else: "127.0.0.1" | |
| 382 | + await server.serve(Port(port), cb, address = address) | |
| 268 | 383 | |
| 269 | 384 | when isMainModule: |
| 270 | 385 | waitFor main() |
| @@ -3,10 +3,19 @@ | |||
| 3 | ## nim c -r -d:ssl glean.nim | 3 | ## nim c -r -d:ssl glean.nim |
| 4 | ## GLEAN_DB=~/.local/share/glean/db GLEAN_PORT=8080 ./glean | 4 | ## GLEAN_DB=~/.local/share/glean/db GLEAN_PORT=8080 ./glean |
| 5 | ## | 5 | ## |
| 6 | -## Single user, no accounts, no auth. It runs on your machine and reads your | 6 | +## Single user, no accounts. It runs on your machine and reads your feeds, |
| 7 | -## feeds. That assumption is what keeps this a few hundred lines instead of a | 7 | +## and that assumption is what keeps this a few hundred lines instead of a |
| 8 | ## few thousand: no sessions, no CSRF, no per-user scoping, no ATProto. | 8 | ## few thousand: no sessions, no CSRF, no per-user scoping, no ATProto. |
| 9 | ## | 9 | ## |
| 10 | +## GLEAN_TOKEN puts a shared secret in front of everything, which is what | ||
| 11 | +## makes it safe anywhere but loopback -- `/feeds` and `/fetch-content` will | ||
| 12 | +## fetch any URL handed to them, so an open instance is an SSRF proxy as well | ||
| 13 | +## as someone else's feed list. Unset, there is no gate at all and the local | ||
| 14 | +## case stays exactly as simple as it was. | ||
| 15 | +## | ||
| 16 | +## GLEAN_WEB points at a directory of static files (a Flutter web build) to | ||
| 17 | +## serve instead of the built-in page. | ||
| 18 | +## | ||
| 10 | ## The pieces underneath -- fetching, parsing, storage, search, full-text | 19 | ## The pieces underneath -- fetching, parsing, storage, search, full-text |
| 11 | ## extraction -- are the same modules the larger port built and tested. | 20 | ## extraction -- are the same modules the larger port built and tested. |
| 12 | 21 | ||
| @@ -28,6 +37,34 @@ const | |||
| 28 | 37 | ||
| 29 | type Reader = ref object | 38 | type Reader = ref object |
| 30 | db: GleanDb | 39 | db: GleanDb |
| 40 | + token: string ## empty disables the gate entirely | ||
| 41 | + webRoot: string ## empty serves the built-in page | ||
| 42 | + | ||
| 43 | +proc cookieValue(header, name: string): string = | ||
| 44 | + for part in header.split(';'): | ||
| 45 | + let kv = part.strip() | ||
| 46 | + let i = kv.find('=') | ||
| 47 | + if i > 0 and kv[0 ..< i] == name: return kv[i + 1 .. ^1] | ||
| 48 | + "" | ||
| 49 | + | ||
| 50 | +proc constantTimeEq(a, b: string): bool = | ||
| 51 | + ## Compare without leaking where the first difference is. A check that | ||
| 52 | + ## returns early is an oracle for recovering the token a byte at a time. | ||
| 53 | + if a.len != b.len: return false | ||
| 54 | + var diff = 0'u8 | ||
| 55 | + for i in 0 ..< a.len: | ||
| 56 | + diff = diff or (a[i].uint8 xor b[i].uint8) | ||
| 57 | + diff == 0 | ||
| 58 | + | ||
| 59 | +proc presentedToken(req: Request, queryKey: string): string = | ||
| 60 | + ## Header first, then ?key=, then the cookie ?key= sets. | ||
| 61 | + ## | ||
| 62 | + ## Three ways because three callers: a native client sends a header, a | ||
| 63 | + ## person opens a link, and the browser then has a cookie. | ||
| 64 | + let h = req.headers.getOrDefault("X-Glean-Token").string | ||
| 65 | + if h.len > 0: return h | ||
| 66 | + if queryKey.len > 0: return queryKey | ||
| 67 | + cookieValue(req.headers.getOrDefault("Cookie").string, "glean_token") | ||
| 31 | 68 | ||
| 32 | proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} = | 69 | proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} = |
| 33 | await req.respond(code, $node, newHttpHeaders({ | 70 | await req.respond(code, $node, newHttpHeaders({ |
| @@ -39,6 +76,48 @@ proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} = | |||
| 39 | proc fail(req: Request, code: HttpCode, msg: string) {.async.} = | 76 | proc fail(req: Request, code: HttpCode, msg: string) {.async.} = |
| 40 | await jsonResponse(req, code, %*{"error": msg}) | 77 | await jsonResponse(req, code, %*{"error": msg}) |
| 41 | 78 | ||
| 79 | +proc contentTypeFor(path: string): string = | ||
| 80 | + ## Enough types for a Flutter web build. A wrong type here is not cosmetic: | ||
| 81 | + ## a browser will refuse to execute JavaScript served as text/plain. | ||
| 82 | + let ext = path.splitFile.ext.toLowerAscii | ||
| 83 | + case ext | ||
| 84 | + of ".html": "text/html; charset=utf-8" | ||
| 85 | + of ".js", ".mjs": "text/javascript; charset=utf-8" | ||
| 86 | + of ".css": "text/css; charset=utf-8" | ||
| 87 | + of ".json": "application/json" | ||
| 88 | + of ".wasm": "application/wasm" | ||
| 89 | + of ".png": "image/png" | ||
| 90 | + of ".jpg", ".jpeg": "image/jpeg" | ||
| 91 | + of ".svg": "image/svg+xml" | ||
| 92 | + of ".ico": "image/x-icon" | ||
| 93 | + of ".woff2": "font/woff2" | ||
| 94 | + of ".woff": "font/woff" | ||
| 95 | + of ".ttf": "font/ttf" | ||
| 96 | + of ".otf": "font/otf" | ||
| 97 | + of ".map": "application/json" | ||
| 98 | + else: "application/octet-stream" | ||
| 99 | + | ||
| 100 | +proc serveStatic(r: Reader, req: Request, rel: string): Future[bool] {.async.} = | ||
| 101 | + ## Serve `rel` from the web root, if it is there. Returns false when it is | ||
| 102 | + ## not, so the caller can fall through. | ||
| 103 | + if r.webRoot.len == 0: return false | ||
| 104 | + # Resolve and confirm the result is still inside the root: "../" in a | ||
| 105 | + # request path is how a static file server turns into a file server for | ||
| 106 | + # the whole disk. | ||
| 107 | + let full = absolutePath(r.webRoot / rel) | ||
| 108 | + let root = absolutePath(r.webRoot) | ||
| 109 | + if not full.startsWith(root) or not fileExists(full): return false | ||
| 110 | + await req.respond(Http200, readFile(full), | ||
| 111 | + newHttpHeaders({"Content-Type": contentTypeFor(full)})) | ||
| 112 | + true | ||
| 113 | + | ||
| 114 | +proc serveApp(r: Reader, req: Request) {.async.} = | ||
| 115 | + ## The client: a Flutter web build when one is configured, otherwise the | ||
| 116 | + ## page baked into the binary. | ||
| 117 | + if await serveStatic(r, req, "index.html"): return | ||
| 118 | + await req.respond(Http200, IndexHtml, | ||
| 119 | + newHttpHeaders({"Content-Type": "text/html; charset=utf-8"})) | ||
| 120 | + | ||
| 42 | proc toJson(a: Article): JsonNode = | 121 | proc toJson(a: Article): JsonNode = |
| 43 | %*{ | 122 | %*{ |
| 44 | "id": a.id, | 123 | "id": a.id, |
| @@ -120,12 +199,27 @@ proc handle(r: Reader, req: Request) {.async.} = | |||
| 120 | else: | 199 | else: |
| 121 | try: parseInt(raw) except ValueError: fallback | 200 | try: parseInt(raw) except ValueError: fallback |
| 122 | 201 | ||
| 202 | + # The gate, before anything reads the database or fetches a URL. | ||
| 203 | + if r.token.len > 0: | ||
| 204 | + if not constantTimeEq(presentedToken(req, param("key")), r.token): | ||
| 205 | + await fail(req, Http401, "unauthorized") | ||
| 206 | + return | ||
| 207 | + # Arriving with ?key= means a person followed a link; remember it so the | ||
| 208 | + # rest of the app's requests carry it without the token sitting in every | ||
| 209 | + # URL (and in the browser history, and in any Referer it sends). | ||
| 210 | + if param("key").len > 0: | ||
| 211 | + await req.respond(Http303, "", newHttpHeaders({ | ||
| 212 | + "Location": "/", | ||
| 213 | + "Set-Cookie": "glean_token=" & r.token & | ||
| 214 | + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000", | ||
| 215 | + })) | ||
| 216 | + return | ||
| 217 | + | ||
| 123 | case req.reqMethod | 218 | case req.reqMethod |
| 124 | of HttpGet: | 219 | of HttpGet: |
| 125 | case segs[0] | 220 | case segs[0] |
| 126 | of "": | 221 | of "": |
| 127 | - await req.respond(Http200, IndexHtml, | 222 | + await serveApp(r, req) |
| 128 | - newHttpHeaders({"Content-Type": "text/html; charset=utf-8"})) | ||
| 129 | of "health": | 223 | of "health": |
| 130 | await jsonResponse(req, Http200, %*{"ok": true}) | 224 | await jsonResponse(req, Http200, %*{"ok": true}) |
| 131 | 225 | ||
| @@ -166,7 +260,9 @@ proc handle(r: Reader, req: Request) {.async.} = | |||
| 166 | await jsonResponse(req, Http200, | 260 | await jsonResponse(req, Http200, |
| 167 | %*{"count": r.db.getUnreadCount(LocalUser, "", "")}) | 261 | %*{"count": r.db.getUnreadCount(LocalUser, "", "")}) |
| 168 | else: | 262 | else: |
| 169 | - await fail(req, Http404, "not found") | 263 | + # Not an API route: it may be an asset of the web client. |
| 264 | + if not await serveStatic(r, req, path): | ||
| 265 | + await fail(req, Http404, "not found") | ||
| 170 | 266 | ||
| 171 | of HttpPost: | 267 | of HttpPost: |
| 172 | case segs[0] | 268 | case segs[0] |
| @@ -250,9 +346,24 @@ proc main() {.async.} = | |||
| 250 | var db = gleandb.open(dbPath) | 346 | var db = gleandb.open(dbPath) |
| 251 | db.migrate() | 347 | db.migrate() |
| 252 | db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser)) | 348 | db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser)) |
| 253 | - let reader = Reader(db: db) | 349 | + |
| 350 | + let token = getEnv("GLEAN_TOKEN") | ||
| 351 | + var webRoot = getEnv("GLEAN_WEB") | ||
| 352 | + if webRoot.len > 0 and not dirExists(webRoot): | ||
| 353 | + # Failing loudly beats silently serving the fallback page and leaving | ||
| 354 | + # someone wondering why their client never updated. | ||
| 355 | + quit(&"GLEAN_WEB points at {webRoot}, which is not a directory") | ||
| 356 | + | ||
| 357 | + let reader = Reader(db: db, token: token, webRoot: webRoot) | ||
| 254 | 358 | ||
| 255 | echo &"glean reading from {dbPath}" | 359 | echo &"glean reading from {dbPath}" |
| 360 | + if webRoot.len > 0: echo &"serving the client from {webRoot}" | ||
| 361 | + if token.len > 0: | ||
| 362 | + echo "token required: append ?key=… once to set the cookie" | ||
| 363 | + else: | ||
| 364 | + # Worth saying out loud, because the difference between this and an open | ||
| 365 | + # instance on a public address is the whole of the security model. | ||
| 366 | + echo "no token set — anyone who can reach this port has full access" | ||
| 256 | echo &"open http://127.0.0.1:{port}" | 367 | echo &"open http://127.0.0.1:{port}" |
| 257 | 368 | ||
| 258 | var server = newAsyncHttpServer() | 369 | var server = newAsyncHttpServer() |
| @@ -264,7 +375,11 @@ proc main() {.async.} = | |||
| 264 | proc cb(req: Request) {.async, gcsafe.} = | 375 | proc cb(req: Request) {.async, gcsafe.} = |
| 265 | {.cast(gcsafe).}: | 376 | {.cast(gcsafe).}: |
| 266 | await reader.handle(req) | 377 | await reader.handle(req) |
| 267 | - await server.serve(Port(port), cb, address = "127.0.0.1") | 378 | + # Loopback unless a token guards it. Modal and other containers need a |
| 379 | + # listener on all interfaces, and requiring the token for that is what | ||
| 380 | + # keeps "reachable" from meaning "open". | ||
| 381 | + let address = if token.len > 0: "0.0.0.0" else: "127.0.0.1" | ||
| 382 | + await server.serve(Port(port), cb, address = address) | ||
| 268 | 383 | ||
| 269 | when isMainModule: | 384 | when isMainModule: |
| 270 | waitFor main() | 385 | waitFor main() |