diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000000..b746cea911d --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,268 @@ +name: Security Scan + +on: + push: + # Scan the default branch directly on merge; feature branches are covered + # by the pull_request trigger below, avoiding duplicate runs. + branches: + - develop + - main + pull_request: + branches: [ "**" ] + schedule: + # Re-scan every Monday at 06:00 UTC to catch newly published CVEs. + # Runs on the default branch; push/PR triggers cover all other branches. + - cron: '0 6 * * 1' + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to scan (optional — leave blank to scan the branch selected above)' + required: false + type: string + default: '' + +permissions: + contents: read + +jobs: + # ----------------------------------------------------------------------- + # Resolve the exact commit to scan. + # - Automatic triggers (push / PR / schedule): use the triggering SHA. + # - Manual (workflow_dispatch, no pr_number): use the branch selected in + # the "Run workflow" UI — GitHub checks out that branch automatically. + # - Manual (workflow_dispatch + pr_number): fetch the PR head SHA via API. + # ----------------------------------------------------------------------- + setup: + name: Resolve target ref + runs-on: ubuntu-latest + outputs: + ref: ${{ steps.resolve.outputs.ref }} + steps: + - name: Resolve checkout ref + id: resolve + run: | + PR="${{ inputs.pr_number }}" + if [ -n "$PR" ]; then + if ! [[ "$PR" =~ ^[0-9]+$ ]]; then + echo "::error::pr_number must be numeric, got: ${PR}" + exit 1 + fi + DATA=$(curl -sf \ + -H "Authorization: Bearer ${{ github.token }}" \ + "https://api.github.com/repos/${{ github.repository }}/pulls/${PR}") + STATE=$(echo "$DATA" | jq -r '.state') + if [ "$STATE" != "open" ]; then + echo "::error::PR #${PR} is not open (state: ${STATE})" + exit 1 + fi + HEAD_REF=$(echo "$DATA" | jq -r '.head.ref') + HEAD_SHA=$(echo "$DATA" | jq -r '.head.sha') + echo "Scanning PR #${PR}: branch=${HEAD_REF} sha=${HEAD_SHA}" + echo "ref=${HEAD_SHA}" >> $GITHUB_OUTPUT + else + echo "ref=${{ github.sha }}" >> $GITHUB_OUTPUT + fi + + # ----------------------------------------------------------------------- + # Job 1: OWASP Dependency-Check (Java / Maven artifacts) + # ----------------------------------------------------------------------- + owasp-maven: + name: OWASP Dependency-Check (Maven) + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: setup + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.setup.outputs.ref }} + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'temurin' + cache: maven + + # Cache the NVD data feed so successive runs skip the ~60s download + - name: Cache NVD data + uses: actions/cache@v4 + with: + path: ~/.m2/repository/org/owasp/dependency-check-data + key: nvd-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + nvd-${{ runner.os }}- + + - name: Run OWASP Dependency-Check + working-directory: amp + # Call the plugin goal directly to avoid triggering the full Maven + # lifecycle (which would run frontend-maven-plugin and fail in CI). + continue-on-error: true + id: owasp + run: | + mvn -B -ntp \ + org.owasp:dependency-check-maven:9.2.0:check \ + -P security \ + -DskipTests \ + -Dmaven.test.skip=true + + - name: Upload HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: owasp-dependency-check-report + path: amp/target/dependency-check-report/ + retention-days: 30 + + - name: Fail if OWASP found CVEs + if: steps.owasp.outcome == 'failure' + run: exit 1 + + # ----------------------------------------------------------------------- + # Job 2: npm audit — all frontend packages + # ----------------------------------------------------------------------- + npm-audit: + name: npm audit (Frontend) + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: setup + + strategy: + fail-fast: false + matrix: + package: + - path: amp/TEMPLATE/reamp + name: reamp + - path: amp/TEMPLATE/ampTemplate/amp-state + name: amp-state + - path: amp/TEMPLATE/ampTemplate/amp-boilerplate + name: amp-boilerplate + - path: amp/TEMPLATE/ampTemplate/amp-filter + name: amp-filter + - path: amp/TEMPLATE/ampTemplate/amp-translate + name: amp-translate + - path: amp/TEMPLATE/ampTemplate/amp-url + name: amp-url + - path: amp/TEMPLATE/ampTemplate/amp-settings + name: amp-settings + - path: amp/TEMPLATE/ampTemplate/gis-layers-manager + name: gis-layers-manager + - path: amp/TEMPLATE/ampTemplate/dashboard/dev + name: dashboard + - path: amp/TEMPLATE/ampTemplate/gisModule/dev + name: gisModule + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.setup.outputs.ref }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '16' + + - name: npm install (no scripts) + working-directory: ${{ matrix.package.path }} + run: npm install --ignore-scripts --prefer-offline 2>&1 || true + + - name: npm audit + id: audit + working-directory: ${{ matrix.package.path }} + # Continue so all packages are scanned; the summary step decides outcome + continue-on-error: true + run: | + npm audit \ + --audit-level high \ + --json \ + > /tmp/${{ matrix.package.name }}-audit.json 2>&1 + + - name: Print audit summary + if: always() + working-directory: ${{ matrix.package.path }} + run: | + FILE="/tmp/${{ matrix.package.name }}-audit.json" + if command -v jq &>/dev/null && [ -f "$FILE" ]; then + echo "### ${{ matrix.package.name }} vulnerability counts" + jq '{ + critical: (.metadata.vulnerabilities.critical // 0), + high: (.metadata.vulnerabilities.high // 0), + moderate: (.metadata.vulnerabilities.moderate // 0), + low: (.metadata.vulnerabilities.low // 0) + }' "$FILE" + fi + + - name: Upload audit JSON + if: always() + uses: actions/upload-artifact@v4 + with: + name: npm-audit-${{ matrix.package.name }} + path: /tmp/${{ matrix.package.name }}-audit.json + retention-days: 30 + + - name: Fail on HIGH or CRITICAL findings + working-directory: ${{ matrix.package.path }} + run: | + FILE="/tmp/${{ matrix.package.name }}-audit.json" + if [ ! -f "$FILE" ]; then exit 0; fi + HIGH=$(jq '.metadata.vulnerabilities.high // 0' "$FILE") + CRIT=$(jq '.metadata.vulnerabilities.critical // 0' "$FILE") + TOTAL=$(( HIGH + CRIT )) + if [ "$TOTAL" -gt 0 ]; then + echo "::error::${{ matrix.package.name }}: ${CRIT} critical + ${HIGH} high vulnerabilities found. Run 'npm audit' in ${{ matrix.package.path }} for details." + exit 1 + fi + + # ----------------------------------------------------------------------- + # Job 3: Trivy — container image CVE scan (runs on push to main / PRs) + # Catches OS-level CVEs that OWASP cannot see (e.g. base image packages) + # ----------------------------------------------------------------------- + trivy-image: + name: Trivy (Container Image) + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: setup + # Only scan the image on pushes to main/master or on PRs targeting them, + # to avoid redundant scans on every feature branch push. + if: | + github.event_name == 'pull_request' || + github.ref == 'refs/heads/main' || + github.ref == 'refs/heads/master' || + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.setup.outputs.ref }} + + - name: Build Docker image (no push) + working-directory: amp + run: | + docker build \ + --build-arg BUILD_SOURCE=security-scan \ + --build-arg AMP_URL=http://localhost/ \ + -t amp-security-scan:${{ github.sha }} \ + . || echo "::warning::Docker build failed; skipping Trivy image scan" + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: 'amp-security-scan:${{ github.sha }}' + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'HIGH,CRITICAL' + exit-code: '1' + ignore-unfixed: true + continue-on-error: true + + - name: Upload Trivy SARIF artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: trivy-sarif + path: trivy-results.sarif + retention-days: 30 diff --git a/amp/context.xml b/amp/context.xml index 72eb3a2099f..ac48b2a4f43 100644 --- a/amp/context.xml +++ b/amp/context.xml @@ -4,7 +4,11 @@ allowLinking="true" antiJARLocking="" antiResourceLocking="" clearReferencesStopTimerThreads="true" clearReferencesThreadLocals="true" jndiExceptionOnFailedWrite="false" processTlds="false"> - + + + amp https://artifactory.dgdev.org/artifactory/amp/ + + + false + + maven-central + Maven Central + https://repo.maven.apache.org/maven2/ + @@ -79,6 +87,14 @@ amp https://artifactory.dgdev.org/artifactory/amp/ + + + false + + maven-central + Maven Central + https://repo.maven.apache.org/maven2/ + @@ -185,6 +201,50 @@ false + + + + + + + security + + + + org.owasp + dependency-check-maven + 9.2.0 + + + 7 + + ${project.basedir}/../security/owasp-suppressions.xml + + + + ${project.basedir}/../ckeditor_4.4.6 + + + + + HTML + JSON + + + ${project.build.directory}/dependency-check-report + + + + verify + + check + + + + + + + @@ -2311,6 +2371,53 @@ + + + + + + + org.owasp + dependency-check-maven + 9.2.0 + + + 7 + + ${project.basedir}/../security/owasp-suppressions.xml + + + + ${project.basedir}/../ckeditor_4.4.6 + + + ${project.basedir}/../ckeditor + + + ${project.basedir}/../TEMPLATE + true + + **/node_modules/** + + + + + HTML + JSON + + ${project.build.directory}/dependency-check-report + + + + dependency-check-verify + + verify + + check + + + + diff --git a/amp/src/main/java/org/dgfoundation/amp/onepager/OnePagerApp.java b/amp/src/main/java/org/dgfoundation/amp/onepager/OnePagerApp.java index dbbb3902cf4..d418fba54d0 100644 --- a/amp/src/main/java/org/dgfoundation/amp/onepager/OnePagerApp.java +++ b/amp/src/main/java/org/dgfoundation/amp/onepager/OnePagerApp.java @@ -7,19 +7,22 @@ import org.apache.log4j.Logger; import org.apache.wicket.*; import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.attributes.AjaxCallListener; +import org.apache.wicket.ajax.attributes.AjaxRequestAttributes; import org.apache.wicket.authroles.authentication.AuthenticatedWebApplication; import org.apache.wicket.authroles.authentication.AuthenticatedWebSession; import org.apache.wicket.authroles.authorization.strategies.role.metadata.MetaDataRoleAuthorizationStrategy; +import org.apache.wicket.core.util.string.JavaScriptUtils; import org.apache.wicket.markup.head.*; import org.apache.wicket.markup.html.DecoratingHeaderResponse; import org.apache.wicket.markup.html.IHeaderResponseDecorator; import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.protocol.http.CsrfPreventionRequestCycleListener; import org.apache.wicket.protocol.http.servlet.ResponseIOException; import org.apache.wicket.protocol.http.servlet.ServletWebRequest; import org.apache.wicket.protocol.http.servlet.ServletWebResponse; import org.apache.wicket.request.Request; import org.apache.wicket.request.Response; -import org.apache.wicket.request.cycle.AbstractRequestCycleListener; import org.apache.wicket.request.cycle.RequestCycle; import org.apache.wicket.request.http.WebRequest; import org.apache.wicket.request.http.WebResponse; @@ -29,7 +32,9 @@ import org.dgfoundation.amp.onepager.web.pages.OnePager; import org.dgfoundation.amp.permissionmanager.web.pages.PermissionManager; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.web.csrf.CsrfToken; +import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.SocketException; @@ -164,23 +169,12 @@ public void init() { //set UTF-8 as the default encoding for all requests getRequestCycleSettings().setResponseRequestEncoding("UTF-8"); getMarkupSettings().setDefaultMarkupEncoding("UTF-8"); + addSpringCsrfTokenToWicketAjax(); + addWicketCsrfProtection(); -// getRequestCycleListeners().add(new AbstractRequestCycleListener() { -// @Override -// public void onBeginRequest(RequestCycle cycle) { -// WebResponse response = (WebResponse) cycle.getResponse(); -// response.setHeader("Content-Security-Policy", -// "default-src 'self'; " + -// "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + -// "style-src 'self' 'unsafe-inline'; " + -// "img-src 'self' data:; " + -// "frame-ancestors 'none'; " + -// "object-src 'none';"); -// response.setHeader("X-Frame-Options", "SAMEORIGIN"); -// } -// }); - - + // Browser hardening headers (X-Frame-Options, Content-Security-Policy, HSTS, etc.) + // are now applied globally by SecurityHeadersFilter registered in web.xml, so no + // per-cycle header injection is needed here. setHeaderResponseDecorator(new IHeaderResponseDecorator() { @Override @@ -206,6 +200,56 @@ public void render(HeaderItem item) { }); } + private void addSpringCsrfTokenToWicketAjax() { + getAjaxRequestTargetListeners().add(new AjaxRequestTarget.AbstractListener() { + @Override + public void updateAjaxAttributes(AjaxRequestAttributes attributes) { + CsrfToken token = getCurrentSpringCsrfToken(); + if (token == null) { + return; + } + + String parameterName = escapeJavaScript(token.getParameterName()); + String tokenValue = escapeJavaScript(token.getToken()); + attributes.getDynamicExtraParameters().add( + "var method = attrs && attrs.mp ? 'POST' : (attrs && attrs.m ? attrs.m : 'GET');" + + "if (method.toUpperCase() !== 'POST') { return {}; }" + + "return [{name:'" + parameterName + "', value:'" + tokenValue + "'}];"); + + AjaxCallListener listener = new AjaxCallListener(); + listener.onBeforeSend("if (jqXHR && jqXHR.setRequestHeader) { jqXHR.setRequestHeader('" + + escapeJavaScript(token.getHeaderName()) + "', '" + tokenValue + "'); }"); + attributes.getAjaxCallListeners().add(listener); + } + }); + } + + private CsrfToken getCurrentSpringCsrfToken() { + RequestCycle requestCycle = RequestCycle.get(); + if (requestCycle == null || !(requestCycle.getRequest() instanceof ServletWebRequest)) { + return null; + } + + HttpServletRequest request = ((ServletWebRequest) requestCycle.getRequest()).getContainerRequest(); + return (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + } + + private String escapeJavaScript(CharSequence value) { + return JavaScriptUtils.escapeQuotes(value).toString(); + } + + /** + * Register Wicket's own Origin/Referer-based CSRF protection for all Wicket + * action requests. Spring CSRF is exempted for /wicket/** in AmpWebCsrfRequestMatcher; + * this listener enforces same-origin policy at the Wicket layer instead. + */ + private void addWicketCsrfProtection() { + CsrfPreventionRequestCycleListener csrfListener = new CsrfPreventionRequestCycleListener(); + // Allow requests that have no Origin/Referer header (curl, server-side calls, old proxies) + csrfListener.setNoOriginAction(CsrfPreventionRequestCycleListener.CsrfAction.ALLOW); + getRequestCycleListeners().add(csrfListener); + } + @Override public final Session newSession(Request request, Response response) { diff --git a/amp/src/main/java/org/dgfoundation/amp/onepager/components/upload/FileUploadBehavior.java b/amp/src/main/java/org/dgfoundation/amp/onepager/components/upload/FileUploadBehavior.java index a343c4a9c63..32df5385f24 100644 --- a/amp/src/main/java/org/dgfoundation/amp/onepager/components/upload/FileUploadBehavior.java +++ b/amp/src/main/java/org/dgfoundation/amp/onepager/components/upload/FileUploadBehavior.java @@ -7,6 +7,7 @@ import org.apache.wicket.markup.head.OnLoadHeaderItem; import org.apache.wicket.model.AbstractReadOnlyModel; import org.apache.wicket.model.IModel; +import org.apache.wicket.protocol.http.servlet.ServletWebRequest; import org.apache.wicket.request.Url; import org.apache.wicket.request.cycle.RequestCycle; import org.apache.wicket.request.resource.JavaScriptResourceReference; @@ -17,7 +18,11 @@ import org.digijava.kernel.translator.TranslatorWorker; import org.digijava.module.aim.helper.GlobalSettingsConstants; import org.digijava.module.aim.util.FeaturesUtil; +import org.springframework.security.web.csrf.CsrfToken; +import javax.servlet.http.HttpServletRequest; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; import java.util.HashMap; import java.util.Map; @@ -71,9 +76,10 @@ public void renderHead(final Component component, IHeaderResponse response) { String maxFileSizeGS = FeaturesUtil.getGlobalSettingValue(GlobalSettingsConstants.CR_MAX_FILE_SIZE); - final Map variables = new HashMap(); + final Map variables = new HashMap(); variables.put("componentMarkupId", markupId); - uploadUrl +="?activityId=" + activityId; + uploadUrl = appendQueryParameter(uploadUrl, "activityId", activityId); + uploadUrl = appendSpringCsrfToken(uploadUrl); variables.put("url", uploadUrl); variables.put("paramName", PARAM_NAME); variables.put("uploadFailedMsg", TranslatorUtil.getTranslatedText("Upload failed! Please try again.")); @@ -82,8 +88,9 @@ public void renderHead(final Component component, IHeaderResponse response) { variables.put("uploadMaxFileSize", Long.toString(Bytes.megabytes(Long.parseLong(maxFileSizeGS)).bytes())); variables.put("uploadNoFileLabel", TranslatorWorker.translateText("No file chosen")); - IModel variablesModel = new AbstractReadOnlyModel() { - public Map getObject() { + IModel> variablesModel = new AbstractReadOnlyModel>() { + @Override + public Map getObject() { return variables; } }; @@ -91,4 +98,32 @@ public Map getObject() { new TextTemplateResourceReference(FileUploadBehavior.class, "FileUploadBehavior.js", variablesModel), String.valueOf(System.currentTimeMillis()), true)); response.render(OnLoadHeaderItem.forScript("setupFileUpload('#" + markupId + "', '" + uploadUrl + "', '" + PARAM_NAME + "');")); } + + static String appendSpringCsrfToken(String url) { + RequestCycle requestCycle = RequestCycle.get(); + if (requestCycle == null || !(requestCycle.getRequest() instanceof ServletWebRequest)) { + return url; + } + + HttpServletRequest request = ((ServletWebRequest) requestCycle.getRequest()).getContainerRequest(); + CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (token == null) { + return url; + } + + return appendQueryParameter(url, token.getParameterName(), token.getToken()); + } + + private static String appendQueryParameter(String url, String name, String value) { + String separator = url.contains("?") ? "&" : "?"; + return url + separator + urlEncode(name) + "=" + urlEncode(value); + } + + private static String urlEncode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } } diff --git a/amp/src/main/java/org/dgfoundation/amp/onepager/components/upload/ImageUploadValidationBehavior.java b/amp/src/main/java/org/dgfoundation/amp/onepager/components/upload/ImageUploadValidationBehavior.java index 01e3987f0e5..789a27c1d9c 100644 --- a/amp/src/main/java/org/dgfoundation/amp/onepager/components/upload/ImageUploadValidationBehavior.java +++ b/amp/src/main/java/org/dgfoundation/amp/onepager/components/upload/ImageUploadValidationBehavior.java @@ -7,7 +7,6 @@ import org.apache.wicket.markup.head.JavaScriptHeaderItem; import org.apache.wicket.markup.head.OnDomReadyHeaderItem; import org.apache.wicket.model.IModel; -import org.apache.wicket.model.Model; import org.apache.wicket.request.Url; import org.apache.wicket.request.cycle.RequestCycle; import org.apache.wicket.request.resource.JavaScriptResourceReference; @@ -54,6 +53,7 @@ public void renderHead(Component component, IHeaderResponse response) { String uploadUrl = RequestCycle.get().getUrlRenderer().renderFullUrl( Url.parse(component.urlFor(new FileUploadResourceReference(activityId, fileItemModel), null).toString())); + uploadUrl = FileUploadBehavior.appendSpringCsrfToken(uploadUrl); String markupId = component.getMarkupId(); // response.render(JavaScriptHeaderItem.forReference( diff --git a/amp/src/main/java/org/dgfoundation/amp/onepager/web/pages/AmpHeaderFooter.java b/amp/src/main/java/org/dgfoundation/amp/onepager/web/pages/AmpHeaderFooter.java index 7f901acb917..f196fbf1767 100644 --- a/amp/src/main/java/org/dgfoundation/amp/onepager/web/pages/AmpHeaderFooter.java +++ b/amp/src/main/java/org/dgfoundation/amp/onepager/web/pages/AmpHeaderFooter.java @@ -86,6 +86,7 @@ protected void setHeaders(WebResponse response) { public void renderHead(IHeaderResponse response) { super.renderHead(response); YuiLib.load(response); + response.render(JavaScriptHeaderItem.forUrl("/static/aim/view/scripts/common.js?csrf=1")); response.render(JavaScriptHeaderItem.forUrl("/ckeditor_4.4.6/ckeditor.js")); response.render(JavaScriptHeaderItem.forUrl("/TEMPLATE/ampTemplate/js_2/opentip/opentip-jquery2-4-6.js")); response.render(JavaScriptHeaderItem.forReference(new PackageResourceReference(AmpStructuresFormSectionFeature.class, "gisPopup.js"))); diff --git a/amp/src/main/java/org/dgfoundation/amp/permissionmanager/web/pages/AmpPMHeaderFooter.java b/amp/src/main/java/org/dgfoundation/amp/permissionmanager/web/pages/AmpPMHeaderFooter.java index a13ddacc472..adeb3f97fa8 100644 --- a/amp/src/main/java/org/dgfoundation/amp/permissionmanager/web/pages/AmpPMHeaderFooter.java +++ b/amp/src/main/java/org/dgfoundation/amp/permissionmanager/web/pages/AmpPMHeaderFooter.java @@ -90,6 +90,7 @@ protected void setHeaders(WebResponse response) { @Override public void renderHead(IHeaderResponse response) { super.renderHead(response); + response.render(JavaScriptHeaderItem.forUrl("/static/aim/view/scripts/common.js?csrf=1")); response.render(JavaScriptHeaderItem.forUrl("/ckeditor/ckeditor.js")); response.render(JavaScriptHeaderItem.forUrl("/TEMPLATE/ampTemplate/js_2/opentip/opentip-jquery2-4-6.js")); response.render(JavaScriptHeaderItem.forReference(new PackageResourceReference(AmpStructuresFormSectionFeature.class, "gisPopup.js"))); diff --git a/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/resource/ResourceService.java b/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/resource/ResourceService.java index ce0a84dca30..091cb98505f 100644 --- a/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/resource/ResourceService.java +++ b/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/resource/ResourceService.java @@ -25,6 +25,7 @@ import javax.jcr.query.Query; import javax.jcr.query.QueryManager; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -140,7 +141,13 @@ public List getAllResources(List uuids) { List resources = new ArrayList<>(); for (String uuid : uuids) { - resources.add(getResource(uuid)); + Node readNode = DocumentManagerUtil.getReadNode(uuid, TLSUtils.getRequest()); + if (readNode == null) { + resources.add(new JsonApiResponse( + ApiError.format(Collections.singletonList(ResourceErrors.RESOURCE_NOT_FOUND)))); + } else { + resources.add(getResource(uuid)); + } } return resources; diff --git a/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/ActionAuthorizer.java b/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/ActionAuthorizer.java index 3c636567df4..23624380823 100644 --- a/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/ActionAuthorizer.java +++ b/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/ActionAuthorizer.java @@ -39,6 +39,18 @@ public class ActionAuthorizer { .addRuleDependency(AuthRule.VIEW_ACTIVITY, AuthRule.IN_WORKSPACE) .build(); + /** + * Returns true if the given ApiMethod requires an authenticated user session. + * Used by request filters to decide whether CSRF protection is warranted. + */ + public static boolean requiresAuthentication(ApiMethod apiMethod) { + if (apiMethod.authTypes().length == 0) { + return false; + } + Collection authRules = ruleHierarchy.getEffectiveRules(apiMethod.authTypes()); + return authRules.contains(AuthRule.AUTHENTICATED); + } + /** * Main process to give authorization to call current method based on its authorization rules * @param method the method to authorize diff --git a/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/ApiAuthentication.java b/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/ApiAuthentication.java index aa263297899..0ed4b0bbcc3 100644 --- a/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/ApiAuthentication.java +++ b/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/ApiAuthentication.java @@ -27,7 +27,7 @@ */ public final class ApiAuthentication { - protected static Logger logger = Logger.getLogger(ApiAuthentication.class); + private static final Logger logger = Logger.getLogger(ApiAuthentication.class); public static ApiErrorMessage login(final User currentUser, final HttpServletRequest request) { ApiErrorMessage errorMessage = performSecurityChecks(currentUser, request); diff --git a/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/AuthorizerResourceFilterFactory.java b/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/AuthorizerResourceFilterFactory.java index 203d82769fc..e55fab92928 100644 --- a/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/AuthorizerResourceFilterFactory.java +++ b/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/AuthorizerResourceFilterFactory.java @@ -7,12 +7,22 @@ import javax.ws.rs.container.ContainerRequestFilter; import javax.ws.rs.container.ResourceInfo; import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; import java.io.IOException; import java.lang.reflect.Method; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; public class AuthorizerResourceFilterFactory implements ContainerRequestFilter { + private static final Set CSRF_SAFE_METHODS = + new HashSet<>(Arrays.asList("GET", "HEAD", "OPTIONS", "TRACE")); + @Context private ResourceInfo resourceInfo; @@ -21,7 +31,105 @@ public void filter(ContainerRequestContext requestContext) throws IOException { Method method = resourceInfo.getResourceMethod(); ApiMethod apiMethod = method.getAnnotation(ApiMethod.class); if (apiMethod != null) { + // Enforce CSRF same-origin check for authenticated endpoints. + // Public/unauthenticated endpoints (authTypes empty or absent) are exempt + // because there is no privileged session to hijack. + if (ActionAuthorizer.requiresAuthentication(apiMethod) && !isSameOrigin(requestContext)) { + requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) + .entity("CSRF check failed: cross-origin request rejected").build()); + return; + } ActionAuthorizer.authorize(method, apiMethod, (ContainerRequest) requestContext.getRequest()); } } -} \ No newline at end of file + + /** + * Returns true when the request is same-origin or comes from a non-browser client. + * Blocks only when {@code Origin} or {@code Referer} is present but does not match + * the server host — the signature of a cross-site browser request. + */ + private boolean isSameOrigin(ContainerRequestContext requestContext) { + String httpMethod = requestContext.getMethod(); + if (httpMethod == null || CSRF_SAFE_METHODS.contains(httpMethod.toUpperCase(Locale.ROOT))) { + return true; + } + + String origin = requestContext.getHeaderString("Origin"); + String referer = requestContext.getHeaderString("Referer"); + + // No Origin and no Referer: non-browser client (curl, server-to-server) — allow + if ((origin == null || origin.isEmpty()) && (referer == null || referer.isEmpty())) { + return true; + } + + String expectedHost = getServerHost(requestContext); + + if (origin != null && !origin.isEmpty()) { + // "null" Origin comes from privacy-sensitive contexts (file://, data:) — allow + if ("null".equals(origin)) { + return true; + } + return hostMatches(origin, expectedHost); + } + + if (referer != null && !referer.isEmpty()) { + return hostMatches(referer, expectedHost); + } + + return true; + } + + /** + * Resolves the expected server host from the request, preferring the + * {@code X-Forwarded-Host} and {@code Host} headers so the check works + * correctly behind reverse proxies. + */ + private String getServerHost(ContainerRequestContext requestContext) { + String forwarded = requestContext.getHeaderString("X-Forwarded-Host"); + if (forwarded != null && !forwarded.isEmpty()) { + String first = forwarded.split(",")[0].trim().toLowerCase(Locale.ROOT); + if (!first.isEmpty()) { + return first; + } + } + + String host = requestContext.getHeaderString("Host"); + if (host != null && !host.isEmpty()) { + return host.trim().toLowerCase(Locale.ROOT); + } + + // Fallback: derive from the JAX-RS request URI (works for direct connections) + URI requestUri = requestContext.getUriInfo().getRequestUri(); + String scheme = requestUri.getScheme().toLowerCase(Locale.ROOT); + String uriHost = requestUri.getHost().toLowerCase(Locale.ROOT); + int port = requestUri.getPort(); + if (port == -1 + || ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443)) { + return uriHost; + } + return uriHost + ":" + port; + } + + private boolean hostMatches(String headerValue, String expectedHost) { + try { + URI uri = new URI(headerValue.trim()); + String scheme = uri.getScheme() != null ? uri.getScheme().toLowerCase(Locale.ROOT) : ""; + String uriHost = uri.getHost() != null ? uri.getHost().toLowerCase(Locale.ROOT) : ""; + int uriPort = uri.getPort(); + + String normalised; + if (uriPort == -1 + || ("http".equals(scheme) && uriPort == 80) + || ("https".equals(scheme) && uriPort == 443)) { + normalised = uriHost; + } else { + normalised = uriHost + ":" + uriPort; + } + return expectedHost.equals(normalised); + } catch (URISyntaxException e) { + // Malformed header — treat as non-matching for safety + return false; + } + } +} diff --git a/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/SecurityService.java b/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/SecurityService.java index d5483fda4c5..85c1af99356 100644 --- a/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/SecurityService.java +++ b/amp/src/main/java/org/digijava/kernel/ampapi/endpoints/security/SecurityService.java @@ -38,8 +38,11 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.stream.Collectors; import static javax.ws.rs.core.Response.Status.BAD_REQUEST; @@ -203,7 +206,11 @@ public UserSessionInformation authenticate(AuthenticationRequest authRequest) { } User user = UserUtils.getUserByEmailAddress(username); - if (user == null || !user.getPassword().equals(password)) { + String storedPassword = (user != null && user.getPassword() != null) ? user.getPassword() : ""; + boolean passwordMatches = MessageDigest.isEqual( + storedPassword.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8), + password.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8)); + if (user == null || !passwordMatches) { ApiErrorResponseService.reportForbiddenAccess(SecurityErrors.INVALID_USER_PASSWORD); } @@ -219,7 +226,7 @@ public UserSessionInformation authenticate(AuthenticationRequest authRequest) { ApiErrorResponseService.reportError(BAD_REQUEST, SecurityErrors.INVALID_TEAM); } - storeInSession(username, password, teamMember, user); + storeInSession(username, teamMember, user); String ampTeamName = (teamMember == null) ? null : teamMember.getAmpTeam().getName(); boolean isAdmin = user.isGlobalAdmin(); return SecurityService.getInstance().createUserSessionInformation(isAdmin, user, ampTeamName, true); @@ -240,11 +247,13 @@ private AmpTeamMember getAmpTeamMember(String username, Long workspaceId) { return teamMember; } - private void storeInSession(String username, String password, AmpTeamMember teamMember, User user) { - final UsernamePasswordAuthenticationToken authRequest = - new UsernamePasswordAuthenticationToken(username, password); - authRequest.setDetails(new WebAuthenticationDetails(TLSUtils.getRequest())); - SecurityContextHolder.getContext().setAuthentication(authRequest); + private void storeInSession(String username, AmpTeamMember teamMember, User user) { + // Do not pass credentials to the token — the submitted hash must not be + // stored in the Spring Security context or serialised into the session. + final UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(username, null); + authToken.setDetails(new WebAuthenticationDetails(TLSUtils.getRequest())); + SecurityContextHolder.getContext().setAuthentication(authToken); final HttpSession session = TLSUtils.getRequest().getSession(); PermissionUtil.putInScope(session, GatePermConst.ScopeKeys.CURRENT_MEMBER, teamMember); if (teamMember != null) { diff --git a/amp/src/main/java/org/digijava/kernel/security/csrf/AmpWebCsrfRequestMatcher.java b/amp/src/main/java/org/digijava/kernel/security/csrf/AmpWebCsrfRequestMatcher.java new file mode 100644 index 00000000000..d44f1e0e0a6 --- /dev/null +++ b/amp/src/main/java/org/digijava/kernel/security/csrf/AmpWebCsrfRequestMatcher.java @@ -0,0 +1,117 @@ +package org.digijava.kernel.security.csrf; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +public class AmpWebCsrfRequestMatcher implements RequestMatcher { + + private static final Set SAFE_METHODS = new HashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS")); + + /** + * Wicket paths are protected by Wicket's own CsrfPreventionRequestCycleListener + * (Origin/Referer header check) registered in OnePagerApp. Spring CSRF is not needed there. + */ + private final AntPathRequestMatcher wicketMatcher = new AntPathRequestMatcher("/wicket/**"); + + private final RequestMatcher readOnlyPostMatcher = new OrRequestMatcher( + new AntPathRequestMatcher("/search/search.do", "POST"), + new AntPathRequestMatcher("/aim/filterDesktopActivities.do", "POST"), + new AntPathRequestMatcher("/aim/searchDesktopActivities.do", "POST"), + new AntPathRequestMatcher("/aim/validateReportsFilterPicker.do", "POST"), + new AntPathRequestMatcher("/aim/export*.do", "POST"), + new AntPathRequestMatcher("/help/*Export.do", "POST"), + new AntPathRequestMatcher("/calendar/viewEvents.do", "POST"), + new AntPathRequestMatcher("/calendar/viewListEvents.do", "POST"), + new AntPathRequestMatcher("/calendar/viewMonthEvents.do", "POST"), + new AntPathRequestMatcher("/calendar/viewYearEvents.do", "POST")); + + @Override + public boolean matches(HttpServletRequest request) { + // Wicket paths handled by Wicket's CsrfPreventionRequestCycleListener + if (wicketMatcher.matches(request)) { + return false; + } + if (isPublicDocTabManagerStateChange(request)) { + return true; + } + if (isContentRepositoryDocumentDelete(request)) { + return true; + } + + String method = request.getMethod(); + if (method != null && SAFE_METHODS.contains(method.toUpperCase(Locale.ROOT))) { + return false; + } + + if (isReadOnlyDocFromTemplatePost(request)) { + return false; + } + if (isReadOnlyDocumentManagerPost(request)) { + return false; + } + + return !readOnlyPostMatcher.matches(request); + } + + private boolean isReadOnlyDocumentManagerPost(HttpServletRequest request) { + if (!"POST".equalsIgnoreCase(request.getMethod()) + || !"/contentrepository/documentManager.do".equals(getRequestPath(request)) + || isMultipart(request)) { + return false; + } + + return "true".equalsIgnoreCase(request.getParameter("ajaxDocumentList")); + } + + private boolean isMultipart(HttpServletRequest request) { + String contentType = request.getContentType(); + return contentType != null && contentType.toLowerCase(Locale.ROOT).startsWith("multipart/form-data"); + } + + private boolean isReadOnlyDocFromTemplatePost(HttpServletRequest request) { + if (!"POST".equalsIgnoreCase(request.getMethod()) + || !"/contentrepository/docFromTemplate.do".equals(getRequestPath(request))) { + return false; + } + + String action = request.getParameter("actType"); + return action == null || action.length() == 0 + || "loadTemplates".equalsIgnoreCase(action) + || "getTemplate".equalsIgnoreCase(action); + } + + private boolean isPublicDocTabManagerStateChange(HttpServletRequest request) { + if (!"/contentrepository/publicDocTabManager.do".equals(getRequestPath(request))) { + return false; + } + + String action = request.getParameter("action"); + return "save".equalsIgnoreCase(action) + || "savePositions".equalsIgnoreCase(action) + || "delete".equalsIgnoreCase(action); + } + + private boolean isContentRepositoryDocumentDelete(HttpServletRequest request) { + return "/contentrepository/deleteForDocumentManager.do".equals(getRequestPath(request)); + } + + private String getRequestPath(HttpServletRequest request) { + String path = request.getServletPath(); + if (path == null || path.length() == 0) { + path = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && contextPath.length() > 0 && path.startsWith(contextPath)) { + path = path.substring(contextPath.length()); + } + } + return path; + } +} \ No newline at end of file diff --git a/amp/src/main/java/org/digijava/kernel/taglib/html/FormTag.java b/amp/src/main/java/org/digijava/kernel/taglib/html/FormTag.java index a5003eca476..0037fb08052 100644 --- a/amp/src/main/java/org/digijava/kernel/taglib/html/FormTag.java +++ b/amp/src/main/java/org/digijava/kernel/taglib/html/FormTag.java @@ -22,6 +22,7 @@ package org.digijava.kernel.taglib.html; +import org.apache.commons.lang.StringEscapeUtils; import org.apache.struts.taglib.TagUtils; import org.apache.struts.taglib.html.Constants; import org.digijava.kernel.request.Site; @@ -31,7 +32,10 @@ import org.digijava.kernel.util.SiteCache; import org.digijava.kernel.util.SiteConfigUtils; import org.digijava.kernel.util.SiteUtils; +import org.springframework.security.web.csrf.CsrfToken; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.jsp.JspException; @@ -161,6 +165,7 @@ public int doStartTag() throws JspException { results.append(this.renderFormStart()); results.append(renderToken()); + results.append(renderSpringCsrfToken(request)); TagUtils.getInstance().write(pageContext, results.toString()); @@ -243,7 +248,7 @@ protected String renderFormStart() throws JspException { results.append(" method=\""); results.append(method == null ? "post" : method); results.append("\" action=\""); - results.append( response.encodeURL(context.toString()) ); + results.append(response.encodeURL(appendSpringCsrfTokenToMultipartAction(context.toString(), request))); results.append("\""); @@ -286,6 +291,60 @@ protected String renderFormStart() throws JspException { return results.toString(); } + protected String renderSpringCsrfToken(HttpServletRequest request) { + if (isSafeMethod()) { + return ""; + } + + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrfToken == null) { + return ""; + } + + return ""; + } + + private String appendSpringCsrfTokenToMultipartAction(String actionUrl, HttpServletRequest request) { + if (isSafeMethod() || !isMultipartForm()) { + return actionUrl; + } + + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrfToken == null || hasQueryParameter(actionUrl, csrfToken.getParameterName())) { + return actionUrl; + } + + String separator = actionUrl.indexOf('?') >= 0 ? "&" : "?"; + return actionUrl + separator + urlEncode(csrfToken.getParameterName()) + "=" + urlEncode(csrfToken.getToken()); + } + + private boolean isMultipartForm() { + return enctype != null && "multipart/form-data".equalsIgnoreCase(enctype); + } + + private boolean hasQueryParameter(String url, String parameterName) { + return url.contains("?" + parameterName + "=") || url.contains("&" + parameterName + "="); + } + + private String urlEncode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + + private boolean isSafeMethod() { + return "get".equalsIgnoreCase(method) + || "head".equalsIgnoreCase(method) + || "options".equalsIgnoreCase(method) + || "trace".equalsIgnoreCase(method); + } + /** * Release any acquired resources. */ diff --git a/amp/src/main/java/org/digijava/kernel/web/SecurityHeadersFilter.java b/amp/src/main/java/org/digijava/kernel/web/SecurityHeadersFilter.java new file mode 100644 index 00000000000..7cf8d036478 --- /dev/null +++ b/amp/src/main/java/org/digijava/kernel/web/SecurityHeadersFilter.java @@ -0,0 +1,103 @@ +package org.digijava.kernel.web; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * Servlet filter that adds browser-hardening HTTP security headers to every response. + * + *

Headers applied: + *

    + *
  • {@code X-Content-Type-Options: nosniff} – prevents MIME-type sniffing.
  • + *
  • {@code X-Frame-Options: SAMEORIGIN} – blocks cross-origin iframe embedding.
  • + *
  • {@code X-XSS-Protection: 0} – disables the legacy browser XSS Auditor (which can + * introduce vulnerabilities of its own; modern browsers ignore it in favour of CSP).
  • + *
  • {@code Referrer-Policy: strict-origin-when-cross-origin} – limits referrer + * leakage on cross-origin navigations.
  • + *
  • {@code Content-Security-Policy} – restricts resource origins to reduce XSS and + * data-injection attack surface. {@code 'unsafe-inline'} and {@code 'unsafe-eval'} + * are permitted for scripts and styles because the legacy Wicket/Struts UI embeds + * inline code extensively; tightening these directives requires a separate + * front-end refactor.
  • + *
  • {@code Strict-Transport-Security: max-age=31536000; includeSubDomains} – instructs + * browsers to use HTTPS exclusively for one year. Applied only when the current + * request itself arrived over TLS, so plain-HTTP local environments are not + * immediately broken. See the cookie policy note below.
  • + *
+ * + *

Cookie policy: the session cookie is declared {@code HttpOnly} and + * {@code Secure} through {@code } in {@code web.xml}. + * {@code HttpOnly} prevents client-side JavaScript from reading the session token. + * {@code Secure} ensures the cookie is only transmitted over HTTPS. + * + *

HTTPS requirement: the {@code Secure} cookie flag and HSTS together require + * every deployment to terminate TLS. If the application sits behind a TLS-terminating + * load-balancer or reverse-proxy, configure it to forward {@code X-Forwarded-Proto: + * https}; Tomcat must then be configured with a {@code RemoteIpValve} (or equivalent) + * so that {@link HttpServletRequest#isSecure()} returns {@code true} and + * {@link javax.servlet.http.HttpServletRequest#getScheme()} returns {@code "https"}. + * For local development over plain HTTP, remove the {@code true} entry + * from the {@code } in {@code web.xml}. + */ +public class SecurityHeadersFilter implements Filter { + + private static final String HSTS_VALUE = "max-age=31536000; includeSubDomains"; + + /** + * Content-Security-Policy directive applied to every response. + * + *

Directive notes: + *

    + *
  • {@code frame-ancestors 'self'} is the CSP equivalent of {@code X-Frame-Options: + * SAMEORIGIN} and takes precedence in browsers that support CSP Level 2.
  • + *
  • {@code img-src 'self' data: blob:} is required because several UI components + * embed images as data URIs or object URLs.
  • + *
  • {@code font-src 'self' data:} covers web-fonts bundled as data URIs.
  • + *
+ */ + private static final String CSP_VALUE = + "default-src 'self'; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: blob:; " + + "font-src 'self' data:; " + + "connect-src 'self'; " + + "frame-ancestors 'self'; " + + "object-src 'none';"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletResponse httpResponse = (HttpServletResponse) response; + + httpResponse.setHeader("X-Content-Type-Options", "nosniff"); + httpResponse.setHeader("X-Frame-Options", "SAMEORIGIN"); + httpResponse.setHeader("X-XSS-Protection", "0"); + httpResponse.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + httpResponse.setHeader("Content-Security-Policy", CSP_VALUE); + + // Only add HSTS when the connection is already secure; sending it over plain + // HTTP would lock out users who cannot reach the HTTPS endpoint yet. + if (((HttpServletRequest) request).isSecure()) { + httpResponse.setHeader("Strict-Transport-Security", HSTS_VALUE); + } + + chain.doFilter(request, response); + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void destroy() { + } +} diff --git a/amp/src/main/webapp/WEB-INF/applicationContext.xml b/amp/src/main/webapp/WEB-INF/applicationContext.xml index 451b5dc129d..ef47db8396f 100644 --- a/amp/src/main/webapp/WEB-INF/applicationContext.xml +++ b/amp/src/main/webapp/WEB-INF/applicationContext.xml @@ -18,6 +18,8 @@ + + @@ -182,7 +184,7 @@ - + @@ -201,22 +203,22 @@ - + - + - + - + @@ -250,7 +252,7 @@ - + diff --git a/amp/src/main/webapp/WEB-INF/jsp/aim/view/autologin.jsp b/amp/src/main/webapp/WEB-INF/jsp/aim/view/autologin.jsp index 12b59038d4f..ee0b67888cd 100644 --- a/amp/src/main/webapp/WEB-INF/jsp/aim/view/autologin.jsp +++ b/amp/src/main/webapp/WEB-INF/jsp/aim/view/autologin.jsp @@ -12,6 +12,7 @@ function delayer(){
+ "/> " /> diff --git a/amp/src/main/webapp/WEB-INF/jsp/aim/view/dynLocationManager/dynLocationManager.jsp b/amp/src/main/webapp/WEB-INF/jsp/aim/view/dynLocationManager/dynLocationManager.jsp index e9cfd4b077a..14e86fa2e66 100644 --- a/amp/src/main/webapp/WEB-INF/jsp/aim/view/dynLocationManager/dynLocationManager.jsp +++ b/amp/src/main/webapp/WEB-INF/jsp/aim/view/dynLocationManager/dynLocationManager.jsp @@ -331,6 +331,7 @@
+ @@ -339,6 +340,7 @@
+ @@ -346,6 +348,7 @@
+
diff --git a/amp/src/main/webapp/WEB-INF/jsp/aim/view/luceneIndex.jsp b/amp/src/main/webapp/WEB-INF/jsp/aim/view/luceneIndex.jsp index 0809f35a2be..0420bd84264 100644 --- a/amp/src/main/webapp/WEB-INF/jsp/aim/view/luceneIndex.jsp +++ b/amp/src/main/webapp/WEB-INF/jsp/aim/view/luceneIndex.jsp @@ -71,6 +71,7 @@
+ Field: String: diff --git a/amp/src/main/webapp/WEB-INF/jsp/aim/view/scripts/common.js b/amp/src/main/webapp/WEB-INF/jsp/aim/view/scripts/common.js index 006916c4157..50103be6547 100644 --- a/amp/src/main/webapp/WEB-INF/jsp/aim/view/scripts/common.js +++ b/amp/src/main/webapp/WEB-INF/jsp/aim/view/scripts/common.js @@ -1,6 +1,178 @@ function unload() { } + (function () { + if (window.ampCsrfProtectionInstalled) { + return; + } + window.ampCsrfProtectionInstalled = true; + + var CSRF_COOKIE = "XSRF-TOKEN"; + var CSRF_HEADER = "X-XSRF-TOKEN"; + var CSRF_PARAMETER = "_csrf"; + + function getCookie(name) { + var namePrefix = name + "="; + var cookies = document.cookie ? document.cookie.split(";") : []; + for (var index = 0; index < cookies.length; index++) { + var cookie = cookies[index].replace(/^\s+|\s+$/g, ""); + if (cookie.indexOf(namePrefix) === 0) { + return decodeURIComponent(cookie.substring(namePrefix.length)); + } + } + return null; + } + + function getCsrfToken() { + return getCookie(CSRF_COOKIE); + } + + function isUnsafeMethod(method) { + var normalizedMethod = (method || "GET").toUpperCase(); + return normalizedMethod !== "GET" && normalizedMethod !== "HEAD" + && normalizedMethod !== "OPTIONS" && normalizedMethod !== "TRACE"; + } + + function isSameOrigin(url) { + if (!url || (url.charAt(0) === "/" && url.charAt(1) !== "/")) { + return true; + } + if (url.indexOf("http://") !== 0 && url.indexOf("https://") !== 0 && url.indexOf("//") !== 0) { + return true; + } + + var anchor = document.createElement("a"); + anchor.href = url; + return anchor.protocol === window.location.protocol && anchor.host === window.location.host; + } + + function addCsrfToForm(form) { + var token = getCsrfToken(); + if (!token || !form || !isUnsafeMethod(form.method) || !isSameOrigin(form.action)) { + return; + } + + var tokenInput = form.elements[CSRF_PARAMETER]; + if (!tokenInput) { + tokenInput = document.createElement("input"); + tokenInput.type = "hidden"; + tokenInput.name = CSRF_PARAMETER; + form.appendChild(tokenInput); + } + tokenInput.value = token; + } + + function setCsrfHeader(jqXHR) { + var token = getCsrfToken(); + if (token && jqXHR && jqXHR.setRequestHeader) { + try { + jqXHR.setRequestHeader(CSRF_HEADER, token); + } catch (ignored) { + } + } + } + + function installWicketCsrfProtection() { + if (window.ampWicketCsrfProtectionInstalled) { + return true; + } + if (!window.Wicket || !Wicket.Event || !Wicket.Event.subscribe || !Wicket.Event.Topic) { + return false; + } + + window.ampWicketCsrfProtectionInstalled = true; + Wicket.Event.subscribe(Wicket.Event.Topic.AJAX_CALL_BEFORE_SEND, function (event, attrs, jqXHR, settings) { + var method = settings && settings.type ? settings.type : (attrs && attrs.mp ? "POST" : attrs && attrs.m); + var url = settings && settings.url ? settings.url : attrs && attrs.u; + if (!isUnsafeMethod(method) || !isSameOrigin(url)) { + return; + } + + setCsrfHeader(jqXHR); + if (attrs && attrs.mp && attrs.f) { + addCsrfToForm(Wicket.$ ? Wicket.$(attrs.f) : document.getElementById(attrs.f)); + } + }); + return true; + } + + if (!installWicketCsrfProtection()) { + var wicketInstallRetries = 40; + var wicketInstallTimer = window.setInterval(function () { + wicketInstallRetries--; + if (installWicketCsrfProtection() || wicketInstallRetries <= 0) { + window.clearInterval(wicketInstallTimer); + } + }, 50); + } + + if (document.addEventListener) { + document.addEventListener("submit", function (event) { + addCsrfToForm(event.target); + }, true); + } else if (document.attachEvent) { + document.attachEvent("onsubmit", function () { + addCsrfToForm(window.event.srcElement); + }); + } + + if (window.HTMLFormElement && window.HTMLFormElement.prototype && window.HTMLFormElement.prototype.submit) { + var originalSubmit = window.HTMLFormElement.prototype.submit; + window.HTMLFormElement.prototype.submit = function () { + addCsrfToForm(this); + return originalSubmit.apply(this, arguments); + }; + } + + if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) { + var originalOpen = window.XMLHttpRequest.prototype.open; + var originalSend = window.XMLHttpRequest.prototype.send; + + window.XMLHttpRequest.prototype.open = function (method, url) { + this.ampCsrfMethod = method; + this.ampCsrfUrl = url; + return originalOpen.apply(this, arguments); + }; + + window.XMLHttpRequest.prototype.send = function () { + var token = getCsrfToken(); + if (token && isUnsafeMethod(this.ampCsrfMethod) && isSameOrigin(this.ampCsrfUrl)) { + try { + this.setRequestHeader(CSRF_HEADER, token); + } catch (ignored) { + } + } + return originalSend.apply(this, arguments); + }; + } + + if (window.fetch) { + var originalFetch = window.fetch; + window.fetch = function (input, init) { + var options = init || {}; + var method = options.method || (input && input.method) || "GET"; + var url = typeof input === "string" ? input : (input && input.url); + var token = getCsrfToken(); + + if (token && isUnsafeMethod(method) && isSameOrigin(url)) { + options = init || {}; + if (window.Headers && options.headers instanceof window.Headers) { + options.headers = new window.Headers(options.headers); + options.headers.set(CSRF_HEADER, token); + } else if (Object.prototype.toString.call(options.headers) === "[object Array]") { + options.headers.push([CSRF_HEADER, token]); + } else { + options.headers = options.headers || {}; + options.headers[CSRF_HEADER] = token; + } + return originalFetch.call(this, input, options); + } + + return originalFetch.apply(this, arguments); + }; + } + }()); + if (!Array.prototype.indexOf) { Array.prototype.indexOf = function (searchElement /*, fromIndex */ ) { diff --git a/amp/src/main/webapp/WEB-INF/jsp/common/csrfInput.jsp b/amp/src/main/webapp/WEB-INF/jsp/common/csrfInput.jsp new file mode 100644 index 00000000000..de713c991c3 --- /dev/null +++ b/amp/src/main/webapp/WEB-INF/jsp/common/csrfInput.jsp @@ -0,0 +1,10 @@ +<%@ page import="org.apache.commons.lang.StringEscapeUtils" %> +<%@ page import="org.springframework.security.web.csrf.CsrfToken" %> +<% +CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); +if (csrfToken != null) { +%> + +<% +} +%> \ No newline at end of file diff --git a/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/doctabmanager/docTabManager.jsp b/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/doctabmanager/docTabManager.jsp index 19f5307f5d0..4e450e8db0e 100644 --- a/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/doctabmanager/docTabManager.jsp +++ b/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/doctabmanager/docTabManager.jsp @@ -133,6 +133,11 @@ var trnObj = { function retrieveFilterData(filterId) { YAHOO.util.Connect.asyncRequest('GET', '/contentrepository/publicDocTabManager.do?time='+ new Date().getTime()+'&action=jsonfilter&filterId='+filterId, new RetrieveFilters(publicListObj) ); } + + function deleteFilter(filterId) { + document.forms["crDocTabManagerForm"].action = "/contentrepository/publicDocTabManager.do?action=delete&filterId=" + filterId; + document.forms["crDocTabManagerForm"].submit(); + } YAHOO.util.Event.on(window, "load", afterPageLoad); @@ -253,7 +258,7 @@ var trnObj = { ${filter.name} [Delete] + href="#" onclick="deleteFilter(${filter.id}); return false;">Delete] diff --git a/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/documentManagerJsHelper.jsp b/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/documentManagerJsHelper.jsp index a8e2ce488dd..8a8f4c7d667 100644 --- a/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/documentManagerJsHelper.jsp +++ b/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/documentManagerJsHelper.jsp @@ -7,6 +7,8 @@ <%@ taglib uri="http://digijava.org/features" prefix="feature" %> <%@ taglib uri="http://digijava.org/modules" prefix="module" %> + + @@ -680,7 +682,7 @@ function deleteRow(uuid,o) { //YAHOO.amp.panels[2].setFooter("
"); //showPanel(2); //YAHOO.amp.table.dataTable.deleteRow(possibleRow); - YAHOO.util.Connect.asyncRequest('GET', '/contentrepository/deleteForDocumentManager.do?uuid='+uuid, getCallbackForDelete(possibleRows,o)); + YAHOO.util.Connect.asyncRequest('POST', '/contentrepository/deleteForDocumentManager.do?uuid='+uuid, getCallbackForDelete(possibleRows,o)); } } diff --git a/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/labelmanager/labelManager.jsp b/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/labelmanager/labelManager.jsp index becd717961c..08198990101 100644 --- a/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/labelmanager/labelManager.jsp +++ b/amp/src/main/webapp/WEB-INF/jsp/contentrepository/view/labelmanager/labelManager.jsp @@ -258,6 +258,7 @@