Compare commits

..
7 Commits
Author SHA1 Message Date
semantic-release-bot c69e5a80b2 chore(release): 7.14.2 [skip ci]
## [7.14.2](https://git.datacontroller.io/dc/dc/compare/v7.14.1...v7.14.2) (2026-09-22)

### Bug Fixes

* **release:** put frontend files at the root of frontend.zip ([7771a24](7771a24b9c)), closes [#147](#147)
* **security:** accept the format-catalog form when validating a libds ([0fa5da8](0fa5da8abf))
* **security:** escape col-info dropdown and origin-check VA replay ([c9eed6d](c9eed6dae7))
* validate request inputs and add admin gates to public services ([be86000](be86000fe0))
2026-09-22 16:17:08 +00:00
allan 86aa1a05e9 Merge pull request 'fix(security): escape col-info dropdown and origin-check VA replay' (#321) from fix/security-hardening-frontend-and-sas into main
Release / Build-production-and-ng-test (push) Successful in 4m38s
Release / Build-and-test-development (push) Successful in 23m51s
Release / release (push) Successful in 8m46s
Reviewed-on: #321
2026-09-22 15:44:52 +00:00
dc 0fa5da8abf fix(security): accept the format-catalog form when validating a libds
Build / Build-and-ng-test (pull_request) Successful in 5m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m53s
Build / Build-and-test-development (pull_request) Successful in 25m7s
mpe_accesscheck and validatefilter validated their libds input with
mp_validatecol(LIBDS), which rejects the LIBREF.CATALOGNAME-FC form that
Data Controller uses to address a format catalog.  A format-catalog load
or filter therefore aborted with "Invalid base_table" / "Invalid
filter_table" - stagedata, getdata and postdata all reach mpe_accesscheck
through the edit/approve path.

Add mpe_validatecol, a wrapper that permits the catalog form: the -FC
suffix is matched exactly and the remainder is validated as a strict
LIBREF.DATASET, so the whole value is covered and a caller that needs the
catalog reference downstream (MPE_SECURITY stores it with the suffix)
still receives it.  getrawdata and getcolvals had grown an inline version
of this check that scanned on the dash and validated only the prefix,
leaving whatever followed it unvalidated; the wrapper replaces both.

mpe_validatecol.test.sas asserts the matrix - plain libds, catalog form,
and payloads that smuggle content past a valid libds prefix.
2026-09-22 11:23:37 +00:00
dc 7771a24b9c fix(release): put frontend files at the root of frontend.zip
Build / Build-and-ng-test (pull_request) Successful in 5m59s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m30s
Build / Build-and-test-development (pull_request) Successful in 25m5s
The release job zipped ./client/dist, so every archive entry carried a
client/dist/ prefix and an unzip dropped the app into a client/dist
subfolder. Zip from inside dist instead, so index.html, assets and the
bundled viya.json sit at the root of the archive.

Closes #147
2026-09-22 09:10:43 +00:00
dc 4f43221819 chore: rename test log marker, declare all called macros in headers
Build / Build-and-ng-test (pull_request) Successful in 6m3s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m59s
Build / Build-and-test-development (pull_request) Successful in 25m32s
- the test result putlog marker is TEST_RESULT_LINE, not a review-session
  specific name
- declare mf_getuser.sas in refreshlibs, refreshcatalog, getdiffs and
  dirlist (called by the new admin gates; was only resolved transitively
  through mpe_getgroups) and mp_abort.sas in validatefilter
2026-09-21 19:24:20 +00:00
dc be86000fe0 fix: validate request inputs and add admin gates to public services
Build / Build-and-ng-test (pull_request) Successful in 5m17s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m6s
Build / Build-and-test-development (pull_request) Successful in 25m40s
Security fixes for the input-validation gaps in the public download and
metadata services, plus missing in-code admin gates:

- mpe_accesscheck: validate base_table (LIBDS) and access_level before
  they reach the authorisation query, and escape embedded quotes in the
  SQL literals (defence in depth for direct macro callers)
- getrawdata: validate table (LIBDS / format-catalog form), filter
  (integer) and type before they are used; read all inputs with symget
  in a data step so macro content cannot execute at a resolution boundary
- getdiffs: validate libds, table and stp_diffs_csv before the access
  check and the staging-file stream path
- getcols, getcolvals, validatefilter: read the IWANT inputs with symget
  in a data step and validate (LIBDS / SAS name) before use
- admin dirlist, refreshlibs, refreshcatalog, exportconfig: require
  membership of the DC administrators group (the admin folder prefix is
  not an access control)
- admin dirlist: read parent with symget and reject macro characters

Tests (all proven RED on the vulnerable services first, then GREEN on
the fix): getrawdata.test.1, getdiffs.test, getcols.test,
getcolvals.test.4, validatefilter.test.1, dirlist.test,
refreshcatalog.test.1
2026-09-21 19:04:35 +00:00
dc c9eed6dae7 fix(security): escape col-info dropdown and origin-check VA replay
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m10s
Build / Build-and-test-development (pull_request) Successful in 25m30s
buildColInfoHtml interpolated server/DB-controlled column labels, formats and
DQ RULE_VALUE (regex/formula) strings into HTML assigned to raw DOM
elem.innerHTML in both viewer.component.ts and editor.component.ts. A user who
can author a validation rule (or a column label) could store markup that runs
in the browser of any editor/approver who opens a column info dropdown - the
same class of stored XSS fixed for the status renderers in #319. Escape every
interpolated field via the same escapeHtml approach.

The pre-bootstrap VA listener (va-early.js) stores any-origin postMessage in
window.__vaLastMessage; replayEarlyMessages replayed it into the editor filter
without the live path's isTrustedSource check. Add isTrustedEarlyOrigin so the
replay only accepts a message from this origin or the embedding frame's origin
(document.referrer), mirroring the live handler.

Regression tests: col-info-html.spec.ts proves the injected element does not
survive (fails on the old impl, 5of7 rando-fail -> all pass), preserving the
10 existing behaviour tests; full Angular suite 523/523 green; production
build (AOT) compiles clean.
2026-09-19 16:28:21 +00:00
25 changed files with 1374 additions and 32 deletions
+4 -2
View File
@@ -302,8 +302,10 @@ jobs:
run: |
cd sas
cp sasjsbuild/viya.json ../client/dist/viya.json
cd ..
zip -r frontend.zip ./client/dist
# Zip from *inside* dist so the archive holds the frontend files at its
# root rather than under a client/dist/ prefix (see #147).
cd ../client/dist
zip -r ../../frontend.zip .
- name: Release Typedoc
run: |
+10
View File
@@ -1,3 +1,13 @@
## [7.14.2](https://git.datacontroller.io/dc/dc/compare/v7.14.1...v7.14.2) (2026-09-22)
### Bug Fixes
* **release:** put frontend files at the root of frontend.zip ([7771a24](https://git.datacontroller.io/dc/dc/commit/7771a24b9ce55751253686885e4848a292817a38)), closes [#147](https://git.datacontroller.io/dc/dc/issues/147)
* **security:** accept the format-catalog form when validating a libds ([0fa5da8](https://git.datacontroller.io/dc/dc/commit/0fa5da8abfc303ce3b18beb4c01484dcce508439))
* **security:** escape col-info dropdown and origin-check VA replay ([c9eed6d](https://git.datacontroller.io/dc/dc/commit/c9eed6dae717414b874ef6f3d60312ea0bb5a0da))
* validate request inputs and add admin gates to public services ([be86000](https://git.datacontroller.io/dc/dc/commit/be86000fe0f0858edcbecf71d426a2ff81257247))
## [7.14.1](https://git.datacontroller.io/dc/dc/compare/v7.14.0...v7.14.1) (2026-09-17)
@@ -138,13 +138,38 @@ export class VaMessagingService {
this.earlyDrained = true
const captured = (window as unknown as { __vaLastMessage?: any })
.__vaLastMessage
const parsed = this.parseData(captured && captured.data)
// The early listener (va-early.js) captures from any origin, so re-apply
// the live-path trust rule here before acting on it: only replay a message
// that came from our own origin or from the frame that embedded us (whose
// URL is document.referrer). Without this, a same-origin sibling frame
// could inject a crafted DDC message that the live isTrustedSource check
// would have rejected.
if (!captured || typeof captured.origin !== 'string') return
if (!this.isTrustedEarlyOrigin(captured.origin)) return
const parsed = this.parseData(captured.data)
if (!parsed) return
this.resultName = parsed.resultName
if (captured.origin) this.parentOrigin = captured.origin
this.parentOrigin = captured.origin
callback(parsed)
}
/**
* Origin check for the pre-bootstrap replay. Mirrors isTrustedSource: the
* live path trusts a message whose event.source IS the parent frame; for a
* captured message we cannot reference its source Window, so we trust an
* origin that is this window's origin or the embedding frame's origin
* (document.referrer). Unverifiable/absent referrer -> reject.
*/
private isTrustedEarlyOrigin(origin: string): boolean {
if (origin === window.location.origin) return true
if (!document.referrer) return false
try {
return new URL(document.referrer).origin === origin
} catch {
return false
}
}
/**
* Parses a raw window MessageEvent into a VaMessage, or null when it is not a
* recognisable DDC message (e.g. unrelated postMessage traffic).
@@ -149,3 +149,75 @@ describe('buildColInfoHtml', () => {
)
})
})
/**
* DOM-injection reproduction for the column-info dropdown.
* buildColInfoHtml interpolates server/DB-controlled values (column label,
* format, and DQ RULE_VALUE regex/formula strings) into a string that the
* viewer/editor assign to raw DOM `elem.innerHTML` - so a value containing
* markup (e.g. a HARDREGEX RULE_VALUE of `<img src=x onerror=alert(1)>`) is
* parsed and executed in the browser of whoever opens the info dropdown.
* These tests fail on the vulnerable implementation and pass once each field
* is escaped.
*/
describe('buildColInfoHtml escapes rather than injecting raw HTML', () => {
const malicious = '<img src=x onerror=alert(1)>'
const info: DataFormat = {
format: malicious,
label: malicious,
length: '8',
type: 'N'
}
// Parse the returned string the same way the callers do (innerHTML on a
// real element) and assert no scriptable element survived.
const parseInto = (html: string): HTMLElement => {
const host = document.createElement('div')
host.innerHTML = html
return host
}
const assertNoInjectedElement = (html: string) => {
const host = parseInto(html)
expect(host.querySelector('img[onerror]')).toBeNull()
host.remove()
}
it('is inert for a colInfo whose label and format carry markup', () => {
assertNoInjectedElement(buildColInfoHtml('SOMECHAR', info))
})
it('escapes the column NAME', () => {
const html = buildColInfoHtml(malicious, {
format: '$8.',
label: 'safe',
length: '8',
type: 'C'
})
// < and > must not survive as markup in the NAME position
expect(html).not.toContain(malicious)
assertNoInjectedElement(html)
})
it('escapes a HARDREGEX RULE_VALUE', () => {
assertNoInjectedElement(
buildColInfoHtml('SOMECHAR', info, malicious, undefined, undefined)
)
})
it('escapes a SOFTREGEX RULE_VALUE', () => {
assertNoInjectedElement(
buildColInfoHtml('SOMECHAR', info, undefined, malicious, undefined)
)
})
it('escapes a formula RULE_VALUE (with and without a leading =)', () => {
assertNoInjectedElement(
buildColInfoHtml('SOMECHAR', info, undefined, undefined, malicious)
)
assertNoInjectedElement(
buildColInfoHtml('SOMECHAR', info, undefined, undefined, `=${malicious}`)
)
})
})
+30 -4
View File
@@ -1,9 +1,35 @@
import { DataFormat } from '../../models/sas/common/DateFormat'
/**
* Returns string-safe text of any value so it can be concatenated into a
* string that is later assigned to raw DOM innerHTML. The column metadata
* (label/format) and DQ RULE_VALUE strings (regex/formula) are DB-controlled -
* a validation-rule author can store markup such as
* `<img src=x onerror=...>` in a HARDREGEX value or a column label - so they
* must never be parsed as HTML by the browser. Escaping turns any embedded
* markup into inert text.
*/
const escapeHtml = (value: any): string =>
String(value ?? '').replace(/[&<>"']/g, (char) => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
return entities[char]
})
/**
* Builds the HTML shown in a column-header "info" dropdown item (viewer and
* editor). NAME is listed first so it's visible regardless of whether
* headers are currently displayed as NAME or LABEL.
*
* The returned string is assigned to raw DOM `elem.innerHTML` by both callers
* (viewer.component.ts / editor.component.ts) - every field interpolated below
* is therefore escaped via escapeHtml, since no Angular sanitizer runs on a
* raw innerHTML assignment.
*/
export function buildColInfoHtml(
colName: string,
@@ -14,16 +40,16 @@ export function buildColInfoHtml(
): string {
if (!colInfo) return 'No info found'
let html = `NAME: ${colName}<br>LABEL: ${colInfo.label}<br>TYPE: ${colInfo.type}<br>LENGTH: ${colInfo.length}<br>FORMAT: ${colInfo.format}`
let html = `NAME: ${escapeHtml(colName)}<br>LABEL: ${escapeHtml(colInfo.label)}<br>TYPE: ${escapeHtml(colInfo.type)}<br>LENGTH: ${escapeHtml(colInfo.length)}<br>FORMAT: ${escapeHtml(colInfo.format)}`
// Only ever one REGEX rule is applied per column: when both HARDREGEX
// and SOFTREGEX exist, SOFTREGEX is ignored entirely (same precedence as
// makeRegexWarningRenderer / DcValidator.failsSoftRegex). Show only the
// rule that is applied.
if (hardRegexValue) {
html += `<br>HARDREGEX: ${hardRegexValue}`
html += `<br>HARDREGEX: ${escapeHtml(hardRegexValue)}`
} else if (softRegexValue) {
html += `<br>SOFTREGEX: ${softRegexValue}`
html += `<br>SOFTREGEX: ${escapeHtml(softRegexValue)}`
}
// '√x=' stands in for a text label here - HARDFORMULA vs SOFTFORMULA is
@@ -36,7 +62,7 @@ export function buildColInfoHtml(
const formula = formulaValue.startsWith('=')
? formulaValue.slice(1)
: formulaValue
html += `<br>√x=${formula}`
html += `<br>√x=${escapeHtml(formula)}`
}
return html
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dcfrontend",
"version": "7.14.1",
"version": "7.14.2",
"description": "Data Controller",
"devDependencies": {
"@nogoo9/gitleaks": "8.30.1-post.2",
+45 -3
View File
@@ -23,7 +23,7 @@
@li mp_abort.sas
@li mf_getuniquename.sas
@li mf_getuser.sas
@li mf_verifymacvars.sas
@li mpe_validatecol.sas
@li mpe_getgroups.sas
<h4> Related Macros </h4>
@@ -52,10 +52,52 @@
,msg=%str(outds should be a WORK table)
)
/**
* Validate inputs before they reach executable code. base_table is
* interpolated into a SQL where clause (and callers may pass raw request
* input), so it must be a well-formed LIBREF.DATASET (or the
* LIBREF.CATALOGNAME-FC form of a format catalog) and access_level must
* be one of the known levels - anything else aborts before the query is
* built. Values are read with symget (never re-resolved) and scanned in
* a data step so no macro content in the input can execute.
*/
%local is_libds is_level;
%let is_libds=0;
%let is_level=0;
data _null_;
length _bt $64 _lvl $16;
_bt=symget('base_table');
_lvl=upcase(symget('access_level'));
%mpe_validatecol(_bt,LIBDS,is_libds)
if is_libds=0 then do;
call symputx('is_libds',0,'l');
putlog 'ERR' 'OR: Invalid base_table:' _bt;
stop;
end;
if _lvl not in ('EDIT','APPROVE','VIEW','SIGNOFF','AUDIT') then do;
call symputx('is_level',0,'l');
putlog 'ERR' 'OR: Invalid access_level:' _lvl;
stop;
end;
/* escape any embedded quotes so the value cannot break out of the
* double-quoted SQL literals below (defence in depth - the LIBDS
* check above already rejects quotes) */
_bt=tranwrd(_bt,'"','');
call symputx('base_table',_bt,'l');
call symputx('access_level',_lvl,'l');
call symputx('is_libds',is_libds,'l');
call symputx('is_level',1,'l');
run;
%mp_abort(
iftrue=(%mf_verifymacvars(base_table user access_level)=0)
iftrue=(&is_libds ne 1)
,mac=mpe_accesscheck
,msg=%str(Missing base_table/user access_level variables)
,msg=%str(Invalid base_table)
)
%mp_abort(
iftrue=(&is_level ne 1)
,mac=mpe_accesscheck
,msg=%str(Invalid access_level)
)
/* make unique temp table vars */
+47
View File
@@ -0,0 +1,47 @@
/**
@file
@brief Validates a column of values, including the Data Controller
format-catalog form of a libds reference
@details Wrapper around mp_validatecol() that adds the Data Controller
convention of addressing a format catalog as `LIBREF.CATALOGNAME-FC`
to the LIBDS rule.
The `-FC` suffix is matched exactly and the remainder is validated as
a strict LIBREF.DATASET, so the *whole* value is covered by the
validation - a bare scan on the dash would leave anything after it
unvalidated. The input column itself is never modified, so a caller
that needs the catalog reference downstream (to match MPE_SECURITY,
for instance) still receives it.
@param [in] incol Input column (a data step variable)
@param [in] rule Validation rule, as per mp_validatecol()
@param [out] outcol 1 when the value is valid, else 0
<h4> SAS Macros </h4>
@li mp_validatecol.sas
@li mf_getuniquename.sas
@version 9.2
@author 4GL Apps Ltd
@copyright 4GL Apps Ltd. This code may only be used within Data Controller
and may not be re-distributed or re-sold without the express permission of
4GL Apps Ltd.
**/
%macro mpe_validatecol(incol,rule,outcol);
%if &rule=LIBDS %then %do;
/* tempcol is given a unique name with every invocation */
%local tempcol;
%let tempcol=%mf_getuniquename(prefix=cat);
&tempcol=strip(&incol);
/* permit the format-catalog form: LIBREF.CATALOGNAME-FC, exactly */
if length(&tempcol)>3
and upcase(substr(&tempcol,length(&tempcol)-2,3))='-FC'
then &tempcol=substr(&tempcol,1,length(&tempcol)-3);
%mp_validatecol(&tempcol,LIBDS,&outcol)
drop &tempcol;
%end;
%else %mp_validatecol(&incol,&rule,&outcol);
%mend mpe_validatecol;
+72
View File
@@ -0,0 +1,72 @@
/**
@file
@brief testing the mpe_validatecol macro (LIBDS rule)
@details The LIBDS rule must accept the Data Controller format-catalog
reference (LIBREF.CATALOGNAME-FC) and reject everything else - including
a value that merely *starts* with a valid libds and continues past the
dash, which a bare scan on the dash would let through.
<h4> SAS Macros </h4>
@li mpe_validatecol.sas
@li mp_assertdsobs.sas
@author 4GL Apps Ltd
@copyright 4GL Apps Ltd. This code may only be used within Data Controller
and may not be re-distributed or re-sold without the express permission of
4GL Apps Ltd.
**/
data work.check;
length val $64;
/* valid: a plain libds */
exp=1; val='WORK.CLASS'; output;
exp=1; val='DC.MPE_TABLES'; output;
exp=1; val='_A._B'; output;
/* valid: the format-catalog form, however the suffix is cased */
exp=1; val='DCTEST.DCFMTS-FC'; output;
exp=1; val='dctest.dcfmts-fc'; output;
exp=1; val='WORK.CLASS-FC'; output;
/* valid: padded values are trimmed before the check */
exp=1; val='WORK.CLASS '; output;
/* invalid: not a libds at all */
exp=0; val='WORK'; output;
exp=0; val='WORK.CLASS.NOPE'; output;
exp=0; val=''; output;
exp=0; val='-FC'; output;
exp=0; val='../secprobe'; output;
/* invalid: the dash suffix is not exactly -FC */
exp=0; val='WORK.CLASS-FCX'; output;
exp=0; val='WORK.CLASS-C'; output;
exp=0; val='WORK.CLASS-FC X'; output;
/* invalid: content smuggled past a valid libds prefix */
exp=0; val="WORK.CLASS-FC'"; output;
exp=0; val='WORK.CLASS-FC;proc sql;'; output;
exp=0; val='WORK.CLASS-FC) or 1=1'; output;
exp=0; val='WORK.CLASS-fc-'; output;
run;
data work.check;
set work.check;
is_libds=0;
%mpe_validatecol(val,LIBDS,is_libds)
got=is_libds;
if got ne exp then putlog 'ERR' 'OR: unexpected result for [' val +(-1) ']';
run;
data work.mismatch;
set work.check;
where got ne exp;
run;
%mp_assertdsobs(work.mismatch,
desc=Every LIBDS value validated as expected (catalog form permitted, nothing smuggled past the dash),
test=EQUALS 0,
outds=work.test_results
)
/* dump for offline inspection */
data _null_;
set work.check;
putlog 'TEST_RESULT_LINE: [' val +(-1) '] exp=' exp 'got=' got;
run;
+36 -2
View File
@@ -5,8 +5,13 @@
@li &parent= (parent path)
Requires membership of the DC administrators group.
<h4> SAS Macros </h4>
@li mf_getuser.sas
@li mp_abort.sas
@li mp_dirlist.sas
@li mpe_getgroups.sas
@version 9.2
@author 4GL Apps Ltd
@@ -17,8 +22,37 @@
**/
%global parent;
/* if no flavour is specified, default to root */
%let parent=%sysfunc(coalescec(&parent,/));
%mpeinit()
/* check user is in admin group */
%let cnt=0;
%mpe_getgroups(user=%mf_getuser(),outds=work.usergroups)
proc sql noprint;
select count(*) into:cnt
from usergroups
where groupname="&mpeadmins";
%mp_abort(iftrue= (&cnt=0)
,mac=&_program
,msg=%str(This service is only available to &mpeadmins members)
)
/* if no parent is specified, default to root - read with symget
* (never re-resolved) so macro content in the param cannot execute */
%let is_bad=0;
data _null_;
length _parent $512;
_parent=coalescec(symget('parent'),'/');
if index(_parent,'%')>0 or index(_parent,'&')>0 then do;
putlog 'ERR' 'OR: Invalid parent:' _parent;
call symputx('is_bad',1,'l');
end;
else call symputx('parent',_parent,'g');
run;
%mp_abort(iftrue=(&is_bad=1)
,mac=&_program..sas
,msg=%str(Invalid parent)
)
%mp_dirlist(path=&parent,outds=dirlist, maxdepth=2)
+45
View File
@@ -0,0 +1,45 @@
/**
@file
@brief testing admin dirlist service - admin gate (security)
@details The service requires membership of the DC administrators
group. The test suite runs as a member of that group, so the gate
passes and the directory listing is returned. (A non-admin negative
test would need a second user, which this suite does not have.)
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mx_execute.sas
**/
%let _program=&appLoc/services/admin/dirlist;
data work.params;
length name $32 value $1000;
name='parent';value='/tmp';
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params,
outlib=web1
)
%let nobs=0;
proc sql noprint;
select count(*) into: nobs from web1.dirlist;
quit;
%mp_assert(
iftrue=(&nobs>0),
desc=Admin user gets a directory listing,
outds=work.test_results
)
/**
* dump results to the log for offline inspection
*/
data _null_;
set work.test_results;
putlog 'TEST_RESULT_LINE: ' test_result ' - ' test_description;
run;
+13
View File
@@ -15,6 +15,7 @@
@li mp_ds2csv.sas
@li mp_streamfile.sas
@li mp_validatecol.sas
@li mpe_getgroups.sas
@author 4GL Apps Ltd
@copyright 4GL Apps Ltd. This code may only be used within Data Controller
@@ -26,6 +27,18 @@
%global dclib islib newlib;
%mpeinit()
/* check user is in admin group */
%let cnt=0;
%mpe_getgroups(user=%mf_getuser(),outds=work.usergroups)
proc sql noprint;
select count(*) into:cnt
from usergroups
where groupname="&mpeadmins";
%mp_abort(iftrue= (&cnt=0)
,mac=&_program
,msg=%str(The DC configuration can only be exported by &mpeadmins members)
)
data _null_;
newlib=coalescec(symget('dclib'),"&mpelib");
%mp_validatecol(newlib,ISLIB,islib)
@@ -2,10 +2,15 @@
@file refreshcatalog.sas
@brief Refreshes the library data catalog
@details A library may be passed in a LIBREF url param.
Requires membership of the DC administrators group.
<h4> SAS Macros </h4>
@li mpeinit.sas
@li dc_refreshcatalog.sas
@li mf_getuser.sas
@li mpe_getgroups.sas
@li mp_abort.sas
@li mp_validatecol.sas
@li mpeterm.sas
@version 9.3
@@ -18,6 +23,44 @@
%global libref;
%mpeinit()
/**
* libref is a request input used in dc_assignlib and catalog queries -
* it must be a well-formed libref before use. Read with symget (never
* re-resolved) and validated in a data step.
*/
%let is_lib=0;
data _null_;
length _libref $8;
_libref=coalescec(symget('libref'),'');
/* an absent libref is a valid, full catalog refresh */
if missing(_libref) then do;
call symputx('is_lib',1,'l');
call symputx('libref','','g');
stop;
end;
%mp_validatecol(_libref,ISLIB,is_lib)
if is_lib=0 then putlog 'ERR' 'OR: Invalid libref:' _libref;
call symputx('is_lib',is_lib,'l');
if is_lib=1 then call symputx('libref',upcase(_libref),'g');
run;
%mp_abort(iftrue= (&is_lib ne 1)
,mac=&_program..sas
,msg=%str(Invalid libref)
)
/* check user is in admin group */
%let cnt=0;
%mpe_getgroups(user=%mf_getuser(),outds=work.usergroups)
proc sql noprint;
select count(*) into:cnt
from usergroups
where groupname="&mpeadmins";
%mp_abort(iftrue= (&cnt=0)
,mac=&_program
,msg=%str(This service is only available to &mpeadmins members)
)
%dc_refreshcatalog(&libref)
@@ -0,0 +1,78 @@
/**
@file
@brief testing admin refreshcatalog service - admin gate + libref validation (security)
@details The service requires membership of the DC administrators
group and a well-formed libref (or none). An invalid libref aborts
the service, which shows up as a canceled child job (an aborted
service registers no webout). The test suite runs as a member of
the admin group, so the gate passes here.
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mx_execute.sas
**/
%let _program=&appLoc/services/admin/refreshcatalog;
/**
* Test 1 - an invalid libref must abort the service
*/
data work.params1;
length name $32 value $1000;
name='libref';value='A.%sysevalf(3+4)B';output;
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params1,
outref=web1,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
desc=Macro content in libref aborts the service,
outds=work.test_results
)
/**
* Test 2 - a valid libref still refreshes the catalog (admin user)
*/
data work.params2;
length name $32 value $1000;
name='libref';value='DCTEST';output;
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params2,
outlib=web2
)
%let msgcheck=0;
data _null_;
set web2.sasparams;
putlog (_all_)(=);
if index(msg,'Catalog Refresh Complete') then call symputx('msgcheck',1);
run;
%mp_assert(
iftrue=(&msgcheck=1),
desc=Valid libref refresh completes for admin user,
outds=work.test_results
)
/**
* dump results to the log for offline inspection
*/
data _null_;
set work.test_results;
putlog 'TEST_RESULT_LINE: ' test_result ' - ' test_description;
run;
+16 -1
View File
@@ -1,11 +1,14 @@
/**
@file refreshlibs.sas
@brief Refreshes the library data catalog
@details
@details Requires membership of the DC administrators group.
<h4> SAS Macros </h4>
@li mpeinit.sas
@li mpe_refreshlibs.sas
@li mf_getuser.sas
@li mpe_getgroups.sas
@li mp_abort.sas
@version 9.3
@author 4GL Apps Ltd
@@ -17,4 +20,16 @@
%mpeinit()
/* check user is in admin group */
%let cnt=0;
%mpe_getgroups(user=%mf_getuser(),outds=work.usergroups)
proc sql noprint;
select count(*) into:cnt
from usergroups
where groupname="&mpeadmins";
%mp_abort(iftrue= (&cnt=0)
,mac=&_program
,msg=%str(This service is only available to &mpeadmins members)
)
%mpe_refreshlibs()
+48 -1
View File
@@ -7,9 +7,11 @@
@li mpe_getvars.sas
@li mpe_accesscheck.sas
@li mf_getattrn.sas
@li mf_getuser.sas
@li mp_abort.sas
@li mp_binarycopy.sas
@li mp_streamfile.sas
@li mp_validatecol.sas
@version 9.2
@author 4GL Apps Ltd
@@ -22,6 +24,52 @@
%mpeinit()
%mpe_getvars(BrowserParams, BrowserParams);
/**
* Validate inputs before they reach executable code. libds is passed to
* the access check (which interpolates it into SQL) and table is used in
* the staging file path, so both must be well formed before use. Values
* are re-read with symget (never re-resolved) and validated in a data
* step so no macro content in the request can execute.
*/
%let is_libds=0;
%let is_table=0;
%let is_csv=0;
data _null_;
length _libds $64 _table $64 _csv $128;
_libds=symget('libds');
_table=symget('table');
_csv=coalescec(symget('stp_diffs_csv'),'tempDiffs.csv');
%mp_validatecol(_libds,LIBDS,is_libds)
%mp_validatecol(_table,ISNAME,is_table)
/* the diffs csv filename must stay inside the staging directory */
if findc(_csv,'/\')>0 or index(_csv,'..')>0 then do;
is_csv=0;
putlog 'ERR' 'OR: Invalid stp_diffs_csv:' _csv;
end;
else is_csv=1;
if is_libds=0 then putlog 'ERR' 'OR: Invalid libds:' _libds;
if is_table=0 then putlog 'ERR' 'OR: Invalid table:' _table;
call symputx('is_libds',is_libds,'l');
call symputx('is_table',is_table,'l');
call symputx('is_csv',is_csv,'l');
if is_libds=1 then call symputx('libds',_libds,'g');
if is_table=1 then call symputx('table',_table,'g');
if is_csv=1 then call symputx('stp_diffs_csv',_csv,'g');
run;
%mp_abort(iftrue= (&is_libds ne 1)
,mac=&_program..sas
,msg=%str(Invalid libds)
)
%mp_abort(iftrue= (&is_table ne 1)
,mac=&_program..sas
,msg=%str(Invalid table)
)
%mp_abort(iftrue= (&is_csv ne 1)
,mac=&_program..sas
,msg=%str(Invalid stp_diffs_csv)
)
/* security checks */
%let user=%mf_getuser();
%mpe_accesscheck(&libds,outds=authEDIT,user=&user,access_level=EDIT)
@@ -51,5 +99,4 @@
%mpestp_diffs()
%mpeterm()
@@ -0,0 +1,194 @@
/**
@file
@brief testing getdiffs service - input validation (security)
@details The libds, table and stp_diffs_csv request params must be
well-formed before they reach the access check and the staging file
path. An invalid value aborts the service, which shows up as a
canceled child job (an aborted service registers no webout).
A real load is staged first (stagedata) and a diffs csv written into
the staging directory, so every payload below resolves to that REAL
file when executed - on a vulnerable service the job completes, and
only the validating service cancels it. The assertions cannot pass
against a service that does not validate.
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mx_execute.sas
@li mf_getuniquefileref.sas
**/
%let _program=&appLoc/services/auditors/getdiffs;
/**
* Stage a real load so a real staging directory exists
*/
data work.sascontroltable;
action='LOAD';
message="getdiffs test prep";
libds="&dclib..MPE_X_TEST";
output;
stop;
run;
proc sql noprint;
select max(primary_key_field) into: maxpk
from &dclib..mpe_x_test;
quit;
data work.jsdata;
set &dclib..mpe_x_test(rename=(
some_date=dt2 SOME_DATETIME=dttm2 some_time=tm2)
);
some_date=put(dt2,date9.);
SOME_DATETIME=put(dttm2,datetime19.);
some_time=put(tm2,time.);
drop dt2 dttm2 tm2;
if _n_=1 then do;
_____DELETE__THIS__RECORD_____='No';
some_char='getdiffs security test';
some_num=&maxpk+1;
end;
else stop;
run;
%mx_execute(&appLoc/services/editors/stagedata,
viyacontext=&defaultcontext,
inputdatasets=work.jsdata work.sascontroltable,
outlib=webstage,
mdebug=&sasjs_mdebug
)
%let stagetest=0;
data _null_;
set webstage.sasparams;
putlog (_all_)(=);
if status='SUCCESS' then call symputx('stagetest',1);
call symputx('loadref',dsid);
run;
%mp_assert(
iftrue=(&stagetest=1 and &syscc=0),
desc=stagedata succeeded in getdiffs prep,
outds=work.test_results
)
/**
* Write the diffs csv into the real staging directory
*/
%let diffscsv=tempDiffs_secrev.csv;
data _null_;
file "&dc_staging_area/&loadref./&diffscsv";
put 'SOME_CHAR,_____STATUS_____';
put 'getdiffs security test,UPDATED';
run;
/**
* Test 1 - the mpe_accesscheck SQL injection payload in libds must
* abort the service (validation fires before the authz query, so
* the authz bypass cannot happen). The payload is sent through the
* sasjs table channel (BrowserParams) like the frontend does - the
* raw-quote form is masked in plain URL params on this platform.
*/
%let fb1=%mf_getuniquefileref();
data _null_;
file &fb1 termstr=crlf;
length _row $400.;
put 'TABLE:$41. STP_DIFFS_CSV:$100. libds:$41.';
_row=cats(symget('loadref'),',',symget('diffscsv'),',',
'SOMELIB.SOMEDS',"'22'x"," or ","'22'x",'1',"'22'x",' ne ',"'22'x",'2');
put _row;
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&fb1:BrowserParams,
outref=web1,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
desc=SQL injection payload in libds aborts the service,
outds=work.test_results
)
/**
* Test 2 - path traversal in table must abort the service (the
* payload resolves to the real staged file through a .. detour)
*/
data _null_;
length _dir $512;
_dir=scan(symget('dc_staging_area'),-1,'/');
call symputx('travtable',cats('../',_dir,'/','&loadref'));
run;
%let fb2=%mf_getuniquefileref();
data _null_;
file &fb2 termstr=crlf;
put 'TABLE:$41. STP_DIFFS_CSV:$100. libds:$41.';
put "&travtable.,&diffscsv.,&dclib..MPE_X_TEST";
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&fb2:BrowserParams,
outref=web2,
viyaresult=WEBOUT_TXT
)
%let abort2=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort2',1);
run;
%mp_assert(
iftrue=(&abort2=1),
desc=Path traversal in table aborts the service,
outds=work.test_results
)
/**
* Test 3 - path traversal in stp_diffs_csv must abort the service
*/
%let fb3=%mf_getuniquefileref();
data _null_;
file &fb3 termstr=crlf;
put 'TABLE:$41. STP_DIFFS_CSV:$100. libds:$41.';
put "&loadref.,../&loadref./&diffscsv.,&dclib..MPE_X_TEST";
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&fb3:BrowserParams,
outref=web3,
viyaresult=WEBOUT_TXT
)
%let abort3=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort3',1);
run;
%mp_assert(
iftrue=(&abort3=1),
desc=Path traversal in stp_diffs_csv aborts the service,
outds=work.test_results
)
/**
* dump results to the log for offline inspection
*/
data _null_;
set work.test_results;
putlog 'TEST_RESULT_LINE: ' test_result ' - ' test_description;
run;
+24 -2
View File
@@ -5,8 +5,8 @@
<h4> SAS Macros </h4>
@li dc_assignlib.sas
@li mf_getvalue.sas
@li mp_abort.sas
@li mp_validatecol.sas
@version 9.2
@author 4GL Apps Ltd
@@ -18,7 +18,29 @@
%mpeinit()
%let ds=%mf_getvalue(work.iwant,libds);
/**
* The libds is read from the IWANT input table. Reading it with
* mf_getvalue would re-resolve any macro content in the value, so it is
* read with symget in a data step and validated (LIBREF.DATASET) before
* it is used in proc contents.
*/
%let is_libds=0;
data _null_;
length _libds $64;
set work.iwant;
_libds=libds;
%mp_validatecol(_libds,LIBDS,is_libds)
if is_libds=0 then putlog 'ERR' 'OR: Invalid libds:' _libds;
call symputx('is_libds',is_libds,'l');
if is_libds=1 then call symputx('ds',upcase(_libds),'l');
stop;
run;
%mp_abort(iftrue= (&is_libds ne 1)
,mac=&_program..sas
,msg=%str(Invalid libds)
)
%dc_assignlib(READ,%scan(&ds,1,.))
proc contents noprint data=&ds
@@ -0,0 +1,85 @@
/**
@file
@brief testing getcols service - input validation (security)
@details The libds in the IWANT input table must be a well-formed
LIBREF.DATASET. An invalid value aborts the service before it
reaches proc contents. The abort shows up as a canceled child job
(an aborted service registers no webout).
The payload in test 1 resolves to a REAL table when the request
content is executed as macro code, so on a vulnerable service the
job completes, and only the validating service cancels it - the
assertion cannot pass against a service that does not validate.
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mx_execute.sas
@li mf_getuniquefileref.sas
**/
%let _program=&appLoc/services/public/getcols;
/**
* Test 1 - macro content in libds must abort the service
*/
%let f1=%mf_getuniquefileref();
data _null_;
file &f1 termstr=crlf;
put 'LIBDS:$41.';
put '%sysfunc(coalescec(&dclib..MPE_X_TEST,))';
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant,
outref=web1,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
desc=Macro content in libds aborts the service,
outds=work.test_results
)
/**
* Test 2 - valid libds still returns columns
*/
%let f2=%mf_getuniquefileref();
data _null_;
file &f2 termstr=crlf;
put 'LIBDS:$41.';
put "&dclib..MPE_X_TEST";
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:iwant,
outlib=web2
)
%let nobs=0;
proc sql noprint;
select count(*) into: nobs from web2.cols;
quit;
%mp_assert(
iftrue=(&nobs>0),
desc=Valid libds returns columns,
outds=work.test_results
)
/**
* dump results to the log for offline inspection
*/
data _null_;
set work.test_results;
putlog 'TEST_RESULT_LINE: ' test_result ' - ' test_description;
run;
+38 -7
View File
@@ -35,7 +35,6 @@
<h4> SAS Macros </h4>
@li mf_existds.sas
@li mf_getvalue.sas
@li mf_verifymacvars.sas
@li dc_assignlib.sas
@li mf_getvarformat.sas
@@ -43,6 +42,8 @@
@li mp_cntlout.sas
@li mp_filtercheck.sas
@li mp_filtergenerate.sas
@li mp_validatecol.sas
@li mpe_validatecol.sas
@version 9.2
@author 4GL Apps Ltd.
@@ -77,18 +78,48 @@ data _null_;
put (_all_)(=);
run;
%let libds=%mf_getvalue(work.iwant,libds);
%let col2=%mf_getvalue(work.iwant,col);
/**
* libds and col are request inputs that flow into executable positions
* (set &libds, proc sql select &col2). They are read from the IWANT
* table with symget in a data step (never re-resolved) and validated
* here before use - mf_getvalue would re-resolve any macro content in
* the value before this code ran.
*/
%let libds=;
%let col2=;
%let is_libds=0;
%let is_col=0;
data _null_;
length _libds $64 _col $32;
set work.iwant;
_libds=libds;
_col=col;
%mpe_validatecol(_libds,LIBDS,is_libds)
%mp_validatecol(_col,ISNAME,is_col)
if is_libds=0 then putlog 'ERR' 'OR: Invalid libds:' _libds;
if is_col=0 then putlog 'ERR' 'OR: Invalid col:' _col;
call symputx('is_libds',is_libds,'l');
call symputx('is_col',is_col,'l');
if is_libds=1 then call symputx('libds',upcase(_libds),'l');
if is_col=1 then call symputx('col2',upcase(_col),'l');
stop;
run;
%let is_fmt=0;
%let startrow=1;
%let rows=4000;
%put &=libds;
%put &=col2;
%mp_abort(iftrue= (%mf_verifymacvars(libds col2)=0)
,mac=&_program..sas
,msg=%str(Missing inputs from iwant. Libds=&libds col=&col2 )
,msg=%str(Missing inputs from iwant)
)
%mp_abort(iftrue= (&is_libds ne 1)
,mac=&_program..sas
,msg=%str(Invalid libds)
)
%mp_abort(iftrue= (&is_col ne 1)
,mac=&_program..sas
,msg=%str(Invalid col)
)
%dc_assignlib(WRITE,%scan(&libds,1,.))
@@ -0,0 +1,115 @@
/**
@file
@brief testing getcolvals service - input validation (security)
@details The libds and col in the IWANT input table must be
well-formed (LIBREF.DATASET and SAS name). An invalid value aborts
the service, which shows up as a canceled child job (an aborted
service registers no webout).
The payloads in tests 1-2 resolve to a REAL table / column when the
request content is executed as macro code, so on a vulnerable
service the job completes, and only the validating service cancels
it - the assertion cannot pass against a service that does not
validate.
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mx_execute.sas
@li mf_getuniquefileref.sas
**/
%let _program=&appLoc/services/public/getcolvals;
/**
* Test 1 - macro content in libds must abort the service
*/
%let f1=%mf_getuniquefileref();
data _null_;
file &f1 termstr=crlf;
put 'LIBDS:$19. COL:$9.';
put '%sysfunc(coalescec(&dclib..MPE_X_TEST,)),SOME_TIME';
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant,
outref=web1,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
desc=Macro content in libds aborts the service,
outds=work.test_results
)
/**
* Test 2 - macro content in col must abort the service
*/
%let f2=%mf_getuniquefileref();
data _null_;
file &f2 termstr=crlf;
put 'LIBDS:$19. COL:$9.';
put '&dclib..MPE_X_TEST,%sysfunc(coalescec(SOME_TIME,))';
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:iwant,
outref=web2,
viyaresult=WEBOUT_TXT
)
%let abort2=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort2',1);
run;
%mp_assert(
iftrue=(&abort2=1),
desc=Macro content in col aborts the service,
outds=work.test_results
)
/**
* Test 3 - valid inputs still return values
*/
%let f3=%mf_getuniquefileref();
data _null_;
file &f3 termstr=crlf;
put 'LIBDS:$19. COL:$9.';
put "&dclib..MPE_X_TEST,SOME_TIME";
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f3:iwant,
outlib=web3
)
%let nobs=0;
proc sql noprint;
select count(*) into: nobs from web3.vals;
quit;
%mp_assert(
iftrue=(&nobs>0),
desc=Valid inputs return values,
outds=work.test_results
)
/**
* dump results to the log for offline inspection
*/
data _null_;
set work.test_results;
putlog 'TEST_RESULT_LINE: ' test_result ' - ' test_description;
run;
+54 -5
View File
@@ -11,16 +11,17 @@
@li filter - the filter RK if used
<h4> SAS Macros </h4>
@li mf_verifymacvars.sas
@li mf_getuser.sas
@li mf_existfeature.sas
@li dc_assignlib.sas
@li mp_ds2cards.sas
@li mp_ds2csv.sas
@li mp_abort.sas
@li mp_binarycopy.sas
@li mp_cntlout.sas
@li mp_ds2cards.sas
@li mp_ds2csv.sas
@li mp_streamfile.sas
@li mp_validatecol.sas
@li mpe_validatecol.sas
@li mpe_filtermaster.sas
@@ -37,9 +38,57 @@
%let user=%mf_getuser();
%let is_fmt=0;
%mp_abort(iftrue= (%mf_verifymacvars(type table)=0)
/**
* Validate inputs before they reach executable code. table is used as a
* dataset reference, in the output file path, and in the download filename,
* so it must be a well-formed LIBREF.DATASET (the trailing -FC catalog
* suffix is permitted); filter must be an integer. Values are read with
* symget (never re-resolved) and validated in a data step so no macro
* content in the input can execute.
*/
%let is_libds=0;
%let is_int=0;
%let is_type=0;
data _null_;
length _table $64 _filter $16 _type $16;
_type=upcase(coalescec(symget('type'),''));
_table=coalescec(symget('table'),'');
_filter=coalescec(symget('filter'),'0');
if missing(_table) then do;
putlog 'ERR' 'OR: Missing table';
stop;
end;
%mpe_validatecol(_table,LIBDS,is_libds)
/* an absent filter is a valid, unfiltered download */
if missing(_filter) then _filter='0';
%mp_validatecol(_filter,ISINT,is_int)
/* type is validated against a fixed list of download formats */
length _types_ok 8;
_types_ok=0;
if _type in ('SAS','CSV','EXCEL','MARKDOWN','WEBCSV','WEBTAB')
then _types_ok=1;
else putlog 'ERR' 'OR: Invalid type:' _type;
if is_libds=0 then putlog 'ERR' 'OR: Invalid table:' _table;
if is_int=0 then putlog 'ERR' 'OR: Invalid filter:' _filter;
call symputx('is_libds',is_libds,'l');
call symputx('is_int',is_int,'l');
call symputx('is_type',_types_ok,'l');
call symputx('filter',_filter,'l');
if is_libds=1 then call symputx('table',upcase(_table),'l');
if _types_ok=1 then call symputx('type',_type,'l');
run;
%mp_abort(iftrue= (&is_libds ne 1)
,mac=&_program..sas
,msg=%str(Invalid inputs: type table)
,msg=%str(Invalid table)
)
%mp_abort(iftrue= (&is_int ne 1)
,mac=&_program..sas
,msg=%str(Invalid filter)
)
%mp_abort(iftrue= (&is_type ne 1)
,mac=&_program..sas
,msg=%str(Invalid type)
)
%let libds=%upcase(&table); /* actual source */
@@ -0,0 +1,152 @@
/**
@file
@brief testing getrawdata service - input validation (security)
@details table must be a well-formed LIBREF.DATASET (the trailing
format-catalog suffix is permitted), filter must be an integer and
type one of the supported download types. An invalid value aborts
the service before any request content can execute. The abort is
asserted from the child job state: the payloads either resolve to a
real table when executed as macro code, or write a file outside the
WORK directory (verified by live probe) - so on the vulnerable
service the job completes, and only the validating service cancels
it. The assertion cannot pass against a service that does not
validate.
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mx_execute.sas
**/
%let _program=&appLoc/services/public/getrawdata;
/**
* Test 1 - macro content in table must abort the service
* (the payload resolves to a real table when executed as macro code)
*/
data work.params1;
length name $32 value $1000;
name='type';value='CSV';output;
name='table';value='%sysfunc(coalescec(&mpelib..MPE_X_TEST,))';output;
name='filter';value='0';output;
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params1,
outref=web1,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
desc=Macro content in table aborts the service,
outds=work.test_results
)
/**
* Test 2 - sql injection in filter must abort the service
*/
data work.params2;
length name $32 value $1000;
name='type';value='CSV';output;
name='table';value="&dclib..MPE_X_TEST";output;
name='filter';value='0 or 1=1';output;
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params2,
outref=web2,
viyaresult=WEBOUT_TXT
)
%let abort2=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort2',1);
run;
%mp_assert(
iftrue=(&abort2=1),
desc=SQL injection in filter aborts the service,
outds=work.test_results
)
/**
* Test 3 - path traversal in table must abort the service
* (verified by live probe: on a vulnerable service this writes
* ../SECPROBE.csv outside the WORK directory and completes)
*/
data work.params3;
length name $32 value $1000;
name='type';value='CSV';output;
name='table';value='../secprobe';output;
name='filter';value='0';output;
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params3,
outref=web3,
viyaresult=WEBOUT_TXT
)
%let abort3=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort3',1);
run;
%mp_assert(
iftrue=(&abort3=1),
desc=Path traversal in table aborts the service,
outds=work.test_results
)
/**
* Test 4 - the valid request must still work (positive control)
*/
data work.params4;
length name $32 value $1000;
name='type';value='CSV';output;
name='table';value="&dclib..MPE_X_TEST";output;
name='filter';value='0';output;
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputparams=work.params4,
outref=web4,
viyaresult=WEBOUT_TXT
)
%let ok4=0;
data _null_;
infile web4;
input;
if _infile_=:'PRIMARY_KEY_FIELD' then do;
call symputx('ok4',1);
stop;
end;
run;
%mp_assert(
iftrue=(&ok4=1),
desc=Valid table request still returns data,
outds=work.test_results
)
/**
* dump results to the log for offline inspection
*/
data _null_;
set work.test_results;
putlog 'TEST_RESULT_LINE: ' test_result ' - ' test_description;
run;
+28 -2
View File
@@ -26,8 +26,9 @@
<h4> SAS Macros </h4>
@li dc_assignlib.sas
@li mf_getvalue.sas
@li mp_abort.sas
@li mp_filterstore.sas
@li mpe_validatecol.sas
@li removecolsfromwork.sas
@version 9.2
@@ -40,7 +41,32 @@
%mpeinit()
%let ds=%upcase(%mf_getvalue(work.iwant,filter_table));
/**
* filter_table is a request input that flows into executable positions
* (mp_filterstore libds=, which interpolates it into SQL). It is read
* from the IWANT table in a data step (never re-resolved) and validated
* before use - mf_getvalue would re-resolve any macro content in the
* value before this code ran. A format catalog is referenced as
* LIBREF.CATALOGNAME-FC, so that form is accepted too.
*/
%let ds=;
%let is_libds=0;
data _null_;
length _ds $64;
set work.iwant;
_ds=filter_table;
%mpe_validatecol(_ds,LIBDS,is_libds)
if is_libds=0 then putlog 'ERR' 'OR: Invalid filter_table:' _ds;
call symputx('is_libds',is_libds,'l');
if is_libds=1 then call symputx('ds',upcase(_ds),'l');
stop;
run;
%mp_abort(iftrue= (&is_libds ne 1)
,mac=&_program..sas
,msg=%str(Invalid filter_table)
)
%dc_assignlib(WRITE,%scan(&ds,1,.))
%mp_filterstore(
@@ -0,0 +1,97 @@
/**
@file
@brief testing validatefilter service - input validation (security)
@details The filter_table in the IWANT input table must be a
well-formed LIBREF.DATASET. An invalid value aborts the service
before it reaches mp_filterstore. The abort shows up as a canceled
child job (an aborted service registers no webout).
The payload in test 1 resolves to a REAL table when the request
content is executed as macro code, so on a vulnerable service the
job completes, and only the validating service cancels it - the
assertion cannot pass against a service that does not validate.
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mx_execute.sas
@li mf_getuniquefileref.sas
**/
%let _program=&appLoc/services/public/validatefilter;
/**
* Test 1 - macro content in filter_table must abort the service
*/
%let f1=%mf_getuniquefileref();
data _null_;
file &f1 termstr=crlf;
put 'FILTER_TABLE:$41.';
put '%sysfunc(coalescec(&dclib..MPE_TABLES,))';
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant,
outref=web1,
viyaresult=WEBOUT_TXT
)
%let abort1=0;
data _null_;
set work.results;
if state='canceled' then call symputx('abort1',1);
run;
%mp_assert(
iftrue=(&abort1=1),
desc=Macro content in filter_table aborts the service,
outds=work.test_results
)
/**
* Test 2 - valid filter_table still stores a filter
*/
%let f2=%mf_getuniquefileref();
data _null_;
file &f2 termstr=crlf;
put 'FILTER_TABLE:$41.';
put "&dclib..MPE_TABLES";
run;
%let f3=%mf_getuniquefileref();
data _null_;
file &f3 termstr=crlf;
infile datalines4 dsd;
input;
put _infile_;
datalines4;
GROUP_LOGIC:$3. SUBGROUP_LOGIC:$3. SUBGROUP_ID:8. VARIABLE_NM:$32. OPERATOR_NM:$10. RAW_VALUE:$4000.
AND,AND,1,LIBREF,CONTAINS,"'DC'"
AND,OR,2,DSN,=,"'MPE_LOCK_ANYTABLE'"
;;;;
run;
%mx_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:iwant &f3:filterquery,
outlib=web2
)
%let nobs=0;
proc sql noprint;
select count(*) into: nobs from web2.result;
quit;
%mp_assert(
iftrue=(&nobs>0),
desc=Valid filter_table returns a filter result,
outds=work.test_results
)
/**
* dump results to the log for offline inspection
*/
data _null_;
set work.test_results;
putlog 'TEST_RESULT_LINE: ' test_result ' - ' test_description;
run;