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.java — putEntityPreferences (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
- 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?
- 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?
- 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.
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 aStandardEvaluationContext— 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/securityPolicyUrlon the repository, which is why I'm filing here.Path 1 — Background Image Preference (most likely user-reachable)
uPortal-portlets/.../backgroundpreference/RoleBasedBackgroundSetSelectionStrategy.javaevaluateImagePath(...)callsportalSpELService.parseExpression(path, TemplateParserContext).getValue(evaluationContext, String.class)(around L181-187).evaluationContextis a bareStandardEvaluationContext(L85) with a simple root object; noBeanResolver, butT(...)/ constructors / method calls remain available by default.ViewBackgroundPreferenceController.getView(...)→imageSetSelectionStrategy.getImageSet(req)(L45) →getImageSetiterates thedefaultBackgroundImages/mobileBackgroundImagesportlet preference and runs each entry throughevaluateImagePath(L94-103).uPortal-api/uPortal-api-rest/.../rest/PortletPreferencesRESTController.java—putEntityPreferences(PUT /preferences/{fname}, L476-540) has noisGuest()/canConfigure()check (compare thedefinition-scope endpoint at L407 / L426, which does enforce these);storePreferences(L543-589) writes every supplied key without an allow-list.portlet.xml(thebackgroundPreferenceportlet, L998-1025) does not declare<read-only>true</read-only>on thedefaultBackgroundImages/mobileBackgroundImagespreferences.ViewBackgroundPreferenceController.savePreferences(@RequestParam backgroundImage)(L68-79) is not an injection point —setSelectedImagefirst checks membership against the already-evaluated image set (L153) and stores only into*SelectedBackgroundImage, whichgetSelectedImagedocuments as "no evaluation required" (L124-129). SobackgroundImagecannot reachevaluateImagePath. The reachability above is via the generic preferences REST PUT, notsavePreferences.Path 2 — Layout / Structure Attribute (also user-reachable)
uPortal-web/.../rendering/StylesheetAttributeSource.javagetAdditionalAttributes(...)(L71-115) iteratesgetLayoutAttributeDescriptors()for the current element, readsstylesheetUserPreferencesService.getLayoutAttribute(...)(L93-95); if the value passesstartsWith("${") && endsWith("}")(L147-152), it is parsed and evaluated as SpEL viaPortalSpELServiceImpl.getValue(expr, WebRequest, String.class)(L164-185 →PortalSpELServiceImplL128-130, sharedevaluationContext, noBeanResolver).StructureAttributeSource.java(L23-28) returnsPreferencesScope.STRUCTURE. There is a dedicated testStylesheetAttributeSourceForSpELVariablesTestthat asserts a user-preference layout-attribute value wrapped in${...}is evaluated (getAdditionalAttributesMethodShouldReturnSpELEvaluatedValueForUserPreferenceLayoutAttribute).UpdatePreferencesServlet.javaupdateAttributes(POST /layout?action=updateAttributes, L1075-1127).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 takesattributes.get("structureAttributes")(aMap<String,String>from the request body) and callssetLayoutAttribute(request, PreferencesScope.STRUCTURE, node.getId(), name, value)— bothnameandvalueflow through verbatim, no${filtering. TheSTRUCTUREscope matches the read side above.setLayoutAttributecall sites inUpdatePreferencesServlet, 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 genericupdateAttributespath therefore lets a user write a${...}value for a structure-scope attribute whose dedicated setter would have constrained it.StylesheetUserPreferencesServiceImpl.java, L847-904): the attributenamemust be a declaredILayoutAttributeDescriptor(L859-864, else ignored); guest persistence is read-only (L156). Value is stored as-is (L899-900).StructureAttributeSource.getAdditionalAttributes(...)at render.flexColumns(referenced byuPortal-webapp/.../layout/theme/respondr/content.xslas@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. IfflexColumns(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)uPortal-portlets/.../sqlquery/SqlQueryPortletController.javareads asqlportlet preference, passes it throughevaluateSpelExpression(sqlQuery, request)(L163-166) →PortletSpELServiceImpl.getValue(...)(L78-82). That context is aStandardEvaluationContextwithsetBeanResolver(...)(L125-127), so bothT(...)and@beanNameare available. The result is then used inJdbcTemplate.query(spelSqlQuery, ...)(L134) → potential SpEL RCE plus SQL execution.SqlQueryis an admin-style portlet (CONFIG mode inportlet.xml). Thesqlpreference isn't declared with<read-only>(onlycacheNameis, L358-376), so the same genericPUT /preferences/SqlQueryentity-preferences write should accept it — but whether a non-admin can subscribe/renderSqlQuerydepends on portlet/channel permissions. I have not exercised this path; I'd defer to the maintainers on whetherSqlQueryis intended to be admin-only and therefore essentially "trusted config".What I'm asking
SqlQueryis admin-only by intent)? Are any considered worth hardening?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?evaluateImagePath): replace theStandardEvaluationContextwith aSimpleEvaluationContext— the only template-side use seems to be${portalContext}, which can be passed as a#portalContextvariable. (SimpleEvaluationContext.forReadOnlyDataBinding().build()is enough if no method calls are needed; add.withInstanceMethods()only if necessary.)StylesheetAttributeSource): same direction — useSimpleEvaluationContextfor attribute-value evaluation, and/or skip evaluation for values written viaupdateAttributesrather than declared as defaults. Alternatively, restrict theupdateAttributespath so only attributes whose descriptor opts-in are writable through it.SqlQueryis meant to be admin-only, encoding that explicitly (e.g.@PreAuthorizeor 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.