Skip to content

Security inquiry: three user-preference / layout-attribute paths feed unsandboxed SpEL evaluation — request for private channel #2989

@cybervuln2077

Description

@cybervuln2077

Summary

While auditing uPortal master, I noticed three code paths where a value originating from a portlet preference or a layout/structure attribute is parsed and evaluated as a SpEL expression in a StandardEvaluationContext — i.e. type references (T(...)), constructors and arbitrary method calls are reachable when the input string is attacker-influenced. None of these are sandboxed (SimpleEvaluationContext), and the evaluation happens during normal rendering.

I have not run an end-to-end exploit; this is a code-trace inquiry. I'm intentionally not posting a working SpEL payload here because uPortal is widely deployed and a public RCE PoC isn't responsible. I'd appreciate confirmation of whether the project considers any of these worth hardening, and ideally a private channel (GitHub private security advisory, or a maintainer email) where I can share concrete reproduction steps if useful.

There is currently no SECURITY.md / securityPolicyUrl on the repository, which is why I'm filing here.

Path 1 — Background Image Preference (most likely user-reachable)

  • Sink: uPortal-portlets/.../backgroundpreference/RoleBasedBackgroundSetSelectionStrategy.java
    • evaluateImagePath(...) calls portalSpELService.parseExpression(path, TemplateParserContext).getValue(evaluationContext, String.class) (around L181-187).
    • evaluationContext is a bare StandardEvaluationContext (L85) with a simple root object; no BeanResolver, but T(...) / constructors / method calls remain available by default.
  • Read flow: ViewBackgroundPreferenceController.getView(...)imageSetSelectionStrategy.getImageSet(req) (L45) → getImageSet iterates the defaultBackgroundImages / mobileBackgroundImages portlet preference and runs each entry through evaluateImagePath (L94-103).
  • Apparent write entry — entity preferences REST: uPortal-api/uPortal-api-rest/.../rest/PortletPreferencesRESTController.javaputEntityPreferences (PUT /preferences/{fname}, L476-540) has no isGuest() / canConfigure() check (compare the definition-scope endpoint at L407 / L426, which does enforce these); storePreferences (L543-589) writes every supplied key without an allow-list.
  • portlet.xml (the backgroundPreference portlet, L998-1025) does not declare <read-only>true</read-only> on the defaultBackgroundImages / mobileBackgroundImages preferences.
  • What does NOT reach the sink: the obvious-looking entry ViewBackgroundPreferenceController.savePreferences(@RequestParam backgroundImage) (L68-79) is not an injection point — setSelectedImage first checks membership against the already-evaluated image set (L153) and stores only into *SelectedBackgroundImage, which getSelectedImage documents as "no evaluation required" (L124-129). So backgroundImage cannot reach evaluateImagePath. The reachability above is via the generic preferences REST PUT, not savePreferences.

Path 2 — Layout / Structure Attribute (also user-reachable)

  • Sink: uPortal-web/.../rendering/StylesheetAttributeSource.java
    • getAdditionalAttributes(...) (L71-115) iterates getLayoutAttributeDescriptors() for the current element, reads stylesheetUserPreferencesService.getLayoutAttribute(...) (L93-95); if the value passes startsWith("${") && endsWith("}") (L147-152), it is parsed and evaluated as SpEL via PortalSpELServiceImpl.getValue(expr, WebRequest, String.class) (L164-185 → PortalSpELServiceImpl L128-130, shared evaluationContext, no BeanResolver).
    • The render-side subclass StructureAttributeSource.java (L23-28) returns PreferencesScope.STRUCTURE. There is a dedicated test StylesheetAttributeSourceForSpELVariablesTest that asserts a user-preference layout-attribute value wrapped in ${...} is evaluated (getAdditionalAttributesMethodShouldReturnSpELEvaluatedValueForUserPreferenceLayoutAttribute).
  • Write entry: UpdatePreferencesServlet.java updateAttributes (POST /layout?action=updateAttributes, L1075-1127).
    • Guard at L1087 is ulm.getNode(targetId).isEditAllowed() — a per-node "edit allowed" check on the user's own layout, not an admin role check. A non-guest authenticated user editing their own layout passes.
    • setObjectAttributes (L1371-1400) splits the request body. The branch at L1389-1398 takes attributes.get("structureAttributes") (a Map<String,String> from the request body) and calls setLayoutAttribute(request, PreferencesScope.STRUCTURE, node.getId(), name, value) — both name and value flow through verbatim, no ${ filtering. The STRUCTURE scope matches the read side above.
    • Of the six setLayoutAttribute call sites in UpdatePreferencesServlet, only this one (L1392) passes a free-form value: the others use computed values (width + "%" at L957, L1312), constrained tab fields (L986, L1179), or a switch-clamped numeric (flexColumns ∈ {2,3,4,6} at L1285). The generic updateAttributes path therefore lets a user write a ${...} value for a structure-scope attribute whose dedicated setter would have constrained it.
  • Persistence constraint (StylesheetUserPreferencesServiceImpl.java, L847-904): the attribute name must be a declared ILayoutAttributeDescriptor (L859-864, else ignored); guest persistence is read-only (L156). Value is stored as-is (L899-900).
  • Open question I'd like to confirm: which declared structure attribute(s) of the shipped Respondr structure stylesheet end up flowing through StructureAttributeSource.getAdditionalAttributes(...) at render. flexColumns (referenced by uPortal-webapp/.../layout/theme/respondr/content.xsl as @flexColumns) looks like the natural candidate; the structure-stylesheet descriptor data ships separately (uPortal-start) and I don't have visibility into the full descriptor here. If flexColumns (or another structure attribute) is registered as a layout-attribute descriptor, this path would be exploitable by a normal authenticated user on their own layout.

Path 3 — PortletSpELServiceImpl / SqlQueryPortletController (narrower)

  • Sink: uPortal-portlets/.../sqlquery/SqlQueryPortletController.java reads a sql portlet preference, passes it through evaluateSpelExpression(sqlQuery, request) (L163-166) → PortletSpELServiceImpl.getValue(...) (L78-82). That context is a StandardEvaluationContext with setBeanResolver(...) (L125-127), so both T(...) and @beanName are available. The result is then used in JdbcTemplate.query(spelSqlQuery, ...) (L134) → potential SpEL RCE plus SQL execution.
  • Why this looks narrower in practice: SqlQuery is an admin-style portlet (CONFIG mode in portlet.xml). The sql preference isn't declared with <read-only> (only cacheName is, L358-376), so the same generic PUT /preferences/SqlQuery entity-preferences write should accept it — but whether a non-admin can subscribe/render SqlQuery depends on portlet/channel permissions. I have not exercised this path; I'd defer to the maintainers on whether SqlQuery is intended to be admin-only and therefore essentially "trusted config".

What I'm asking

  1. Triage: Are any of these paths considered intended behavior (e.g. Path 3 because SqlQuery is admin-only by intent)? Are any considered worth hardening?
  2. Private channel: Since there's no SECURITY.md, would you like me to use a GitHub private security advisory on this repo (if you enable it), or an email to a maintainer, to share concrete reproduction steps without putting working payloads in this public thread?
  3. Suggested hardening direction (if you want one):
    • For Path 1 (evaluateImagePath): replace the StandardEvaluationContext with a SimpleEvaluationContext — the only template-side use seems to be ${portalContext}, which can be passed as a #portalContext variable. (SimpleEvaluationContext.forReadOnlyDataBinding().build() is enough if no method calls are needed; add .withInstanceMethods() only if necessary.)
    • For Path 2 (StylesheetAttributeSource): same direction — use SimpleEvaluationContext for attribute-value evaluation, and/or skip evaluation for values written via updateAttributes rather than declared as defaults. Alternatively, restrict the updateAttributes path so only attributes whose descriptor opts-in are writable through it.
    • For Path 3: if SqlQuery is meant to be admin-only, encoding that explicitly (e.g. @PreAuthorize or an explicit check in the controller) would make the trust boundary clear.

If a private channel is preferred I'm happy to move there immediately. Thanks for the project.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions