diff --git a/Justfile b/Justfile index 63a5ea89f25..03f1432588d 100644 --- a/Justfile +++ b/Justfile @@ -478,6 +478,12 @@ SHELLTEST := STACK + ' exec -- shelltest --execdir --threads=40' # --test so that subsequent `stack test` won't recompile everything # --no-run-tests to avoid running the slow doctest suite every time +# run hledger-web's browser tests against the current build, with any playwright OPTS (needs setup, see hledger-web/test/browser/README.md) +@browsertest *PWOPTS: + {{ STACK }} build hledger-web + cd hledger-web/test/browser && \ + HLEDGER_WEB="{{ STACK }} exec -- hledger-web" pnpm test {{ PWOPTS }} + # too fragile: # echo # just perftest {{ STOPTS }} diff --git a/hledger-web/Hledger/Web/Test.hs b/hledger-web/Hledger/Web/Test.hs index 2048264679e..9fa7e89a0aa 100644 --- a/hledger-web/Hledger/Web/Test.hs +++ b/hledger-web/Hledger/Web/Test.hs @@ -44,6 +44,11 @@ module Hledger.Web.Test ( import Data.String (fromString) import Data.Function ((&)) import Data.Text qualified as T +import Data.Text.IO qualified as TIO +import Data.Text.Lazy qualified as TL +import Data.Text.Lazy.Encoding qualified as TLE +import System.Directory (getTemporaryDirectory) +import System.FilePath (()) import Test.Hspec (hspec) import Yesod.Default.Config import Yesod.Test @@ -85,6 +90,31 @@ runTests testsdesc rawopts j tests = do app <- makeAppWith j yconf wopts hspec $ yesodSpec app $ ydescribe testsdesc tests -- https://hackage.haskell.org/package/yesod-test/docs/Yesod-Test.html +-- | Assert that a journal file on disk does not contain the given text, +-- ie that a request which should have been refused did not write to it. +journalFileLacks :: FilePath -> T.Text -> YesodExample App () +journalFileLacks f t = do + txt <- liftIO $ TIO.readFile f + assertEq (f ++ " should not contain " ++ T.unpack t) (T.isInfixOf t txt) False + +-- | The name of the edit form's textarea in the current page. The edit form +-- does not name that field, so yesod generates one (eg "f1"); find it rather +-- than hardcode it, or a post can silently do nothing. +editFieldName :: YesodExample App T.Text +editFieldName = do + els <- htmlQuery "textarea" + case els of + [] -> error' "no textarea in the edit form" + (e:_) -> do + let needle = "name=\"" + html = TL.toStrict (TLE.decodeUtf8 e) + (_, fromneedle) = T.breakOn needle html + afterneedle = T.drop (T.length needle) fromneedle + fieldname = T.takeWhile (/= '"') afterneedle + if T.null fromneedle + then error' "the edit form's textarea has no name" + else return fieldname + -- | Run hledger-web's built-in tests using the hspec test runner. hledgerWebTest :: IO () hledgerWebTest = do @@ -168,3 +198,116 @@ hledgerWebTest = do -- bodyContains "href=\"https://base" -- bodyContains "src=\"https://files" + -- Tests for the write side: yesod's CSRF protection, and the restriction of + -- file access to the journal's own files. These use a journal in a temp file, + -- so that if one of these protections ever fails, the test writes there + -- rather than to the journal the developer happens to have configured. + tmpdir <- getTemporaryDirectory + let + jfile = tmpdir "hledger-web-test.journal" + jtext = T.pack $ unlines + ["2025-01-01 gift" + ," assets:bank:checking 10" + ," income:gifts" + ] + -- A path is only editable if it is one of the journal's own files, so + -- these must all be refused however they are spelled. + otherfiles = + ["/etc/passwd" + ,"../../../../etc/passwd" + ,"....//....//etc/passwd" + ,jfile ++ "/../../etc/passwd" + ] + TIO.writeFile jfile jtext + let wiopts = rawOptsToInputOpts d usecolor $ mkRawOpts [("file", jfile)] + wpj <- readJournal'' jtext + wj <- fmap (either error' id) . runExceptT $ journalFinalise wiopts jfile jtext wpj + runTests "hledger-web write requests" [("file", jfile), ("allow", "edit")] wj $ do + + yit "puts a CSRF token in the add form" $ do + get JournalR + statusIs 200 + bodyContains "name=\"_token\"" + + -- These three post the same valid, balanced transaction, and differ only + -- in the CSRF token, so that the two failures can only be about the token. + -- The form is wrapped in identifyForm, so _formid must be sent too, or the + -- post is ignored as FormMissing and these would pass either way. + let postTransaction desc = do + setMethod "POST" + setUrl AddR + addPostParam "_formid" "identify-add" + addPostParam "date" "2025-02-02" + addPostParam "description" desc + addPostParam "account" "assets:bank:checking" + addPostParam "amount" "1" + addPostParam "account" "income:gifts" + addPostParam "amount" "" + + yit "does not add a transaction when the CSRF token is missing" $ do + request $ postTransaction "CsrfNoToken" + bodyNotContains "Transaction added" + journalFileLacks jfile "CsrfNoToken" + + yit "does not add a transaction when the CSRF token is wrong" $ do + request $ do + postTransaction "CsrfBadToken" + addPostParam "_token" "not-the-token" + bodyNotContains "Transaction added" + journalFileLacks jfile "CsrfBadToken" + + -- The control for the two tests above: the same request, with a real + -- token, is accepted. Without this they could pass for the wrong reason. + yit "adds a transaction when the CSRF token is present" $ do + get JournalR + statusIs 200 + request $ do + postTransaction "CsrfGoodToken" + addToken -- from the page just fetched + statusIs 303 -- a successful add redirects to the journal + _ <- followRedirect + bodyContains "Transaction added" + txt <- liftIO $ TIO.readFile jfile + assertEq "journal should contain the added transaction" + (T.isInfixOf "CsrfGoodToken" txt) True + + -- Likewise for the edit form: the same save, with and without the token. + let editJournal fld desc = do + setMethod "POST" + setUrl (EditR jfile) + addPostParam "_formid" "identify-edit" + addPostParam fld $ + "2025-03-03 " <> desc <> "\n assets:bank:checking 1\n income:gifts\n" + + yit "does not save the journal when the CSRF token is missing" $ do + get (EditR jfile) + statusIs 200 + fld <- editFieldName + request $ editJournal fld "CsrfEdit" + bodyNotContains "Saved journal" + journalFileLacks jfile "CsrfEdit" + + yit "saves the journal when the CSRF token is present" $ do + get (EditR jfile) + statusIs 200 + fld <- editFieldName + request $ do + editJournal fld "CsrfEditOk" + addToken -- from the page just fetched + txt <- liftIO $ TIO.readFile jfile + assertEq "journal should contain the saved text" + (T.isInfixOf "CsrfEditOk" txt) True + + yit "serves its own journal file for editing" $ do + get (EditR jfile) + statusIs 200 + + forM_ otherfiles $ \otherfile -> do + yit ("refuses to edit " ++ otherfile) $ do + get (EditR otherfile) + statusIs 404 + yit ("refuses to download " ++ otherfile) $ do + get (DownloadR otherfile) + statusIs 404 + + diff --git a/hledger-web/test/browser/.gitignore b/hledger-web/test/browser/.gitignore new file mode 100644 index 00000000000..dbd64df8309 --- /dev/null +++ b/hledger-web/test/browser/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/hledger-web/test/browser/README.md b/hledger-web/test/browser/README.md new file mode 100644 index 00000000000..874630fb8d0 --- /dev/null +++ b/hledger-web/test/browser/README.md @@ -0,0 +1,58 @@ +# hledger-web browser tests + +End-to-end tests for the hledger-web UI, using [Playwright](https://playwright.dev). +Unlike the yesod-test suite (`Hledger/Web/Test.hs`), these run a real browser, so +they cover the parts of hledger-web that only exist once javascript runs: the add +form, autocomplete, the date picker, keyboard shortcuts, sidebar state, and hash +highlighting. + +- `webui.spec.js` — the UI's current behavior, so that changes to it are deliberate. +- `security.spec.js` — journal data is rendered as text and not markup, including the + data handed to the autocomplete's javascript. `fixture.journal` deliberately + contains html/javascript payloads for this. + +Nothing here is part of `stack build` or `stack test`; the suite is opt-in and needs +node only to run it. + +Each run starts its own hledger-web on port 5099 (override with `HLEDGER_WEB_PORT`) +against a scratch copy of `fixture.journal`, and stops it afterwards. The tests add +and edit transactions, so they need `--allow=edit`, which the setup passes. + +## Setup (once) + +Note: running these tests means installing and running javascript tooling from +the npm registry, and a browser it downloads. That is more exposure than the +rest of hledger's test suites carry, and the npm ecosystem has a history of +compromised packages. The settings below reduce the risk but do not remove it; +if that is not a trade you want to make on a machine you care about, run these +tests in a container or a throwaway environment, or not at all. The Haskell +suites (`stack test`) need none of this. + +Uses [pnpm](https://pnpm.io) (>= 10). Config is in `pnpm-workspace.yaml`: +- `onlyBuiltDependencies: []` (no dependency build scripts run) and +- `minimumReleaseAge: 1440` (nothing published in the last 24h). +- `pnpm-lock.yaml` is committed. The one dependency is `@playwright/test`. + +```sh +corepack enable # use the pnpm version pinned in package.json +cd hledger-web/test/browser +pnpm install --frozen-lockfile # install exactly what pnpm-lock.yaml pins +pnpm exec playwright install chromium # download the test browser +``` + +## Run + + # using a hledger-web binary on $PATH: + pnpm test + + # or say how to run hledger-web (eg to test your working copy): + HLEDGER_WEB="stack exec -- hledger-web" pnpm test + + # just the security tests: + pnpm test security + + # watch the browser while it runs: + pnpm test:headed + + # run one test: + pnpm exec playwright test -g "adds a transaction" diff --git a/hledger-web/test/browser/fixture.journal b/hledger-web/test/browser/fixture.journal new file mode 100644 index 00000000000..5465b5f0def --- /dev/null +++ b/hledger-web/test/browser/fixture.journal @@ -0,0 +1,34 @@ +; Test journal for the e2e suite. Tests add to and edit a scratch copy of +; this file, so it is safe to modify. +; +; The last transaction deliberately carries html/javascript payloads in a +; description, an account name and a comment. security.spec.js asserts that +; every view renders them as text. Please keep them here. + +2025-01-01 opening balances + assets:bank:checking 1000.00 + equity:opening + +2025-01-05 Grocer Green | weekly shop + expenses:food:groceries 52.10 + assets:bank:checking + +2025-01-10 Metro Transit + expenses:transport:transit 21.50 + assets:bank:checking + +2025-02-01 * Employer Inc | payroll + assets:bank:checking 3200.00 + income:salary + +2025-02-03 Grocer Green | weekly shop + expenses:food:groceries 48.75 + assets:bank:checking + +2025-02-14 Cafe Luna + expenses:food:dining 18.00 + assets:bank:checking + +2025-03-01 Payee | desc ; comment + expenses:food:dining:xss 1.00 + assets:bank:checking diff --git a/hledger-web/test/browser/global-setup.js b/hledger-web/test/browser/global-setup.js new file mode 100644 index 00000000000..1f1451484ae --- /dev/null +++ b/hledger-web/test/browser/global-setup.js @@ -0,0 +1,54 @@ +// Start hledger-web for the browser test run, on a scratch copy of fixture.journal +// (tests add and edit transactions, so the journal must be disposable). +// +// The binary is located by, in order: +// 1. $HLEDGER_WEB (a command, may contain spaces, e.g. "stack exec -- hledger-web") +// 2. `stack exec -- hledger-web` if a stack project is detected two dirs up +// 3. plain `hledger-web` from $PATH +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); + +const PORT = process.env.HLEDGER_WEB_PORT || '5099'; +const URL = process.env.HLEDGER_WEB_URL || `http://127.0.0.1:${PORT}`; + +function serverCommand() { + if (process.env.HLEDGER_WEB) return process.env.HLEDGER_WEB.split(/\s+/); + const repoRoot = path.resolve(__dirname, '..', '..', '..'); + if (fs.existsSync(path.join(repoRoot, 'stack.yaml'))) + return ['stack', 'exec', '--', 'hledger-web']; + return ['hledger-web']; +} + +function waitForServer(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + (function poll() { + http.get(url + '/journal', res => { + res.resume(); + res.statusCode < 500 ? resolve() : retry(); + }).on('error', retry); + function retry() { + if (Date.now() > deadline) return reject(new Error(`hledger-web did not start at ${url}`)); + setTimeout(poll, 300); + } + })(); + }); +} + +module.exports = async () => { + const journal = path.join(os.tmpdir(), `hledger-web-browser-${process.pid}.journal`); + fs.copyFileSync(path.join(__dirname, 'fixture.journal'), journal); + process.env.BROWSER_JOURNAL = journal; + + const [cmd, ...args] = serverCommand(); + const child = spawn(cmd, [...args, + '-f', journal, '--serve', '--host', '127.0.0.1', '--port', PORT, '--allow=edit', + ], { stdio: 'ignore', detached: true }); + child.unref(); + fs.writeFileSync(path.join(os.tmpdir(), 'hledger-web-browser.pid'), String(child.pid)); + + await waitForServer(URL, 60000); +}; diff --git a/hledger-web/test/browser/global-teardown.js b/hledger-web/test/browser/global-teardown.js new file mode 100644 index 00000000000..2a7d03ea212 --- /dev/null +++ b/hledger-web/test/browser/global-teardown.js @@ -0,0 +1,15 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +module.exports = async () => { + const pidFile = path.join(os.tmpdir(), 'hledger-web-browser.pid'); + try { + const pid = parseInt(fs.readFileSync(pidFile, 'utf8'), 10); + if (pid) process.kill(pid); + fs.unlinkSync(pidFile); + } catch (e) { /* already gone */ } + try { + if (process.env.BROWSER_JOURNAL) fs.unlinkSync(process.env.BROWSER_JOURNAL); + } catch (e) { /* already gone */ } +}; diff --git a/hledger-web/test/browser/package.json b/hledger-web/test/browser/package.json new file mode 100644 index 00000000000..02163113df3 --- /dev/null +++ b/hledger-web/test/browser/package.json @@ -0,0 +1,13 @@ +{ + "name": "hledger-web-browser", + "private": true, + "description": "Browser end-to-end tests for hledger-web (Playwright). See README.md.", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed" + }, + "devDependencies": { + "@playwright/test": "^1.49.0" + }, + "packageManager": "pnpm@10.33.0" +} diff --git a/hledger-web/test/browser/playwright.config.js b/hledger-web/test/browser/playwright.config.js new file mode 100644 index 00000000000..ec8689b388b --- /dev/null +++ b/hledger-web/test/browser/playwright.config.js @@ -0,0 +1,17 @@ +// Playwright config for hledger-web browser tests. +// The web server is started per-run by global-setup.js (see there for +// how the hledger-web binary is located and the test journal prepared). +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: '.', + globalSetup: './global-setup.js', + globalTeardown: './global-teardown.js', + timeout: 30000, + // hledger-web mutates one shared journal file; keep tests serial. + workers: 1, + use: { + baseURL: process.env.HLEDGER_WEB_URL || 'http://127.0.0.1:5099', + }, + reporter: [['list']], +}); diff --git a/hledger-web/test/browser/pnpm-lock.yaml b/hledger-web/test/browser/pnpm-lock.yaml new file mode 100644 index 00000000000..0d9c688d9be --- /dev/null +++ b/hledger-web/test/browser/pnpm-lock.yaml @@ -0,0 +1,52 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: ^1.49.0 + version: 1.62.1 + +packages: + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + +snapshots: + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 diff --git a/hledger-web/test/browser/pnpm-workspace.yaml b/hledger-web/test/browser/pnpm-workspace.yaml new file mode 100644 index 00000000000..7959e356a5d --- /dev/null +++ b/hledger-web/test/browser/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +minimumReleaseAge: 1440 +onlyBuiltDependencies: [] diff --git a/hledger-web/test/browser/security.spec.js b/hledger-web/test/browser/security.spec.js new file mode 100644 index 00000000000..3d0d573fc11 --- /dev/null +++ b/hledger-web/test/browser/security.spec.js @@ -0,0 +1,83 @@ +// Security regression tests for the hledger-web UI. +// +// hledger-web is meant to be run locally, but journal data is not always +// trusted (imported CSV, shared files), and the browser is a hostile place to +// interpolate text. Yesod escapes template output by default; these tests +// exist so that a future refactor cannot quietly opt out of it. +// +// Run with: npx playwright test security (see README.md) +const { test, expect } = require('@playwright/test'); + +// A payload that executes if it is ever inserted as markup rather than text. +// Tests assert window.__xss stays undefined and the text is shown literally. +const PAYLOAD = ''; + +let pageErrors; +test.beforeEach(({ page }) => { + pageErrors = []; + page.on('pageerror', err => pageErrors.push(String(err))); +}); + +async function xssFired(page) { + return page.evaluate(() => window.__xss !== undefined); +} + +test.describe('journal data is rendered as text, not markup', () => { + + // fixture.journal carries the payload in a description, an account name and + // a comment, so every view that renders them is covered. + test('the journal view escapes a payload in a description', async ({ page }) => { + await page.goto('/journal'); + expect(await xssFired(page)).toBe(false); + await expect(page.locator('#main-content')).toContainText(PAYLOAD); + expect(await page.locator('#main-content img[src="x"]').count()).toBe(0); + }); + + test('the register view escapes a payload in a description', async ({ page }) => { + await page.goto('/register?q=inacct:expenses:food:dining'); + expect(await xssFired(page)).toBe(false); + expect(await page.locator('#main-content img[src="x"]').count()).toBe(0); + }); + + test('the sidebar escapes a payload in an account name', async ({ page }) => { + await page.goto('/journal'); + await expect(page.locator('#sidebar-menu')).toContainText('xss