42 Commits

Author SHA1 Message Date
dillard 93221b2905 Also unquote a function name followed by a comment
SELECT "pg_read_file" /* x */ ('/etc/passwd') still passed: the check
for a following parenthesis only skipped whitespace. Skip comments too.
2026-09-30 17:45:09 +02:00
dillard 839572f1fb Only treat --full as the option when it is outside string literals
A value like 'value --full' in the query turned the full table view on.
Leave string literals and quoted identifiers out when looking for the
option.
2026-09-30 17:34:49 +02:00
dillard 266b5a6985 Release 0.4.6
Bump the version, add 0.4.6 to the compatibility matrix and the switch
from occ to the SQL mode as a screenshot.
2026-09-30 17:31:37 +02:00
dillard 3b9fbf42a0 Present the app as an independent project
Rename the README title to Extended OCC Web (occ & SQL terminal), link
the App Store listing, and describe the fork history in the past tense:
the project no longer tracks fanategorius/occweb. Credits stay.
2026-09-30 17:26:35 +02:00
dillard ec4b4a4ece Cut long values in SQL result tables to fit the terminal
One long value made every line of the table wider than the terminal,
so the whole table wrapped. Narrow the widest columns until the table
fits and end cut values with an ellipsis. Adding --full to the query,
which is an SQL comment, shows the values in full as before.
2026-09-30 17:26:16 +02:00
dillard 6dbf2f9675 Ignore trailing comments when splitting SQL batches
A comment after the last semicolon ("SELECT 1; -- note") became a
statement of its own. It is not a read, so the whole batch asked for
confirmation and the comment was then sent to the database. Drop parts
that contain only comments.
2026-09-30 17:26:01 +02:00
dillard d349aad2e6 Show the SQL mode and a command in the screenshots
Replace the empty start screen with a screenshot of the SQL mode and
one of an occ command with its output.
2026-09-30 17:00:00 +02:00
dillard d25580239e Describe the app properly in the app store listing
The summary and description only said "Run OCC Commands in a web
terminal" and did not mention the SQL mode, the confirmation and
blocking rules, the admin-only access or the limitations.
2026-09-30 16:59:01 +02:00
dillard 5c7fbcbbfc Check quoted function names like bare ones in the SQL mode
PostgreSQL accepts a function name in double quotes, so
SELECT "pg_read_file"('/etc/passwd') passed the forbidden-construct
check and SELECT "setval"('seq', 1) skipped the confirmation: the
patterns expect the name right before the parenthesis. The scanner now
unquotes a plain identifier when a parenthesis follows it, so a quoted
call is checked like the bare function name; quoted column names stay
as they are.
2026-09-30 16:56:53 +02:00
dillard d7b093812d Block all dblink functions in the SQL mode
Only dblink() and dblink_connect() were blocked, so dblink_exec(),
dblink_send_query() and the other functions of the extension could
still reach other databases. Match every function whose name starts
with dblink.
2026-09-30 16:48:50 +02:00
dillard 284e75ddb3 Log occ commands for the audit trail
Commands run through the terminal were only logged at debug level, and
admin_audit skipped them because the console request carried no command
in argv. Pass the command as argv so admin_audit records the full
command the way it does for occ on the command line, and log the
command name with the user as a warning, like the SQL mode does for
queries. The regular log gets only the name, because arguments can hold
secrets (config:system:set --value=...).
2026-09-30 16:33:12 +02:00
dillard c1fd53845f Build the console application from the container
The console application was created with a hand-written list of eight
constructor arguments, which broke whenever Nextcloud changed its
constructor. Resolve the arguments by type from the container instead,
passing our own request so the console still gets an argv for its
ConsoleEvent.
2026-09-30 16:11:53 +02:00
dillard 2b57349519 Refresh the screenshots
Replace the old screenshot with a current one of the terminal running
occ, and add the start screen as a second screenshot for the app store.
2026-09-30 16:08:22 +02:00
dillard 45c97653ca Trim the compatibility matrix and add 0.4.5
Keep the last release under the old id and the latest releases; the
older entries stay in the git history of the README.
2026-09-30 16:03:00 +02:00
dillard 363eb0de11 Document the security model
Only admins can use the app, so who gets admin rights decides who can use
it. The database should run as a scoped user without superuser or
server file/program rights, since the SQL checks are an extra layer.
Explain why sensitive occ commands are not blocked: an admin can reach
the same information in other ways, so a blocklist would only give a
false sense of security and make the app less useful.
2026-09-30 11:39:30 +02:00
dillard bea30b94c9 Confirm MySQL SET forms that change server state
SET PASSWORD, SET GLOBAL and SET PERSIST (also as @@GLOBAL.x) change
the server or the database account rather than the session, so they
need confirmation like other writes. Session variables stay a read.
2026-09-30 10:44:24 +02:00
dillard 9861313961 Treat DESCRIBE as a read
DESCRIBE and DESC only show a table on MySQL/MariaDB. SQLite's PRAGMA
stays out of the read list because it can also change settings.
2026-09-30 04:00:33 +02:00
dillard f89ced651b Show rows for all reads and confirm nextval()
SHOW, EXPLAIN, VALUES and TABLE count as reads now but their rows were
dropped and reported as a write; every read except SET now returns its
rows. nextval() advances a sequence, so it needs confirmation like
setval().
2026-09-30 03:59:11 +02:00
dillard 10055c9358 Only treat comment markers outside literals as comments
removeComments() used regexes that also matched "--" and "/*" inside
string literals. A literal like '--' or '/*' then removed the code after
it from the checks while PostgreSQL still ran it, e.g.
COPY (SELECT '--') TO PROGRAM 'sh' passed the forbidden-construct check,
and a DELETE in a CTE between '/*' and '*/' literals skipped the
confirmation.

Comments and literals are now found by one scanner that walks the SQL
the way the database does ('...', E'...', "...", $tag$...$tag$, -- and
block comments), shared by removeComments(), removeLiterals() and
splitStatements().
2026-09-30 03:46:34 +02:00
dillard f4b7484e2f Split SQL batches correctly around dollar quotes and comments
splitStatements() only knew 'strings' and "identifiers", so a DO block
or function body ($$ ... ; ... $$) was cut at its inner semicolons and
failed, and a quote inside a comment (-- don't) opened a string that
never closed. It now also skips dollar-quoted bodies, E'...' strings
with backslash escapes, and comments. It moves to SqlGuard so it is
tested with the other checks.

OccController::run() now catches Throwable, so an Error thrown by a
command is logged and returned like an Exception instead of ending in
an HTTP 500.

Not changed on purpose: showing rows for statements with RETURNING or
data-modifying CTEs, and failure handling in the Makefile release
targets. Both came up in the same review but are unrelated to the
security fixes in this release.
2026-09-30 03:32:58 +02:00
dillard f68b1658be Harden SQL mode checks and sanitize occ output
A security review found three issues:

- Comments were removed without a space before matching the forbidden
  patterns, so "TO/**/PROGRAM" became "TOPROGRAM" and passed the
  COPY ... PROGRAM check while PostgreSQL still ran it. Comments are
  now replaced by a space, and the COPY file check also covers E'...',
  U&'...' and dollar-quoted file names.
- Only statements starting with DELETE or UPDATE asked for
  confirmation, so a leading block comment, a data-modifying CTE
  (WITH x AS (DELETE ...)) or DROP/TRUNCATE/INSERT ran without it.
  Every statement that is not a plain read now needs confirmation,
  decided on the server.
- occ output could contain escape sequences, backspaces and URLs from
  user-controlled text (display names, file names) that rewrite or hide
  text or show a clickable link. Only color codes are kept now, and
  URLs are no longer turned into links.

The checks move to SqlGuard so they can be tested on their own. They
remain defense in depth: dynamic SQL can always get around a text
check, so the database role should not have superuser rights,
pg_execute_server_program or pg_read/write_server_files. A read that
starts with WITH now shows its rows like a SELECT.
2026-09-30 03:15:35 +02:00
dillard 3fc8601344 Keep occ output brackets escaped through the formatters
The overtyping and from_ansi formatters unescape brackets before they
parse the text, so escaping them beforehand was not enough: literal
brackets were still read as formatting. Enable
unixFormattingEscapeBrackets so both formatters escape the text again
after parsing. Verified in the browser: Symfony's "[alias]" in
`occ list` stays in place, ANSI colors still work, and formatting in
the output text (colors, links) is no longer applied.

Bump the version to 0.4.4 so browsers load the new script.
2026-09-30 02:25:13 +02:00
dillard 635cdd4b24 Escape brackets in occ output before rendering
jQuery Terminal reads "[" and "]" as its own formatting syntax. occ
output was echoed as is, so literal brackets, such as Symfony's
"[alias]" in `occ list`, closed the color formatting of the next line
early and moved text to the wrong place. Text from the output, like a
display name, could also inject terminal formatting.

Escape the brackets in the text and keep the ANSI sequences, so the
colors still work.
2026-09-30 02:07:46 +02:00
dillard 76368f154e Add check-on-branch guard: HEAD's branch, not just its cleanliness
git archive HEAD packages whatever commit the checkout currently points to,
regardless of branch - check-clean-tree only guards uncommitted changes, not
HEAD being a feature branch, old tag, PR checkout, or detached HEAD entirely.
Wired into source/appstore/version alongside the existing guards.
Override via RELEASE_BRANCH=<branch> for an intentional non-main release.
2026-09-27 00:45:18 +02:00
dillard a607426a9c Only load vendored jQuery on NC34+, verified against NC32/33/34 in testbak/
Core still provides a working jQuery on NC32/33, but via a read-only
window.$ getter - loading our own vendored copy there too made its UMD
wrapper throw trying to reassign window.$ (confirmed on real containers,
harmless in practice since this app attaches via window.jQuery, but a real
avoidable console exception). Gate the extra script() call on
ServerVersion::getMajorVersion() >= 34 instead.

Re-tested both sides of the boundary: NC32 no longer requests jquery.js and
has zero console errors; NC34 still requests it and still works.
2026-09-27 00:24:47 +02:00
dillard 35e1f5df32 Fix NC35 fatal in OccOutput, verify+narrow supported version range to 32-35
testbak/ (local, non-AIO docker compat rigs for NC32/33/34/35) found a real
fatal on NC35: OccOutput::setErrorOutput() didn't declare the ": void"
return type the Symfony Console version NC35 bundles now requires on
ConsoleOutputInterface. Fixed by declaring it - compatible with older
Symfony/NC versions too since it only narrows an untyped parent signature.

32/33 were also verified functional (the previously-suspected jQuery
getter-conflict console exception on those versions is real but harmless -
the app attaches via window.jQuery, not window.$).

min-version/max-version narrowed from 30/34 to 32/35: 30/31 were never
actually tested even before this session, so keeping them was an unverified
claim; 35 is now genuinely verified rather than guessed.
2026-09-27 00:17:28 +02:00
dillard 4b84be3cb7 Fix sixth Codex finding: check-clean-tree only guarded version, not source/appstore
Running make appstore/source/dist directly (bypassing make version)
still had the stale-archive risk check-clean-tree was added for:
app_name/paths read from a dirty working tree while git archive HEAD
packages the last commit. Added check-clean-tree as a prerequisite to
source and appstore directly, not just transitively through version.
2026-09-26 17:18:26 +02:00
dillard 93244a0d4d Fix fifth Codex finding: app_name silently empty without xpath installed
app_name now comes from xpath against appinfo/info.xml (needed so
packaging works regardless of what this checkout is named - see the
git-archive fix). If xpath isn't installed, $(shell ...) just returns
an empty string rather than failing the build: --prefix=$(app_name)/
would silently become --prefix=/, producing a broken, wrongly-rooted
archive with no indication anything went wrong. Previously app_name
came from the checkout directory name, so this failure mode didn't
exist. Added a check-app-name prerequisite to source/appstore/sign
that fails loudly if app_name is empty.
2026-09-26 17:14:19 +02:00
dillard 8fc4942517 Fix fourth Codex finding: reject a dirty tree before releasing
VERSION/app_name are read live from the working tree's info.xml, but
the packaged/signed content comes from git archive HEAD (the last
commit). Bumping <version> without committing first would tag and
sign an artifact named/keyed after the new version while its actual
contents are still the previous commit's. make version now refuses to
proceed if there are any uncommitted changes to tracked files.
2026-09-26 17:08:22 +02:00
dillard 95cdd06426 Fix third Codex finding: make version hardcoded a non-portable remote name
git push fork v\$(VERSION) only works on this maintainer's own machine,
where "fork" happens to be configured (and where "origin" still means
the stale upstream, from how this repo was originally forked). A plain
clone via the README's own instructions only ever creates "origin",
pointing at dillardblom/occweb - so make version would tag locally,
then fail at the push step before packaging/signing ever ran.

Made the target remote configurable via RELEASE_REMOTE, defaulting to
"origin" (correct for a normal clone), overridable for this specific
checkout's non-standard remote layout.
2026-09-26 17:01:19 +02:00
dillard 6016d0916c Fix second Codex finding: packaging symlink produced a dangling-symlink-only archive
The ensure-app-name-symlink fix from the previous commit created
../extended_occweb as a symlink so the inherited "../$(app_name)" tar
references would resolve regardless of the checkout's real directory
name - but the tar invocations ran without --dereference, so GNU tar
stored the symlink itself rather than following it into the real
files. make source/appstore therefore produced a 1-entry archive
containing nothing but a dangling absolute symlink, unusable on any
other machine. Caught by a second independent Codex pass; my own
"make appstore" test after the first fix only checked the archive's
top-level entry name, not its actual file count, so this went
unnoticed.

Rather than patch this with --dereference, replaced the whole
symlink+raw-tar approach with `git archive --prefix=$(app_name)/
HEAD`: it sets the correct top-level folder name directly (no symlink
needed at all), and - caught during this fix's own verification,
not by either Codex pass - it also closes a separate real leak the
symlink approach had: tarring the checkout directory directly
included every untracked/gitignored file too (e.g.
notes/deploy-nc01.md, which exists specifically because it must
never leave this machine). `git archive` only ever includes tracked
files. Dev/CI-only files (tests/, Makefile, composer.*, phpunit*.xml,
.travis.yml) are now stripped via `export-ignore` entries in a new
.gitattributes instead of a long list of tar --exclude flags repeated
per target - also means source and appstore now produce identical
output, which is fine given neither is part of our actual release
flow yet.

Verified end to end this time: extracted the resulting archive and
confirmed file count, absence of symlinks, absence of tests/notes/
dev files, and correct info.xml content - not just the archive's
top-level listing.
2026-09-26 16:55:55 +02:00
dillard 422748cace Fix Codex-review findings: Makefile packaging, git archive main hardcode
Independent Codex review of the app-id rename found real gaps our own
review missed:

- Makefile: app_name was derived from the checkout directory's own
  basename, not the app id in appinfo/info.xml. Our actual working
  checkout is still named "occweb" (matches the repo name), so
  building from it would have produced an "occweb"-named/rooted
  archive under the new "extended_occweb" id. Fixed by deriving
  app_name from info.xml via xpath (same technique already used for
  VERSION), and added an ensure-app-name-symlink step so the
  Makefile's inherited "../$(app_name)" references (used throughout
  the source/appstore tar exclude patterns) keep resolving correctly
  regardless of what the checkout directory is actually named.
  Verified end to end: `make appstore` now produces
  extended_occweb.tar.gz with extended_occweb/ as its single top-level
  folder, matching the App Store's stated requirement.
- Makefile: sign target had "occweb" hardcoded into both the
  certificate path and the archive filename - broken independent of
  the app_name fix above. Now derived from the same variables.
- Makefile: version target pushed the release tag to the "origin"
  remote, which in this repo is the stale upstream
  (fanategorius/occweb), not somewhere we want to push. Changed to
  "fork" (dillardblom/occweb), matching how every tag has actually
  been pushed this whole session.
- INSTALL-AIO.md / notes/deploy-nc01.md: the deploy snippet did
  `git archive main` unconditionally, silently deploying the main
  branch even if an operator had checked out a specific tag or
  maintenance branch as instructed elsewhere. Changed to `git archive
  HEAD`.
- README.md: the compatibility matrix's rollback advice
  ("git checkout v0.3.4") doesn't actually work as a rollback anymore
  post-rename - that tag still declares the old occweb id, so checking
  it out under an extended_occweb-named install just breaks. Added a
  caveat explaining what a real rollback requires; full migration docs
  are still deferred to when a test instance exists (tracked
  separately, not written up here).
2026-09-26 16:48:58 +02:00
dillard a5f5c5cdb7 Fix code-review findings: ajax error handling, translated comment, docs
- js/index.js: the occ-command ajax call used the wrong jQuery .fail()
  signature ((response, code) instead of (jqXHR, textStatus,
  errorThrown)), so any failure printed the literal string
  "[object Object]" instead of a useful message.
- js/index.js: the initial bootstrap call that builds the terminal had
  no .fail() handler at all - a failure there (expired session/CSRF
  token, network error, rejected admin check) left #app-content
  permanently blank with zero diagnostic information. This is exactly
  what made today's NC34 debugging session harder than it needed to
  be; now it shows the actual HTTP status/reason in the page.
- OccController.php: translated a leftover Dutch comment (missed by
  the earlier Russian->English pass since it wasn't Russian), and
  documented why requireAdmin() is intentional defense-in-depth rather
  than a misunderstanding of the framework: verified against
  Nextcloud's own SecurityMiddleware source that any controller method
  without @NoAdminRequired already rejects non-admins by default, so
  this check (and the original "security fix" commit that added it)
  was addressing a non-issue for THIS controller specifically.
  DbController's equivalent check is still genuinely load-bearing -
  that controller originally shipped with @PublicPage, which does
  bypass the default protection - so it gets a note explaining the
  distinction instead.
- README: added compatibility matrix rows for 0.4.0 (app id rename to
  extended_occweb) and 0.4.1 (these fixes), both verified on NC 34.0.4.

Deferred to when a test instance exists (tracked, not fixed here): no
documented migration path from the old occweb id to extended_occweb
for existing installs, OccController's constructor doing expensive
work before the (redundant) admin check runs, and js/jquery.js loading
unconditionally on NC30-33 where core still provides jQuery.

Version bump to 0.4.1.
2026-09-26 16:04:14 +02:00
dillard 221ea8a249 Shorten navigation menu label to "OCC Web" (was "OCCWeb")
Full app name stays "Extended OCC Web" for the App Store/apps
management page - only the top-nav menu entry needed a shorter,
readable label.
2026-09-26 15:55:10 +02:00
dillard 138ee88486 Rename app id from occweb to extended_occweb, prep for App Store
Nextcloud's app id "occweb" is effectively unavailable for a new store
listing: the original Adphi/occweb still owns that App Store slot
(stale, v0.2.3), and el-choco/occweb_v2 has since published a
same-lineage rewrite under its own id. Rather than fight over the
"occweb" name, publish this fork under its own id, matching the
<name> this app has always had: "Extended OCC Web".

Renames every place the literal app id is used: info.xml <id> and the
auto-generated navigation <route>, script()/style() registrations in
templates/index.php, the TemplateResponse app name in
OccController::index(), the /apps/... base URL in js/index.js, and the
'app' log context tag in DbController.php. The GitHub repo name and
its <website>/<bugs>/<repository>/<screenshot> links are unaffected -
those identify the repo, not the Nextcloud app.

Docs (README, INSTALL-AIO.md, install.sh) updated to clone into and
reference an "extended_occweb" directory, since Nextcloud requires the
app folder name to match its id - a plain `git clone` of this repo no
longer produces a correctly-named install directory on its own.

Version bump to 0.4.0 given the scope (breaking change for anyone who
already deployed under the old "occweb" id - requires disable+remove
of the old app and a fresh enable under the new id, not an in-place
upgrade).
2026-09-26 12:51:21 +02:00
dillard f091cc8c0b Add NC-version compatibility matrix to README 2026-09-26 12:27:49 +02:00
dillard 2872d70653 Fix NC34 compatibility: vendor jQuery, send CSRF token explicitly on ajax calls
Live-tested against nc01 after its auto-update jumped to NC 34.0.4:

- Nextcloud's global jQuery has been deprecated since NC19 and is no
  longer reliably present/first-loaded on NC34 (other apps like
  serverinfo hit the same issue). occweb's index.js and the vendored
  jquery.terminal (term.js) both assume a global jQuery, so ship our
  own copy (js/jquery.js, 3.7.1 from code.jquery.com) and load it
  before them in templates/index.php. Fixes a hard "jQuery is not
  defined" crash that left the terminal blank (black screen).

- With that fixed, every ajax call started failing with HTTP 412
  "CSRF check failed": core only auto-attaches the requesttoken
  header to requests made through ITS OWN globally-loaded jQuery, and
  with occweb now vendoring a separate jQuery instance that no longer
  happens. A global $.ajaxSetup() call turned out unreliable (another
  app's later jQuery load can replace window.jQuery afterwards), so
  each of the three ajax calls (SQL query, initial completion list,
  occ command execution) now sets the requesttoken header explicitly
  via OC.requestToken.

Bump max-version to 34 - actually verified working on NC 34.0.4 now,
not inherited/guessed (see the policy comment in this same file).
Version bump to 0.3.5.
2026-09-26 12:23:28 +02:00
dillard 1a333d161b Replace INSTALL-AIO.md with a generic, English, host-agnostic version
The previous version was in Dutch and hardcoded internal deploy details
(hostname alias, personal local path, private Gitea mirror domain) that
had no business being in a public repo. Replaced with a generic version
using <docker-host> placeholders; the internal nc01-specific copy now
lives at notes/deploy-nc01.md, which is gitignored and stays local-only.

Not rewriting prior history for this (confirmed with user: nothing
credential-level was exposed, just a hostname alias and a path).
2026-09-26 03:59:00 +02:00
dillard 827b2d10dd Revert max-version to 33: only claim what we've actually verified
35 was fanategorius's own tested value, but this is now a modified
fork (CSRF fix, admin checks, translated internals) - inheriting their
compatibility claim isn't the same as having verified it ourselves.
33 is the only version this fork has actually run on (nc01).

An untested max-version that's too high is worse than one that's
accurate: on the next core bump, Nextcloud's own compat check would
keep the app enabled on an unverified major instead of cleanly
disabling it, so a real incompatibility would surface as a runtime
error instead of a safe, expected "needs testing" disable.

Policy going forward (documented inline in info.xml): test against
the next major's beta ahead of time and bump max-version before that
major goes final, so the app is never caught by surprise - never bump
it reactively/speculatively.
2026-09-26 03:53:16 +02:00
dillard 20bdb7dcd6 Fix install/clone instructions to point at our fork, revert max-version to 35
README.md and install.sh's install instructions still told new users to
git clone the abandoned upstream (fanategorius/occweb), which lacks all
of this fork's fixes. Point those at dillardblom/occweb; leave the
Credits section's links to Adphi/fanategorius as-is since those are
deliberate attribution, not install instructions. Also repoint the
README screenshot badge, which was hotlinking Adphi's repo.

composer.json still listed only the original author; add ourselves
alongside, matching the info.xml author field.

Revert max-version 40->35: 35 was fanategorius's own tested release
value (commit 7c09dd2), not a guess. Bumping to 40 was our own
speculative headroom into an untested future major - exactly the
pattern this project's own history already reverted twice, for the
same stated reason ("hasn't been tested against newer Nextcloud
releases"). Getting auto-disabled by the compat check on an untested
major is the correct, safe failure mode; claiming compatibility we
haven't verified is not. 35 still satisfies the Codex finding (no
narrowing below a previously-validated ceiling) without guessing ahead.
2026-09-26 03:50:37 +02:00
dillard c2186ce2ea Give max-version real headroom instead of pinning to nc01's exact core version
Codex review flagged the previous 35->33 change as an unjustified
compatibility regression: nothing in this app's code is actually
incompatible with NC 34/35, so narrowing the ceiling only reflects
nc01's current version, not a real limitation. Worse, nc01's AIO now
auto-updates its core nightly (watchtower-aio), so pinning max-version
to today's exact version would very likely repeat the 2026-08-09
incident (occweb auto-disabled by the compat check) on the very next
core bump. This app only calls a handful of stable, long-lived OCP
interfaces, so a generous ceiling carries little real compatibility
risk compared to that recurring outage.
2026-09-26 03:38:48 +02:00
dillard f4395d8dda Add INSTALL-AIO.md to the repo, rewrite it for the actual AIO/custom_apps workflow
Was untracked locally and stale (referenced apps/ instead of custom_apps/,
git clone inside the container instead of our tar-based deploy). Documents
the two things that just caused a production incident on nc01: files
extracted from a tarball keep the source uid/gid and need an explicit
chown to www-data, and skipping the installed_version sync after bumping
<version> puts the whole instance in "needs upgrade" state, which blocks
not just occ commands but the web login page too.

Link it from README.md's install section.
2026-09-26 03:27:27 +02:00
20 changed files with 946 additions and 258 deletions
+11
View File
@@ -0,0 +1,11 @@
# Files excluded from `git archive` output - used by `make appstore` (and by
# our own nc01 deploy tarballs) to strip dev/CI-only files that don't belong
# in a running install, without needing them repeated as tar --exclude flags.
tests export-ignore
.travis.yml export-ignore
phpunit.xml export-ignore
phpunit.integration.xml export-ignore
composer.json export-ignore
composer.lock export-ignore
Makefile export-ignore
.gitattributes export-ignore
+6
View File
@@ -30,5 +30,11 @@ js/*.log
.env
*.local
# Internal deploy notes (hostnames, paths) - never publish these
notes/
# Local compat-test docker rigs (throwaway, not part of the app release)
testbak/
# Misc logs
*.log
+98
View File
@@ -0,0 +1,98 @@
# Installing/updating occweb on Nextcloud AIO (Docker)
Nextcloud's official All-in-One (AIO) distribution runs core in its own container
(`nextcloud-aio-nextcloud` by convention), so the generic [install instructions](README.md)
(`git clone` straight into the server) don't apply as-is. This covers the differences.
## Where the app belongs
`/var/www/html/custom_apps/` inside the core container — **not** `/var/www/html/apps/`.
AIO's `config.php` sets `apps_paths` with `apps/` as `writable: false` (managed by
Nextcloud's own core/appstore sync, which can overwrite custom code placed there) and
`custom_apps/` as `writable: true` — the intended location for third-party apps like
this one.
## Deploy/update procedure
The AIO container has no working git access to GitHub, so deploy a tarball instead of
`git clone`/`git pull` inside it:
```bash
# 1. On your machine: clean checkout of the branch/tag you want, dev files stripped
STAGE=$(mktemp -d)
git archive HEAD | tar -x -C "$STAGE"
rm -rf "$STAGE"/{tests,.travis.yml,phpunit.xml,phpunit.integration.xml,composer.json,composer.lock,Makefile}
tar -C "$STAGE" -cf /tmp/occweb-deploy.tar .
# 2. Copy it onto the Docker host, then into the container
scp /tmp/occweb-deploy.tar <docker-host>:/tmp/occweb-deploy.tar
ssh <docker-host> "docker cp /tmp/occweb-deploy.tar nextcloud-aio-nextcloud:/tmp/occweb-deploy.tar && \
docker exec nextcloud-aio-nextcloud sh -c 'mkdir -p /var/www/html/custom_apps/extended_occweb && \
tar -xf /tmp/occweb-deploy.tar -C /var/www/html/custom_apps/extended_occweb && rm /tmp/occweb-deploy.tar'"
# 3. Fix ownership - tar preserves the source uid/gid, not www-data
ssh <docker-host> "docker exec nextcloud-aio-nextcloud chown -R www-data:www-data /var/www/html/custom_apps/extended_occweb"
# 4. Fresh install only (skip when updating an already-enabled app):
ssh <docker-host> "docker exec -u www-data nextcloud-aio-nextcloud php occ app:enable extended_occweb"
```
> **Gotcha**: never copy a file into a container with `ssh host "docker exec container bash -c 'cat > file'"`
> piped from local stdin — that produces an empty file. Always `scp` to the host, then
> `docker cp` into the container, as above.
## ⚠️ Critical after bumping `<version>` in `appinfo/info.xml`
This step is **not optional** — skipping it doesn't just block occ commands, it can
block **logging into the web UI entirely** (Nextcloud shows an upgrade screen to every
user instead of the login page).
Nextcloud tracks each app's installed version in the database (`installed_version` in
`oc_appconfig`), separately from `<version>` in `info.xml`. If you replace files in place
(instead of going through the app store or `occ upgrade`), those two fall out of sync,
and Nextcloud puts the whole instance into a "needs upgrade" state until you sync it
manually:
```bash
ssh <docker-host> "docker exec -u www-data nextcloud-aio-nextcloud php occ config:app:set extended_occweb installed_version --value='X.Y.Z'"
```
(use the same `X.Y.Z` as the new `<version>`). Verify with `occ status` that
`needsDbUpgrade: false` again.
## Restart PHP-FPM after every deploy
`occ app:disable`/`app:enable` does not clear PHP's opcache — an update can silently
fail to take effect without a restart (routes 404ing is the typical symptom). Prefer a
graceful reload over a hard restart (keeps in-flight requests alive):
```bash
ssh <docker-host> "docker exec nextcloud-aio-nextcloud ps aux | grep 'php-fpm: master'" # find the PID
ssh <docker-host> "docker exec nextcloud-aio-nextcloud kill -USR2 <master-pid>"
```
`supervisorctl` won't work directly inside this container (its socket isn't at the
default path) — hence the manual PID lookup.
## Removing the app
```bash
ssh <docker-host> "docker exec -u www-data nextcloud-aio-nextcloud php occ app:disable extended_occweb && \
docker exec nextcloud-aio-nextcloud rm -rf /var/www/html/custom_apps/extended_occweb"
```
## `max-version` policy
See the comment next to `<dependencies>` in `appinfo/info.xml`: it must always equal the
highest Nextcloud core version this app has actually been verified against — never a
value inherited from upstream, and never a guess ahead of what's been tested. Test
against the next major's beta ahead of time and bump it before that major goes final,
rather than reacting after the fact.
## SQL query mode
Type `sql` in the OCCWeb terminal to run raw SQL directly against the Nextcloud
database. Separate statements with `;`, use Shift+Enter for a new line and Enter to run.
Before giving anyone access, read the Security section in the README: only
admins can use the app, and the database user should have limited rights.
+62 -39
View File
@@ -39,7 +39,7 @@
# "build": "node node_modules/gulp-cli/bin/gulp.js"
# },
app_name=$(notdir $(CURDIR))
app_name=$(shell xpath -q -e "//info/id/text()" appinfo/info.xml)
build_tools_directory=$(CURDIR)/build/tools
source_build_directory=$(CURDIR)/build/artifacts/source
source_package_name=$(source_build_directory)/$(app_name)
@@ -110,48 +110,41 @@ dist:
make source
make appstore
# Builds the source package
# Both targets package via `git archive` rather than tarring the checkout
# directory directly: it only ever includes tracked files (so untracked/
# gitignored content - build/, notes/, any stray .env - can never leak into
# a release archive), it sets the correct top-level folder name via
# --prefix regardless of what this checkout is actually called (this repo
# is "occweb", the app id is "extended_occweb" - no symlink trick needed),
# and dev/CI-only files (tests/, Makefile, composer.*, phpunit*.xml,
# .travis.yml) are stripped via the `export-ignore` entries in
# .gitattributes instead of a long, easy-to-miss list of tar --exclude
# flags repeated per target. That also means source and appstore now
# produce the same archive; both targets are kept for compatibility with
# anything that invokes one of them by name.
#
# app_name comes from `xpath` (see its definition above) - if that binary
# is missing, $(shell ...) silently returns an empty string rather than
# failing the build, which would turn --prefix=$(app_name)/ below into
# --prefix=/ and produce a broken, unusably-rooted archive with no
# indication anything went wrong. Fail loudly instead.
.PHONY: check-app-name
check-app-name:
@test -n "$(app_name)" || \
(echo "app_name is empty - is 'xpath' (libxml2-utils/perl-XML-XPath) installed?" >&2; exit 1)
.PHONY: source
source:
source: check-app-name check-clean-tree check-on-branch
rm -rf $(source_build_directory)
mkdir -p $(source_build_directory)
tar \
--exclude-vcs \
--exclude="../$(app_name)/build" \
--exclude="../$(app_name)/js/node_modules" \
--exclude="../$(app_name)/node_modules" \
--exclude="../$(app_name)/*.log" \
--exclude="../$(app_name)/js/*.log" \
-cvzf $(source_package_name).tar.gz ../$(app_name)
git archive --format=tar --prefix=$(app_name)/ HEAD | gzip > $(source_package_name).tar.gz
# Builds the source package for the app store, ignores php and js tests
.PHONY: appstore
appstore:
appstore: check-app-name check-clean-tree check-on-branch
rm -rf $(appstore_build_directory)
mkdir -p $(appstore_build_directory)
tar \
--exclude-vcs \
--exclude="../$(app_name)/build" \
--exclude="../$(app_name)/tests" \
--exclude="../$(app_name)/Makefile" \
--exclude="../$(app_name)/*.log" \
--exclude="../$(app_name)/phpunit*xml" \
--exclude="../$(app_name)/composer.*" \
--exclude="../$(app_name)/js/node_modules" \
--exclude="../$(app_name)/js/tests" \
--exclude="../$(app_name)/js/test" \
--exclude="../$(app_name)/js/*.log" \
--exclude="../$(app_name)/js/package.json" \
--exclude="../$(app_name)/js/bower.json" \
--exclude="../$(app_name)/js/karma.*" \
--exclude="../$(app_name)/js/protractor.*" \
--exclude="../$(app_name)/package.json" \
--exclude="../$(app_name)/bower.json" \
--exclude="../$(app_name)/karma.*" \
--exclude="../$(app_name)/protractor\.*" \
--exclude="../$(app_name)/.*" \
--exclude="../$(app_name)/js/.*" \
-cvzf $(appstore_package_name).tar.gz ../$(app_name)
git archive --format=tar --prefix=$(app_name)/ HEAD | gzip > $(appstore_package_name).tar.gz
.PHONY: test
test: composer
@@ -159,8 +152,8 @@ test: composer
$(CURDIR)/vendor/phpunit/phpunit/phpunit -c phpunit.integration.xml
.PHONY: sign
sign:
@openssl dgst -sha512 -sign ~/.nextcloud/certificates/occweb.key build/artifacts/appstore/occweb.tar.gz |openssl base64
sign: check-app-name
@openssl dgst -sha512 -sign ~/.nextcloud/certificates/$(app_name).key $(appstore_package_name).tar.gz |openssl base64
VERSION := $(shell xpath -q -e "//info/version/text()" appinfo/info.xml)
@@ -168,11 +161,41 @@ VERSION := $(shell xpath -q -e "//info/version/text()" appinfo/info.xml)
show-version:
@echo $(VERSION)
# Defaults to "origin" - correct for a plain clone of this repo, where
# "origin" is dillardblom/occweb. Override for a checkout where "origin"
# means something else (e.g. this maintainer's own machine, where the repo
# was originally forked from upstream and "origin" still points there):
# make version RELEASE_REMOTE=fork
RELEASE_REMOTE ?= origin
# VERSION/app_name are read live from the working tree's appinfo/info.xml,
# but the archive this tags/packages/signs is git archive HEAD - i.e. the
# last COMMITTED state. Without this check, bumping <version> without
# committing first would tag and sign a package named/keyed after the new
# version while its actual contents are still the previous commit's.
.PHONY: check-clean-tree
check-clean-tree:
@git diff --quiet HEAD -- || \
(echo "Uncommitted changes to tracked files - commit before releasing (see 'git status')." >&2; exit 1)
# `git archive HEAD` packages whatever commit the checkout currently points
# to, regardless of branch - check-clean-tree only guards against *uncommitted*
# changes, not against HEAD being the wrong branch entirely (a feature branch,
# an old tag, a PR checkout, a detached HEAD). Override for the rare
# intentional case (e.g. releasing from a branch on purpose):
# make appstore RELEASE_BRANCH=some-branch
RELEASE_BRANCH ?= main
.PHONY: check-on-branch
check-on-branch:
@test "$$(git rev-parse --abbrev-ref HEAD)" = "$(RELEASE_BRANCH)" || \
(echo "HEAD is on '$$(git rev-parse --abbrev-ref HEAD)', not '$(RELEASE_BRANCH)' - pass RELEASE_BRANCH=<branch> to override if this is intentional." >&2; exit 1)
.PHONY: version
version:
version: check-clean-tree check-on-branch
@echo "Creating version v$(VERSION)"
@git tag v$(VERSION)
@git push origin v$(VERSION)
@git push $(RELEASE_REMOTE) v$(VERSION)
@make dist
@echo "\nRelease Signature: \n"
@make sign
+85 -16
View File
@@ -1,22 +1,26 @@
# OCCWeb terminal (maintained fork)
# Extended OCC Web (occ & SQL terminal)
### A web terminal for admins to launch Nextcloud's occ commands
### A web terminal for Nextcloud admins to run occ commands and SQL queries
![occweb](https://github.com/Adphi/OCCWeb/raw/main/appinfo/screenshot.png)
Available in the [Nextcloud App Store](https://apps.nextcloud.com/apps/extended_occweb) as `extended_occweb`.
## About this fork
![occweb](https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot.png)
## History
The original [Adphi/occweb](https://github.com/Adphi/occweb) was marked deprecated by its
author and is unmaintained. This fork tracks [fanategorius/occweb](https://github.com/fanategorius/occweb),
which picked up compatibility maintenance where Adphi left off, and adds SQL query mode,
author and is unmaintained. This project started as a fork of
[fanategorius/occweb](https://github.com/fanategorius/occweb), which had picked up
compatibility maintenance where Adphi left off, and tracked it while adding SQL query mode,
security hardening (admin checks, CSRF/confirmation flows, transactional batches), and PHP
8.1+ compatibility fixes on top.
8.1+ compatibility fixes. Since 0.4.3 it is published under its own app id,
`extended_occweb`, and it is now maintained as an independent project.
### Credits
- [Adphi/occweb](https://github.com/Adphi/occweb) — original author/project.
- [fanategorius/occweb](https://github.com/fanategorius/occweb) — the actively maintained
fork this repo is based on and tracks.
- [fanategorius/occweb](https://github.com/fanategorius/occweb) — the maintained fork this
project was based on.
- [Git-Usr123/occweb](https://github.com/Git-Usr123/occweb) — independent fork; the
`OC::$server->getConfig()` → `OC::$server->get(\OCP\IConfig::class)` fix in
`OccController.php` was spotted there and backported here.
@@ -33,13 +37,19 @@ has background on why Nextcloud has no native async support for occ tasks.
## Install
Running Nextcloud AIO (Docker)? See [INSTALL-AIO.md](INSTALL-AIO.md) instead — the app
path and deploy steps differ from the generic instructions below.
No build step required (plain PHP + vanilla JS). Clone straight into the
target server's `apps/` directory and run the install script:
Nextcloud requires the app's directory name to match its id (`extended_occweb`), so
clone into that name explicitly rather than the repo's own name:
```bash
cd /var/www/nextcloud/apps
git clone https://github.com/fanategorius/occweb.git
bash occweb/install.sh
git clone https://github.com/dillardblom/occweb.git extended_occweb
bash extended_occweb/install.sh
```
`install.sh` removes the dev/CI-only files that don't belong in a running
@@ -50,7 +60,7 @@ server user, and runs `occ app:enable`. It assumes Nextcloud lives at
values as arguments if that's not the case:
```bash
bash occweb/install.sh /path/to/nextcloud custom-web-user
bash extended_occweb/install.sh /path/to/nextcloud custom-web-user
```
`occ app:enable` takes care of registering the app's version correctly, so
@@ -79,8 +89,40 @@ run the whole block in a single request — this matters for scripts like
statement in one submission runs on the same database connection/session.
Type `occ` to switch back to the normal occ-command mode.
⚠️ There is no undo for `DELETE`/`UPDATE` statements — double check what
you are about to run, ideally against a non-critical row/user first.
Long values in result tables are cut to fit the terminal. Add `--full` to the
query (it is an SQL comment, so the database ignores it) to see them in full,
e.g. `SELECT configvalue FROM oc_appconfig WHERE configkey = 'x'; --full`.
Every statement that changes data or the schema (`DELETE`, `UPDATE`,
`INSERT`, `DROP`, a data-modifying `WITH`, ...) asks for confirmation before
the batch runs. There is still no undo — double check what you are about to
run, ideally against a non-critical row/user first. Statements that access the
server's filesystem or start programs are blocked.
## Security
OCC Web is a powerful tool: it gives whoever can open it the same control over
Nextcloud as `occ` on the command line, plus direct access to the database.
Treat access to it like shell access to the server.
- **Only admins can use it.** Every endpoint checks that the user is a member
of the Nextcloud `admin` group, and the endpoints that run commands or SQL
also require a valid CSRF token; other users get HTTP 403, anonymous
requests HTTP 401. This is the security boundary of the app.
- **Be careful who you make an admin.** Anyone in the `admin` group can use OCC
Web, and an admin can do everything this app does in other ways as well.
Deciding who gets admin rights is up to the instance operator, not this app.
- **Run the database as a scoped user with limited rights.** The SQL mode runs
with the rights of Nextcloud's own database user. Make sure that user is not
a database superuser and cannot read or write files on the database server
or start programs there (on PostgreSQL: no superuser, no
`pg_execute_server_program`, `pg_read_server_files` or
`pg_write_server_files`). The checks in the SQL mode are an extra layer, not
a replacement for this.
- **No blocklist of occ commands, on purpose.** We don't block commands that
show sensitive settings. It would make the app less useful, and an admin
can run such commands in a slightly more roundabout way anyway, so it would
only give a false sense of security.
## ⚠️ Warnings ⚠️
@@ -99,7 +141,7 @@ So if your instance is pretty big, commands like `occ files:scan` will time out
## Deploying updates
After pulling/copying new code into `nextcloud/apps/occweb/`, restart PHP so
After pulling/copying new code into `nextcloud/apps/extended_occweb/`, restart PHP so
the changes actually take effect. `occ app:disable`/`app:enable` is **not**
enough — it does not clear PHP's opcode cache, so a stale, cached version of
the code can keep running (routes silently 404ing is a typical symptom).
@@ -121,12 +163,39 @@ CLI-upgrade wizard, even though nothing about Nextcloud core actually
changed. After bumping the version, sync it manually:
```bash
sudo -u www-data php /var/www/nextcloud/occ config:app:set occweb installed_version --value="X.Y.Z"
sudo -u www-data php /var/www/nextcloud/occ config:app:set extended_occweb installed_version --value="X.Y.Z"
```
(use the same `X.Y.Z` as the new `<version>`), then restart PHP as above.
This does not apply on a fresh `occ app:enable` install — that command sets
`installed_version` correctly on its own.
## Compatibility matrix
`max-version` in `appinfo/info.xml` only ever reflects the highest version we've actually
tested (see the policy comment right above it) - this table is the actual history of what
was verified, so a rollback target is obvious if a newer major causes problems.
| occweb version | Tag | NC version tested | Result |
|---|---|---|---|
| 0.3.4 | `v0.3.4` | 33.0.8 | ✅ Verified (2026-09-25). Last release under the old `occweb` id; does not work on NC34 (missing jQuery, CSRF), fixed from 0.3.5 on. |
| 0.4.3 | `v0.4.3` | 32.0.15, 33.0.9, 34.0.4, 35.0.1 | ✅ Verified (`testbak/`, 2026-09-27). First release under `extended_occweb`: NC35 fatal in `OccOutput` fixed, vendored jQuery only loaded on NC34+. |
| 0.4.4 | (untagged) | 34.0.4 | ✅ Verified (2026-09-30). Brackets in occ output escaped before rendering. |
| 0.4.5 | `v0.4.5` | 32.0.15, 33.0.9, 34.0.4, 35.0.1 | ✅ Verified (`testbak/`, 2026-09-30). SQL mode hardening, occ output sanitized, first release code-signed with the Nextcloud certificate. |
| 0.4.6 | `v0.4.6` | 32.0.15, 33.0.9, 34.0.4, 35.0.1 | ✅ Verified (`testbak/`, 2026-09-30). occ commands logged for the audit trail, console built from the container, more SQL guard gaps closed, long values in result tables cut to fit (`--full` shows them). |
Older entries (0.3.5-0.4.2) are in the git history of this file.
`min-version`/`max-version` in `appinfo/info.xml` are `32`/`35` since 2026-09-27: 30/31 were never tested and are past Nextcloud's supported range, and 35 is verified rather than guessed.
If a future major breaks something, `v0.3.4` is the last version confirmed working on NC33
and older - but a plain `git checkout v0.3.4` is **not** a working rollback by itself. That
tag predates the app id rename (it still declares `<id>occweb</id>`), so checking it out
into an `extended_occweb`-named install just produces a broken id/directory mismatch. A
real rollback means disabling/removing `extended_occweb` and deploying `v0.3.4` fresh under
an `occweb`-named directory instead, same as the original install steps above. This is one
of the items still deferred to when a proper test instance exists (see the code review
notes in git history around 0.4.1) - not yet written up in full.
## TODOs:
See [open issues](https://github.com/dillardblom/occweb/issues)
+40 -7
View File
@@ -1,11 +1,32 @@
<?xml version="1.0"?>
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>occweb</id>
<id>extended_occweb</id>
<name>Extended OCC Web</name>
<summary>OCC Commands in a web terminal</summary>
<description><![CDATA[Run OCC Commands in a web terminal]]></description>
<version>0.3.4</version>
<summary>Run occ commands and SQL queries from a web terminal (admins only)</summary>
<description><![CDATA[A terminal in the browser for Nextcloud administrators: run occ commands and query the database without shell access to the server. Handy on Nextcloud All-in-One (AIO) and other container setups, where occ otherwise needs `docker exec`.
**occ mode**
- Run any occ command, with tab completion of command names and colored output.
- Every command is logged with the user who ran it, in the Nextcloud log and, when enabled, in the admin_audit log.
**SQL mode** (type `sql`)
- Run SQL against the Nextcloud database. Statements separated by `;` run as one batch on one connection, and the batch is rolled back if a statement fails.
- Results are shown as tables (up to 1000 rows per query).
- Statements that change data or the schema ask for confirmation before the batch runs. Reading or writing files on the database server and starting programs through SQL are blocked.
- Ready-made scripts: type `templates`, for example to look up or completely delete a local user. A template only fills the command line; nothing runs until you review it and press Enter.
**Security**
- Only members of the Nextcloud admin group can use the app; every request is checked and CSRF-protected.
- Treat access to it like shell access to the server, and run the database as a user without superuser or server file/program rights. See the Security section in the README.
**Limitations**
- Not an interactive terminal: long-running commands (like `files:scan` on a large instance) time out, and commands that ask questions cannot be answered (use `-n`).]]></description>
<version>0.4.6</version>
<licence>agpl</licence>
<author mail="dillard.blom@ensembia.com" >Dillard Blom</author>
<namespace>OCCWeb</namespace>
@@ -14,13 +35,25 @@
<bugs>https://github.com/dillardblom/occweb/issues</bugs>
<repository>https://github.com/dillardblom/occweb</repository>
<screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot.png</screenshot>
<screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot-status.png</screenshot>
<screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot-sql-switch.png</screenshot>
<screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot-sql.png</screenshot>
<dependencies>
<nextcloud min-version="30" max-version="33"/>
<!-- min-version and max-version must equal the lowest/highest NC core
versions this app has actually been verified on (via testbak/,
see its README) - never inherited from upstream or guessed at
either end. min-version was 30 pre-testbak, unverified since the
app-id rename/jQuery fixes - narrowed to 32 (2026-09-27) once 30/31
turned out to never have been re-tested and 32 was confirmed
working. Test against the next major's beta and bump max-version
BEFORE it goes final, so occ:upgrade never has to fall back to
auto-disabling the app. -->
<nextcloud min-version="32" max-version="35"/>
</dependencies>
<navigations>
<navigation role="admin">
<name>OCCWeb</name>
<route>occweb.occ.index</route>
<name>OCC Web</name>
<route>extended_occweb.occ.index</route>
<icon>app.svg</icon>
<type>link</type>
</navigation>
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 KiB

After

Width:  |  Height:  |  Size: 101 KiB

+4
View File
@@ -6,6 +6,10 @@
"authors": [
{
"name": "Adphi"
},
{
"name": "Dillard Blom",
"email": "dillard.blom@ensembia.com"
}
],
"require": {},
+5 -4
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bash
#
# Post-clone install script for occweb.
# Post-clone install script for occweb (Nextcloud app id: extended_occweb).
#
# Usage (from nextcloud/apps/):
# git clone https://github.com/fanategorius/occweb.git
# bash occweb/install.sh [nextcloud-root] [web-user]
# Usage (from nextcloud/apps/) - clone into "extended_occweb", not the repo's
# own name, since Nextcloud requires the app directory to match its id:
# git clone https://github.com/dillardblom/occweb.git extended_occweb
# bash extended_occweb/install.sh [nextcloud-root] [web-user]
#
# Defaults: nextcloud-root=/var/www/nextcloud, web-user=www-data
#
+96 -13
View File
@@ -1,11 +1,42 @@
(function (OC, window, $, undefined) {
'use strict';
$(function() {
// Nextcloud core only auto-attaches the CSRF requesttoken header to ajax
// calls made through its own globally-loaded jQuery. Since NC34 that
// global jQuery is no longer guaranteed to be the one still active by
// the time our own calls fire (other apps loading their own jQuery can
// replace window.jQuery afterwards - see js/jquery.js), ajaxSetup() here
// isn't reliable; every $.ajax() call below sets the header explicitly.
function scrollToBottom(){
var html = $('html');
html.scrollTop(html.prop('scrollHeight'));
}
var baseUrl = OC.generateUrl('/apps/occweb');
var baseUrl = OC.generateUrl('/apps/extended_occweb');
// occ output is plain text with ANSI colors, but jQuery Terminal reads
// "[" and "]" as its own formatting syntax. Escape the brackets in the
// text (e.g. Symfony's "[alias]" in `occ list`) and leave the ANSI
// sequences alone, so from_ansi can still turn them into colors. This
// only works together with unixFormattingEscapeBrackets (see below).
//
// Output can contain text from users (display names, file names), so
// keep only color codes (SGR) and drop other escape sequences,
// backspaces and control characters, which could rewrite or hide text
// on screen. A lone CR becomes a newline.
function sanitizeOccOutput(output) {
return String(output)
.replace(/\x1B\][^\x07\x1B]*(\x07|\x1B\\)?/g, '')
.replace(/(\x1B\[[0-9;]*m)|\x1B\[[0-?]*[ -\/]*[@-~]|\x1B[@-_]?/g, function (match, sgr) {
return sgr || '';
})
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F\x7F]/g, '')
.replace(/(\x1B\[[0-9;]*m)|[\[\]]/g, function (match, sgr) {
return sgr ? sgr : (match === '[' ? '&#91;' : '&#93;');
});
}
// Current terminal mode: 'occ' (regular occ commands) or 'sql'
// (running arbitrary SQL queries via /db/query).
@@ -56,7 +87,8 @@
// Quick client-side check for DELETE/UPDATE — purely for UX (to avoid
// an extra round trip to the server). The backend always makes the
// final call (requiresConfirmation); this is just a hint.
// final call (requiresConfirmation) and also covers every other
// statement that changes something; this is just a hint.
function scriptNeedsConfirmation(sql) {
return splitStatements(sql).some(function (part) {
var normalized = part.replace(/^(\s*--[^\n]*\n)*\s*/, '');
@@ -171,7 +203,10 @@
}
// Tabular rendering of SELECT results, psql-style.
function renderTable(term, rows) {
// Unless full is set, the widest columns are narrowed until the table
// fits the terminal, so one long value doesn't wrap the whole table.
// Returns true when a value was cut.
function renderTable(term, rows, full) {
if (!rows || !rows.length) {
return;
}
@@ -202,9 +237,34 @@
}, col.length);
});
var MIN_WIDTH = 10;
var cut = false;
if (!full) {
var available = term.cols() - 1;
var total = function () {
return widths.reduce(function (sum, w) { return sum + w + 3; }, -1);
};
while (total() > available) {
var widest = widths.indexOf(Math.max.apply(null, widths));
if (widths[widest] <= MIN_WIDTH) {
break;
}
widths[widest] = Math.max(MIN_WIDTH, widths[widest] - (total() - available));
}
}
function fit(str, width) {
str = String(str);
if (str.length <= width) {
return str;
}
cut = true;
return str.slice(0, width - 1) + '\u2026';
}
function formatRow(cells) {
return cells.map(function (cell, i) {
return ' ' + pad(cell, widths[i]) + ' ';
return ' ' + pad(fit(cell, widths[i]), widths[i]) + ' ';
}).join('|');
}
@@ -216,9 +276,10 @@
});
term.echo($.terminal.escape_formatting(lines.join('\n')));
return cut;
}
function renderSqlResponse(term, response) {
function renderSqlResponse(term, response, full) {
if (!response) {
term.echo('[[;#ff5555;]Empty response from server]');
return;
@@ -238,8 +299,8 @@
term.echo('[[;#ff5555;] Error: ]' + $.terminal.escape_formatting(r.error || 'unknown error'));
} else if (r.type === 'select') {
term.echo('[[;gray;] ' + r.count + ' row(s)]');
if (r.count > 0) {
renderTable(term, r.data);
if (r.count > 0 && renderTable(term, r.data, full)) {
term.echo('[[;gray;] Long values are cut to fit the terminal. Add --full to the query to see them in full.]');
}
if (r.truncated) {
term.echo('[[;yellow;] Result truncated — showing only the first ' + r.count + ' rows, add LIMIT to see more precisely.]');
@@ -287,6 +348,7 @@
type: 'POST',
contentType: 'application/json',
timeout: SQL_REQUEST_TIMEOUT_MS,
headers: { requesttoken: OC.requestToken },
data: JSON.stringify({ sql: sql, confirm: !!confirmed })
}).done(function (response) {
if (response && response.requiresConfirmation) {
@@ -294,7 +356,10 @@
askDeleteConfirmation(term, sql, response.error);
return;
}
renderSqlResponse(term, response);
// "--full" is a plain SQL comment, so the database ignores it; string
// literals are left out so a value containing it doesn't count
var withoutStrings = sql.replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '""');
renderSqlResponse(term, response, /(^|\s)-- ?full\b/.test(withoutStrings));
term.resume();
}).fail(function (xhr, status) {
if (status === 'timeout') {
@@ -307,7 +372,8 @@
}
function askDeleteConfirmation(term, sql, message) {
var prompt = '[[;#ff5555;]' + (message || 'This script contains DELETE/UPDATE statement(s).') + ' Type "yes" to run it: ]';
var text = message || 'This script contains statement(s) that change data or the schema.';
var prompt = '[[;#ff5555;]' + $.terminal.escape_brackets(text) + ' Type "yes" to run it: ]';
term.read(prompt).then(function (answer) {
if ((answer || '').trim().toLowerCase() === 'yes') {
sendSqlQuery(term, sql, true);
@@ -319,7 +385,7 @@
});
}
$.get(baseUrl + '/cmd', function(response){
$.ajax({ url: baseUrl + '/cmd', headers: { requesttoken: OC.requestToken } }).done(function(response){
$('#app-content').terminal(function(command, term) {
if (mode === 'sql') {
var trimmed = command.trim();
@@ -375,11 +441,12 @@
url: baseUrl + '/cmd',
type: 'POST',
contentType: 'application/json',
headers: { requesttoken: OC.requestToken },
data: JSON.stringify(occCommand)
}).done(function (response) {
term.echo('\n' + response).resume();
}).fail(function (response, code) {
term.echo('\n' + response).resume();
term.echo('\n' + sanitizeOccOutput(response)).resume();
}).fail(function (xhr, status) {
term.echo('\n[[;#ff5555;]Request failed: ]' + $.terminal.escape_formatting(xhr.status + ' ' + xhr.statusText)).resume();
});
}
}, {
@@ -389,6 +456,12 @@
name: 'occ',
prompt: OCC_PROMPT,
completion: response,
// The overtyping and from_ansi formatters unescape brackets before
// parsing; this makes them escape the text again afterwards.
unixFormattingEscapeBrackets: true,
// Output can contain user-controlled text; don't turn URLs in it
// into links an admin might click by mistake.
convertLinks: false,
keydown: function (e) {
// Shift+Enter inserts a newline instead of running the command,
// letting you type multi-line SQL scripts in sql mode.
@@ -401,6 +474,16 @@
scrollToBottom()
}
});
}).fail(function (xhr, status) {
// Without this, a failure here (expired session/CSRF token, network
// error, a rejected admin check) leaves #app-content permanently
// empty with no visible error and nothing in the console pointing
// at the cause - exactly what happened during NC34 testing before
// this handler existed.
$('#app-content').text(
'Failed to load the terminal: ' + xhr.status + ' ' + xhr.statusText +
'. Try reloading the page; if that keeps happening, check the server logs.'
);
});
$('html').keypress(function(){
scrollToBottom()
+2
View File
File diff suppressed because one or more lines are too long
+38 -158
View File
@@ -3,6 +3,7 @@
namespace OCA\OCCWeb\Controller;
use OC;
use OCA\OCCWeb\Service\SqlGuard;
use OCP\AppFramework\Controller;
use OCP\IRequest;
use OCP\IDBConnection;
@@ -19,26 +20,6 @@ class DbController extends Controller
/** Maximum size of submitted SQL text in bytes (protection against OOM/DoS). */
private const MAX_SQL_BYTES = 1048576; // 1 MB
/**
* Constructs that give access to the server's filesystem or allow
* launching external programs via SQL. These are blocked entirely,
* with no way to confirm past them — unlike DELETE, this isn't
* about data loss, it's about potential server takeover.
*/
private const FORBIDDEN_PATTERNS = [
'/\bCOPY\b[\s\S]*\bPROGRAM\b/i' => 'COPY ... PROGRAM (runs external commands)',
'/\bCOPY\b[\s\S]*\b(FROM|TO)\b\s*\'/i' => "COPY ... FROM/TO 'file' (access to the server's filesystem)",
'/\bpg_read_binary_file\s*\(/i' => 'pg_read_binary_file()',
'/\bpg_read_file\s*\(/i' => 'pg_read_file()',
'/\bpg_ls_dir\s*\(/i' => 'pg_ls_dir()',
'/\bpg_stat_file\s*\(/i' => 'pg_stat_file()',
'/\blo_import\s*\(/i' => 'lo_import()',
'/\blo_export\s*\(/i' => 'lo_export()',
'/\bdblink(_connect)?\s*\(/i' => 'dblink() (connects to arbitrary databases)',
'/\bLOAD_FILE\s*\(/i' => 'LOAD_FILE()',
'/\bINTO\s+(OUTFILE|DUMPFILE)\b/i' => 'INTO OUTFILE/DUMPFILE',
];
private $db;
private $groupManager;
private $userSession;
@@ -60,109 +41,14 @@ class DbController extends Controller
$this->logger = OC::$server->get(LoggerInterface::class);
}
/**
* Strips leading single-line comments ("-- ...") before a query.
* After splitting a batch, such a comment can end up stuck to the
* next query and interfere with detecting its type (SELECT/SET/DELETE).
*/
private function stripLeadingComments($query)
{
$query = ltrim($query);
while (preg_match('/^--[^\n]*\n/', $query)) {
$query = ltrim(preg_replace('/^--[^\n]*\n/', '', $query, 1));
}
return $query;
}
/**
* Strips ALL single-line (-- ...) and block-style C comments from a
* query, including ones sitting inside an expression (e.g. between a
* function name and its opening parenthesis — otherwise a forbidden
* construct could be hidden from findForbiddenConstruct that way).
* Used only for checking against forbidden constructs — the query
* that actually gets executed is left unmodified.
*/
private function removeAllComments($query)
{
$query = preg_replace('/--[^\n]*/', '', $query);
$query = preg_replace('/\/\*[\s\S]*?\*\//', '', $query);
return $query;
}
/**
* Splits a batch of queries on ";" while respecting single and double
* quotes (strings and Postgres-escaped identifiers), so that a
* semicolon inside a 'string' or "identifier" (including '' / "" as
* an escaped quote) doesn't break up the split.
*/
private function splitStatements($sql)
{
$statements = [];
$current = '';
$len = strlen($sql);
$quoteChar = null;
for ($i = 0; $i < $len; $i++) {
$ch = $sql[$i];
if ($quoteChar !== null) {
if ($ch === $quoteChar) {
if ($i + 1 < $len && $sql[$i + 1] === $quoteChar) {
$current .= $quoteChar . $quoteChar;
$i++;
continue;
}
$quoteChar = null;
}
$current .= $ch;
continue;
}
if ($ch === "'" || $ch === '"') {
$quoteChar = $ch;
$current .= $ch;
continue;
}
if ($ch === ';') {
$statements[] = trim($current);
$current = '';
continue;
}
$current .= $ch;
}
if (trim($current) !== '') {
$statements[] = trim($current);
}
return array_values(array_filter($statements, function ($s) {
return $s !== '';
}));
}
/**
* Returns a description of the forbidden construct found (filesystem
* access, running programs), or null if the query is safe in that
* regard. The comment-stripped version of the query is checked —
* otherwise the construct could be hidden by inserting a comment
* between the function name and "(".
*/
private function findForbiddenConstruct($query)
{
$clean = $this->removeAllComments($query);
foreach (self::FORBIDDEN_PATTERNS as $pattern => $label) {
if (preg_match($pattern, $clean)) {
return $label;
}
}
return null;
}
public function query()
{
// Check admin privileges
// Deliberate defense-in-depth (see the equivalent check in
// OccController::requireAdmin() for why it's kept even though
// Nextcloud's SecurityMiddleware already enforces this by default
// absent @NoAdminRequired) - this one is also the direct fix for
// this controller having shipped with @PublicPage originally,
// which did bypass that default protection entirely.
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse(['error' => 'Not authenticated'], 401);
@@ -189,8 +75,8 @@ class DbController extends Controller
// Audit: log the fact that execution was attempted BEFORE any checks,
// so blocked/rejected queries are recorded in the log too, not just
// the ones that succeeded.
$this->logger->warning('[occweb] SQL submitted by {user}: {sql}', [
'app' => 'occweb',
$this->logger->warning('[extended_occweb] SQL submitted by {user}: {sql}', [
'app' => 'extended_occweb',
'user' => $user->getUID(),
'sql' => $sql,
]);
@@ -199,16 +85,16 @@ class DbController extends Controller
// All queries run sequentially on the same DB connection (within a
// single HTTP request), so SET retains its value for
// current_setting() in the following queries of the same batch.
$queries = $this->splitStatements($sql);
$queries = SqlGuard::splitStatements($sql);
// Access to the server's filesystem / running programs via SQL is
// blocked entirely — this isn't about data loss (like DELETE), it's
// about potential server takeover, and confirmation doesn't bypass it.
foreach ($queries as $query) {
$forbidden = $this->findForbiddenConstruct($query);
$forbidden = SqlGuard::findForbiddenConstruct($query);
if ($forbidden !== null) {
$this->logger->error('[occweb] Blocked forbidden construct ({construct}) from {user}: {sql}', [
'app' => 'occweb',
$this->logger->error('[extended_occweb] Blocked forbidden construct ({construct}) from {user}: {sql}', [
'app' => 'extended_occweb',
'construct' => $forbidden,
'user' => $user->getUID(),
'sql' => $sql,
@@ -220,34 +106,29 @@ class DbController extends Controller
}
}
// DELETE and UPDATE are irreversible (or hard to reverse), so we
// require explicit confirmation from the client (confirm=true) before
// executing a single query from the batch.
$deleteCount = 0;
$updateCount = 0;
// Anything that isn't a plain read (DELETE, UPDATE, INSERT, DROP,
// a data-modifying CTE, ...) needs explicit confirmation from the
// client (confirm=true) before a single query of the batch runs.
$writeTypes = [];
foreach ($queries as $query) {
$normalized = $this->stripLeadingComments($query);
if (stripos($normalized, 'DELETE') === 0) {
$deleteCount++;
} elseif (stripos($normalized, 'UPDATE') === 0) {
$updateCount++;
if (!SqlGuard::isReadOnly($query)) {
$type = SqlGuard::statementType($query) ?: 'UNKNOWN';
$writeTypes[$type] = ($writeTypes[$type] ?? 0) + 1;
}
}
if (($deleteCount > 0 || $updateCount > 0) && !$confirmed) {
if ($writeTypes !== [] && !$confirmed) {
$parts = [];
if ($deleteCount > 0) {
$parts[] = "{$deleteCount} DELETE";
}
if ($updateCount > 0) {
$parts[] = "{$updateCount} UPDATE";
foreach ($writeTypes as $type => $count) {
$parts[] = "{$count} {$type}";
}
return new JSONResponse([
'success' => false,
'requiresConfirmation' => true,
'deleteCount' => $deleteCount,
'updateCount' => $updateCount,
'error' => 'Batch contains ' . implode(' and ', $parts) . ' statement(s) and was not executed. Resend with confirm=true to proceed.'
'deleteCount' => $writeTypes['DELETE'] ?? 0,
'updateCount' => $writeTypes['UPDATE'] ?? 0,
'writeCount' => array_sum($writeTypes),
'error' => 'Batch contains statement(s) that change data or the schema (' . implode(', ', $parts) . ') and was not executed. Resend with confirm=true to proceed.'
]);
}
@@ -277,24 +158,23 @@ class DbController extends Controller
$this->db->beginTransaction();
$transactionStarted = true;
} catch (\Exception $e) {
$this->logger->warning('[occweb] beginTransaction() failed, proceeding without an explicit transaction: {error}', [
'app' => 'occweb',
$this->logger->warning('[extended_occweb] beginTransaction() failed, proceeding without an explicit transaction: {error}', [
'app' => 'extended_occweb',
'error' => $e->getMessage(),
]);
}
foreach ($queries as $query) {
$normalized = $this->stripLeadingComments($query);
$isSelect = stripos($normalized, 'SELECT') === 0;
$isSet = stripos($normalized, 'SET ') === 0;
$isDelete = stripos($normalized, 'DELETE') === 0;
$isUpdate = stripos($normalized, 'UPDATE') === 0;
$statementType = SqlGuard::statementType($query);
$isSet = $statementType === 'SET';
$isDelete = $statementType === 'DELETE';
$isUpdate = $statementType === 'UPDATE';
try {
$stmt = $this->db->prepare($query);
$stmt->execute();
if ($isSelect) {
if ($statementType === 'SELECT' || ($statementType !== 'SET' && SqlGuard::isReadOnly($query))) {
// Read row by row and stop at MAX_ROWS, instead of
// fetchAll() + array_slice — otherwise a SELECT without
// LIMIT on a huge table would still pull everything into
@@ -353,8 +233,8 @@ class DbController extends Controller
// permanently applied — we deliberately do NOT set
// rolledBack to true, since that would be untrue.
$rollbackFailed = true;
$this->logger->warning('[occweb] rollBack() failed after an error — earlier statements in this batch may already be permanently applied: {error}', [
'app' => 'occweb',
$this->logger->warning('[extended_occweb] rollBack() failed after an error — earlier statements in this batch may already be permanently applied: {error}', [
'app' => 'extended_occweb',
'error' => $rollbackError->getMessage(),
]);
}
@@ -371,8 +251,8 @@ class DbController extends Controller
// a DDL statement on MySQL/MariaDB) — the effects are
// already saved, this isn't an error in executing the batch
// itself.
$this->logger->info('[occweb] commit() had nothing to commit (likely auto-committed by a DDL statement): {error}', [
'app' => 'occweb',
$this->logger->info('[extended_occweb] commit() had nothing to commit (likely auto-committed by a DDL statement): {error}', [
'app' => 'extended_occweb',
'error' => $e->getMessage(),
]);
}
+43 -16
View File
@@ -5,7 +5,6 @@ namespace OCA\OCCWeb\Controller;
use Exception;
use OC;
use OC\Console\Application;
use OC\MemoryInfo;
use OCP\IRequest;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Http\DataResponse;
@@ -21,6 +20,7 @@ class OccController extends Controller
private $userId;
private $application;
private $fakeRequest;
private $symphonyApplication;
private $output;
@@ -30,16 +30,8 @@ class OccController extends Controller
$this->logger = OC::$server->get(LoggerInterface::class);
$this->userId = $userId;
$this->application = new Application(
OC::$server->get(\OCP\ServerVersion::class),
OC::$server->get(\OCP\IConfig::class),
OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class),
new FakeRequest(),
$this->logger,
OC::$server->query(MemoryInfo::class),
OC::$server->get(\OCP\App\IAppManager::class), // Obtain the IAppManager
OC::$server->get(\OCP\Defaults::class)
);
$this->fakeRequest = new FakeRequest();
$this->application = $this->createConsoleApplication($this->fakeRequest);
$this->application->setAutoExit(false);
$this->output = new OccOutput(OutputInterface::VERBOSITY_NORMAL, true);
$this->application->loadCommands(new StringInput(""), $this->output);
@@ -50,8 +42,17 @@ class OccController extends Controller
private function requireAdmin(): ?JSONResponse
{
// Services via OC::$server zodat de constructor-signatuur ongewijzigd blijft
// en NC's DI-container (zonder application.php) de class kan resolven.
// Deliberate defense-in-depth, not a workaround for a missing framework
// check: Nextcloud's own SecurityMiddleware already rejects non-admins
// here by default (no method below has @NoAdminRequired), so this is
// redundant today. Kept anyway given what this app can do (arbitrary
// occ/SQL execution) - if the framework check ever regresses, this
// still holds. If you ever want to intentionally open one of these
// routes to non-admins, remove the call below AND add
// @NoAdminRequired, or this will keep silently blocking it.
//
// Services via OC::$server so the constructor signature stays unchanged
// and NC's DI container (without an application.php) can resolve the class.
$userSession = OC::$server->get(\OCP\IUserSession::class);
$user = $userSession->getUser();
if ($user === null) {
@@ -70,7 +71,25 @@ class OccController extends Controller
public function index()
{
if ($err = $this->requireAdmin()) return $err;
return new TemplateResponse('occweb', 'index');
return new TemplateResponse('extended_occweb', 'index');
}
/**
* Builds Nextcloud's console application with its dependencies from the
* container, except the request: the console reads argv from it for the
* ConsoleEvent (used by admin_audit), which a web request doesn't have.
* Resolving the arguments by type keeps this working when Nextcloud
* changes the constructor, which it has done several times.
*/
private function createConsoleApplication(IRequest $request): Application
{
$arguments = [];
foreach ((new \ReflectionClass(Application::class))->getConstructor()->getParameters() as $parameter) {
$type = $parameter->getType();
$class = $type instanceof \ReflectionNamedType ? $type->getName() : null;
$arguments[] = $class === IRequest::class ? $request : OC::$server->get($class);
}
return new Application(...$arguments);
}
/**
@@ -82,7 +101,7 @@ class OccController extends Controller
try {
$this->application->run($input, $this->output);
return $this->output->fetch();
} catch (Exception $ex) {
} catch (\Throwable $ex) {
$this->logger->error($ex->getMessage(), ['exception' => $ex]);
return "error: " . $ex->getMessage();
}
@@ -95,7 +114,15 @@ class OccController extends Controller
public function cmd($command)
{
if ($err = $this->requireAdmin()) return $err;
$this->logger->debug($command);
// The regular log gets only the command name: arguments can hold
// secrets (config:system:set --value=...). The full command goes to
// admin_audit through the ConsoleEvent, like occ on the CLI.
$this->logger->warning('[extended_occweb] occ command by {user}: {command}', [
'app' => 'extended_occweb',
'user' => $this->userId,
'command' => strtok(trim((string) $command), " \t\n") ?: '(empty)',
]);
$this->fakeRequest->server['argv'] = ['occ', $command];
$input = new StringInput($command);
$response = $this->run($input);
$this->logger->debug($response);
+6 -1
View File
@@ -17,7 +17,12 @@ class OccOutput extends BufferedOutput implements ConsoleOutputInterface
return $this;
}
public function setErrorOutput(OutputInterface $error)
// NC35 bundles a Symfony Console version whose ConsoleOutputInterface
// declares this method `: void` - PHP 8 fatals ("Declaration must be
// compatible") without a matching return type here. Declaring `: void`
// is a valid narrowing of the untyped signature older Symfony/NC
// versions have, so this stays compatible with NC30-34 too.
public function setErrorOutput(OutputInterface $error): void
{
}
+245
View File
@@ -0,0 +1,245 @@
<?php
namespace OCA\OCCWeb\Service;
/**
* Checks run on SQL submitted through the SQL mode before it is executed.
*
* This is defense in depth, not a sandbox: dynamic SQL (EXECUTE with a
* built-up string, functions) can always get around a text-based check.
* The real protection is a database role without superuser rights,
* pg_execute_server_program or pg_read/write_server_files.
*/
class SqlGuard
{
/**
* Constructs that give access to the server's filesystem or allow
* launching external programs via SQL. These are blocked entirely,
* with no way to confirm past them.
*/
private const FORBIDDEN_PATTERNS = [
'/\bCOPY\b[\s\S]*\bPROGRAM\b/i' => 'COPY ... PROGRAM (runs external commands)',
// A COPY file name is a string literal: 'x', E'x', U&'x' or $$x$$
'/\bCOPY\b[\s\S]*\b(FROM|TO)\s+(E|U&)?(\'|\$)/i' => "COPY ... FROM/TO 'file' (access to the server's filesystem)",
'/\bpg_read_binary_file\s*\(/i' => 'pg_read_binary_file()',
'/\bpg_read_file\s*\(/i' => 'pg_read_file()',
'/\bpg_ls_dir\s*\(/i' => 'pg_ls_dir()',
'/\bpg_stat_file\s*\(/i' => 'pg_stat_file()',
'/\blo_import\s*\(/i' => 'lo_import()',
'/\blo_export\s*\(/i' => 'lo_export()',
'/\bdblink\w*\s*\(/i' => 'dblink functions (connect to arbitrary databases)',
'/\bLOAD_FILE\s*\(/i' => 'LOAD_FILE()',
'/\bINTO\s+(OUTFILE|DUMPFILE)\b/i' => 'INTO OUTFILE/DUMPFILE',
];
/**
* Statements that start with one of these only read, unless
* WRITES_INSIDE_READ matches. SQLite's PRAGMA is left out on purpose:
* it can also change settings, so it asks for confirmation.
*/
private const READ_ONLY_START = '/^(SELECT|WITH|SHOW|EXPLAIN|DESCRIBE|DESC|SET|VALUES|TABLE)\b/i';
/**
* What makes a statement that starts as a read change something:
* a data-modifying CTE (WITH x AS (DELETE ...)), SELECT ... INTO,
* SELECT ... FOR UPDATE, EXPLAIN ANALYZE (runs the statement),
* SET ROLE / SET SESSION AUTHORIZATION, MySQL's SET PASSWORD /
* GLOBAL / PERSIST, and functions with side
* effects. Statements that don't start as a read always need
* confirmation, so INSERT, DROP, CREATE etc. don't need to be here.
*/
private const WRITES_INSIDE_READ = [
'/\b(INSERT|UPDATE|DELETE|MERGE|INTO|ANALYZE)\b/i',
'/^SET\s+(SESSION\s+|LOCAL\s+)?(ROLE|SESSION\s+AUTHORIZATION)\b/i',
'/^SET\s+(PASSWORD|GLOBAL|PERSIST|PERSIST_ONLY)\b|^SET\s+@@(GLOBAL|PERSIST|PERSIST_ONLY)\./i',
'/\b(pg_terminate_backend|pg_cancel_backend|pg_reload_conf|set_config|setval|nextval|lo_create|lo_put|lo_unlink|pg_switch_wal|pg_create_restore_point|pg_advisory_lock)\s*\(/i',
];
/**
* Splits a batch on ";" outside of 'strings', "identifiers",
* $tag$dollar-quoted bodies$tag$ and comments, so a semicolon in a
* DO block or a quote in a comment doesn't break up the split.
*
* @return string[]
*/
public static function splitStatements(string $sql): array
{
$statements = [];
$current = '';
$len = strlen($sql);
$i = 0;
while ($i < $len) {
$end = self::literalEnd($sql, $i) ?? self::commentEnd($sql, $i);
if ($end !== null) {
$current .= substr($sql, $i, $end - $i);
$i = $end;
continue;
}
if ($sql[$i] === ';') {
$statements[] = trim($current);
$current = '';
} else {
$current .= $sql[$i];
}
$i++;
}
$statements[] = trim($current);
// A trailing comment ("SELECT 1; -- note") is not a statement
return array_values(array_filter($statements, function ($s) {
return trim(self::removeComments($s)) !== '';
}));
}
/**
* Replaces comments with a space, outside of literals only. A comment
* separates tokens, so dropping it without a space would glue
* "TO/ ** /PROGRAM" into one word; and a comment marker inside a
* literal ('/*') must not swallow the code after it.
*/
public static function removeComments(string $query): string
{
return self::scan($query, false);
}
/**
* Also replaces string literals and quoted identifiers with a
* placeholder, so words inside them ('deleted', "update") don't count
* as keywords.
*/
public static function removeLiterals(string $query): string
{
return self::scan($query, true);
}
private static function scan(string $sql, bool $replaceLiterals): string
{
$result = '';
$len = strlen($sql);
$i = 0;
while ($i < $len) {
$end = self::literalEnd($sql, $i);
if ($end !== null) {
$literal = substr($sql, $i, $end - $i);
if (preg_match('/^"([A-Za-z_][A-Za-z0-9_$]*)"$/', $literal, $m) && preg_match('/\G(?:\s|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*\(/', $sql, $unused, 0, $end)) {
// A quoted function name ("setval"(...)) calls the same
// function as the bare word, so check it as one
$result .= $m[1];
} else {
$result .= $replaceLiterals ? ($sql[$i] === '"' ? '""' : "''") : $literal;
}
$i = $end;
continue;
}
$end = self::commentEnd($sql, $i);
if ($end !== null) {
$result .= ' ';
$i = $end;
continue;
}
$result .= $sql[$i];
$i++;
}
return $result;
}
/**
* End offset of the 'string', E'string', "identifier" or $tag$body$tag$
* starting at $i, or null if none starts there. An unclosed literal
* runs to the end.
*/
private static function literalEnd(string $sql, int $i): ?int
{
$len = strlen($sql);
$ch = $sql[$i];
if ($ch === "'" || $ch === '"') {
$backslashEscapes = $ch === "'" && $i > 0 && ($sql[$i - 1] === 'E' || $sql[$i - 1] === 'e')
&& ($i === 1 || !preg_match('/[A-Za-z0-9_]/', $sql[$i - 2]));
$end = $i + 1;
while ($end < $len) {
if ($backslashEscapes && $sql[$end] === '\\') {
$end += 2;
continue;
}
if ($sql[$end] === $ch) {
if ($end + 1 < $len && $sql[$end + 1] === $ch) {
$end += 2;
continue;
}
return $end + 1;
}
$end++;
}
return $len;
}
if ($ch === '$' && preg_match('/\G\$([A-Za-z_][A-Za-z0-9_]*)?\$/', $sql, $m, 0, $i)) {
$close = strpos($sql, $m[0], $i + strlen($m[0]));
return $close === false ? $len : $close + strlen($m[0]);
}
return null;
}
/**
* End offset of the -- or block comment starting at $i, or null.
*/
private static function commentEnd(string $sql, int $i): ?int
{
if (substr($sql, $i, 2) === '--') {
$newline = strpos($sql, "\n", $i);
return $newline === false ? strlen($sql) : $newline;
}
if (substr($sql, $i, 2) === '/*') {
$close = strpos($sql, '*/', $i + 2);
return $close === false ? strlen($sql) : $close + 2;
}
return null;
}
/**
* Returns a description of the forbidden construct found, or null.
* Literals are kept here on purpose: a file name is a literal.
*/
public static function findForbiddenConstruct(string $query): ?string
{
$clean = self::removeComments($query);
foreach (self::FORBIDDEN_PATTERNS as $pattern => $label) {
if (preg_match($pattern, $clean)) {
return $label;
}
}
return null;
}
/**
* The first keyword of a statement, upper case, e.g. SELECT or DELETE.
*/
public static function statementType(string $query): string
{
$clean = ltrim(self::removeComments($query), " \t\n\r\0\x0B(");
return preg_match('/^([A-Za-z]+)/', $clean, $m) ? strtoupper($m[1]) : '';
}
/**
* Whether a statement only reads. Anything else needs confirmation.
*/
public static function isReadOnly(string $query): bool
{
$clean = ltrim(self::removeLiterals(self::removeComments($query)), " \t\n\r\0\x0B(");
if (preg_match(self::READ_ONLY_START, $clean) !== 1) {
return false;
}
foreach (self::WRITES_INSIDE_READ as $pattern) {
if (preg_match($pattern, $clean) === 1) {
return false;
}
}
return true;
}
}
+18 -4
View File
@@ -1,8 +1,22 @@
<?php
script('occweb', 'index');
script('occweb', 'term');
script('occweb', 'unix_formatting');
style('occweb', 'style');
// Nextcloud's global jQuery has been deprecated since NC19 and is no longer
// reliably present on NC34+ (see e.g. serverinfo needing to bundle its own) -
// term.js (jquery.terminal) and index.js both require jQuery to be loaded
// first, so ship and load our own copy where core no longer provides one.
//
// Only do this on NC34+: on NC32/33 core still exposes a working jQuery, but
// via a read-only `window.$` getter - loading our vendored copy there too
// makes its UMD wrapper throw trying to reassign `window.$` (confirmed via
// testbak/, see the README compat matrix). Harmless in practice (this app
// attaches via window.jQuery, not window.$) but a real, avoidable console
// exception, so skip the extra load where core's own jQuery already works.
if (\OCP\Server::get(\OCP\ServerVersion::class)->getMajorVersion() >= 34) {
script('extended_occweb', 'jquery');
}
script('extended_occweb', 'index');
script('extended_occweb', 'term');
script('extended_occweb', 'unix_formatting');
style('extended_occweb', 'style');
?>
<div id="app" class="full-width">
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace OCA\OCCWeb\Tests\Unit\Service;
use OCA\OCCWeb\Service\SqlGuard;
use PHPUnit\Framework\TestCase;
class SqlGuardTest extends TestCase
{
public static function forbiddenProvider(): array
{
return [
["COPY (SELECT 1) TO PROGRAM 'sh'"],
["COPY (SELECT 'id') TO/**/PROGRAM 'sh'"],
["COPY (SELECT 1) TO--x\nPROGRAM 'sh'"],
["COPY (SELECT 1) TO '/tmp/x'"],
["COPY (SELECT current_user) TO/**/'/tmp/x'"],
["COPY (SELECT 1) TO E'/tmp/x'"],
["COPY (SELECT 1) TO \$\$/tmp/x\$\$"],
["COPY (SELECT 1) TO \$f\$/tmp/x\$f\$"],
["COPY oc_users FROM '/etc/passwd'"],
["SELECT pg_read_file/**/('/etc/passwd')"],
["SELECT pg_ls_dir('/')"],
["SELECT lo_import('/etc/passwd')"],
["SELECT dblink_connect('host=x')"],
["SELECT dblink_exec('host=x', 'DROP TABLE t')"],
["SELECT \"pg_read_file\"('/etc/passwd')"],
["SELECT pg_catalog.\"pg_read_file\"('/etc/passwd')"],
["SELECT \"pg_read_file\" /* x */ ('/etc/passwd')"],
["SELECT \"pg_read_file\"-- x\n('/etc/passwd')"],
["SELECT dblink_send_query('c', 'SELECT 1')"],
["SELECT * FROM dblink('host=x', 'SELECT 1') AS t(a int)"],
["SELECT * FROM t INTO OUTFILE '/tmp/x'"],
["COPY (SELECT '/*') TO PROGRAM 'sh' --*/"],
["COPY (SELECT '--') TO PROGRAM 'sh'"],
["COPY (SELECT \$\$/*\$\$) TO PROGRAM 'sh' --*/"],
["COPY (SELECT \"/*\") TO PROGRAM 'sh' --*/"],
["COPY (SELECT E'\\'/*') TO PROGRAM 'sh' --*/"],
];
}
/**
* @dataProvider forbiddenProvider
*/
public function testForbidden(string $sql): void
{
$this->assertNotNull(SqlGuard::findForbiddenConstruct($sql));
}
public static function allowedProvider(): array
{
return [
['SELECT * FROM oc_users'],
['COPY (SELECT 1) TO STDOUT'],
];
}
/**
* @dataProvider allowedProvider
*/
public function testAllowed(string $sql): void
{
$this->assertNull(SqlGuard::findForbiddenConstruct($sql));
}
public static function readOnlyProvider(): array
{
return [
['SELECT * FROM oc_users'],
[' -- comment' . "\n" . 'SELECT 1'],
['/* comment */ SELECT 1'],
['(SELECT 1) UNION (SELECT 2)'],
['WITH x AS (SELECT 1) SELECT * FROM x'],
["SELECT * FROM oc_jobs WHERE class = 'OCA\\\\Mail\\\\DeleteJob'"],
["SELECT * FROM t WHERE status = 'deleted' OR note = 'update into'"],
['SELECT "update" FROM t'],
["SELECT replace(name, 'a', 'b') FROM t"],
['SHOW search_path'],
['EXPLAIN SELECT 1'],
["SET myvar.uid = 'alice'"],
['SET @uid = 5'],
['SET SESSION sql_mode = \'\''],
["SELECT \$\$DELETE FROM x\$\$"],
["SELECT E'it\\'s delete' FROM t"],
['VALUES (1)'],
['DESCRIBE oc_users'],
['DESC oc_users'],
["SELECT '/* not a comment */' AS a, '-- nor this' AS b"],
];
}
/**
* @dataProvider readOnlyProvider
*/
public function testReadOnly(string $sql): void
{
$this->assertTrue(SqlGuard::isReadOnly($sql), $sql);
}
public static function writeProvider(): array
{
return [
['DELETE FROM oc_preferences'],
['/* not a leading -- comment */ DELETE FROM oc_preferences'],
["-- x\nUPDATE t SET a = 1"],
['WITH removed AS (DELETE FROM oc_preferences RETURNING *) SELECT count(*) FROM removed'],
['WITH x AS (UPDATE t SET a = 1 RETURNING *) SELECT 1'],
['INSERT INTO t VALUES (1)'],
['TRUNCATE oc_preferences'],
['DROP TABLE t'],
['ALTER TABLE t ADD c int'],
['CREATE TABLE t (a int)'],
['GRANT ALL ON t TO x'],
["DO \$\$BEGIN PERFORM 1; END\$\$"],
['CALL p()'],
['SELECT * INTO newtable FROM t'],
['SELECT * FROM t FOR UPDATE'],
['EXPLAIN ANALYZE DELETE FROM t'],
['SET ROLE postgres'],
['SET SESSION AUTHORIZATION postgres'],
["SET PASSWORD = 'x'"],
['SET GLOBAL max_connections = 10'],
['SET PERSIST max_connections = 10'],
['SET @@GLOBAL.max_connections = 10'],
['SELECT pg_terminate_backend(123)'],
["SELECT set_config('x', 'y', false)"],
["SELECT setval('seq', 1)"],
["SELECT nextval('seq')"],
["SELECT \"setval\"('seq', 1)"],
["SELECT pg_catalog.\"setval\"('seq', 1)"],
["SELECT pg_catalog.setval('seq', 1)"],
["SELECT \"setval\" /* x */ ('seq', 1)"],
['MERGE INTO t USING s ON true WHEN MATCHED THEN DELETE'],
['VACUUM'],
['PRAGMA journal_mode = WAL'],
["WITH a AS (SELECT '/*'), b AS (DELETE FROM t RETURNING 1) SELECT '*/'"],
["SELECT '--' AS x FROM t WHERE a IN (SELECT 1) FOR UPDATE"],
["WITH a AS (SELECT \$\$/*\$\$), b AS (DELETE FROM t RETURNING 1) SELECT '*/'"],
['', ],
];
}
/**
* @dataProvider writeProvider
*/
public function testNeedsConfirmation(string $sql): void
{
$this->assertFalse(SqlGuard::isReadOnly($sql), $sql);
}
public function testStatementType(): void
{
$this->assertSame('DELETE', SqlGuard::statementType('/* x */ delete from t'));
$this->assertSame('WITH', SqlGuard::statementType("-- x\nWITH a AS (SELECT 1) SELECT 1"));
$this->assertSame('SELECT', SqlGuard::statementType('(SELECT 1)'));
$this->assertSame('', SqlGuard::statementType(''));
}
public static function splitProvider(): array
{
return [
['SELECT 1; SELECT 2', ['SELECT 1', 'SELECT 2']],
["SELECT ';'; SELECT 2", ["SELECT ';'", 'SELECT 2']],
["SELECT 'it''s; x'", ["SELECT 'it''s; x'"]],
['SELECT "a;b" FROM t', ['SELECT "a;b" FROM t']],
["SELECT E'it\\'s; x'; SELECT 2", ["SELECT E'it\\'s; x'", 'SELECT 2']],
["DO \$\$BEGIN PERFORM 1; PERFORM 2; END\$\$; SELECT 3", ["DO \$\$BEGIN PERFORM 1; PERFORM 2; END\$\$", 'SELECT 3']],
["SELECT \$f\$a;\$\$;b\$f\$; SELECT 2", ["SELECT \$f\$a;\$\$;b\$f\$", 'SELECT 2']],
["-- don't; split\nSELECT 1; SELECT 2", ["-- don't; split\nSELECT 1", 'SELECT 2']],
["/* it's; here */ SELECT 1; SELECT 2", ["/* it's; here */ SELECT 1", 'SELECT 2']],
['SELECT \$1; SELECT 2', ['SELECT \$1', 'SELECT 2']],
[' ; ;SELECT 1;', ['SELECT 1']],
["SELECT 1; --full", ['SELECT 1']],
["SELECT 1; -- note\nSELECT 2", ['SELECT 1', "-- note\nSELECT 2"]],
["-- only a comment", []],
["SELECT 1; /* note */", ['SELECT 1']],
];
}
/**
* @dataProvider splitProvider
*/
public function testSplitStatements(string $sql, array $expected): void
{
$this->assertSame($expected, SqlGuard::splitStatements($sql));
}
}