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
This commit is contained in:
dc
2026-09-21 19:04:35 +00:00
parent c9eed6dae7
commit be86000fe0
17 changed files with 1113 additions and 23 deletions
+44 -3
View File
@@ -23,7 +23,7 @@
@li mp_abort.sas @li mp_abort.sas
@li mf_getuniquename.sas @li mf_getuniquename.sas
@li mf_getuser.sas @li mf_getuser.sas
@li mf_verifymacvars.sas @li mp_validatecol.sas
@li mpe_getgroups.sas @li mpe_getgroups.sas
<h4> Related Macros </h4> <h4> Related Macros </h4>
@@ -52,10 +52,51 @@
,msg=%str(outds should be a WORK table) ,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 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'));
%mp_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( %mp_abort(
iftrue=(%mf_verifymacvars(base_table user access_level)=0) iftrue=(&is_libds ne 1)
,mac=mpe_accesscheck ,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 */ /* make unique temp table vars */
+36 -2
View File
@@ -5,8 +5,13 @@
@li &parent= (parent path) @li &parent= (parent path)
Requires membership of the DC administrators group.
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li mf_getuser.sas
@li mp_abort.sas
@li mp_dirlist.sas @li mp_dirlist.sas
@li mpe_getgroups.sas
@version 9.2 @version 9.2
@author 4GL Apps Ltd @author 4GL Apps Ltd
@@ -17,8 +22,37 @@
**/ **/
%global parent; %global parent;
/* if no flavour is specified, default to root */ %mpeinit()
%let parent=%sysfunc(coalescec(&parent,/));
/* 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) %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 'SECREVRESULT: ' test_result ' - ' test_description;
run;
+13
View File
@@ -15,6 +15,7 @@
@li mp_ds2csv.sas @li mp_ds2csv.sas
@li mp_streamfile.sas @li mp_streamfile.sas
@li mp_validatecol.sas @li mp_validatecol.sas
@li mpe_getgroups.sas
@author 4GL Apps Ltd @author 4GL Apps Ltd
@copyright 4GL Apps Ltd. This code may only be used within Data Controller @copyright 4GL Apps Ltd. This code may only be used within Data Controller
@@ -26,6 +27,18 @@
%global dclib islib newlib; %global dclib islib newlib;
%mpeinit() %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_; data _null_;
newlib=coalescec(symget('dclib'),"&mpelib"); newlib=coalescec(symget('dclib'),"&mpelib");
%mp_validatecol(newlib,ISLIB,islib) %mp_validatecol(newlib,ISLIB,islib)
@@ -2,10 +2,14 @@
@file refreshcatalog.sas @file refreshcatalog.sas
@brief Refreshes the library data catalog @brief Refreshes the library data catalog
@details A library may be passed in a LIBREF url param. @details A library may be passed in a LIBREF url param.
Requires membership of the DC administrators group.
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li mpeinit.sas @li mpeinit.sas
@li dc_refreshcatalog.sas @li dc_refreshcatalog.sas
@li mpe_getgroups.sas
@li mp_abort.sas
@li mp_validatecol.sas
@li mpeterm.sas @li mpeterm.sas
@version 9.3 @version 9.3
@@ -18,6 +22,44 @@
%global libref; %global libref;
%mpeinit() %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) %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 'SECREVRESULT: ' test_result ' - ' test_description;
run;
+15 -1
View File
@@ -1,11 +1,13 @@
/** /**
@file refreshlibs.sas @file refreshlibs.sas
@brief Refreshes the library data catalog @brief Refreshes the library data catalog
@details @details Requires membership of the DC administrators group.
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li mpeinit.sas @li mpeinit.sas
@li mpe_refreshlibs.sas @li mpe_refreshlibs.sas
@li mpe_getgroups.sas
@li mp_abort.sas
@version 9.3 @version 9.3
@author 4GL Apps Ltd @author 4GL Apps Ltd
@@ -17,4 +19,16 @@
%mpeinit() %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() %mpe_refreshlibs()
+47 -1
View File
@@ -10,6 +10,7 @@
@li mp_abort.sas @li mp_abort.sas
@li mp_binarycopy.sas @li mp_binarycopy.sas
@li mp_streamfile.sas @li mp_streamfile.sas
@li mp_validatecol.sas
@version 9.2 @version 9.2
@author 4GL Apps Ltd @author 4GL Apps Ltd
@@ -22,6 +23,52 @@
%mpeinit() %mpeinit()
%mpe_getvars(BrowserParams, BrowserParams); %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 */ /* security checks */
%let user=%mf_getuser(); %let user=%mf_getuser();
%mpe_accesscheck(&libds,outds=authEDIT,user=&user,access_level=EDIT) %mpe_accesscheck(&libds,outds=authEDIT,user=&user,access_level=EDIT)
@@ -51,5 +98,4 @@
%mpestp_diffs() %mpestp_diffs()
%mpeterm() %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 'SECREVRESULT: ' test_result ' - ' test_description;
run;
+24 -2
View File
@@ -5,8 +5,8 @@
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li dc_assignlib.sas @li dc_assignlib.sas
@li mf_getvalue.sas
@li mp_abort.sas @li mp_abort.sas
@li mp_validatecol.sas
@version 9.2 @version 9.2
@author 4GL Apps Ltd @author 4GL Apps Ltd
@@ -18,7 +18,29 @@
%mpeinit() %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,.)) %dc_assignlib(READ,%scan(&ds,1,.))
proc contents noprint data=&ds 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 'SECREVRESULT: ' test_result ' - ' test_description;
run;
+42 -7
View File
@@ -35,7 +35,6 @@
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li mf_existds.sas @li mf_existds.sas
@li mf_getvalue.sas
@li mf_verifymacvars.sas @li mf_verifymacvars.sas
@li dc_assignlib.sas @li dc_assignlib.sas
@li mf_getvarformat.sas @li mf_getvarformat.sas
@@ -43,6 +42,7 @@
@li mp_cntlout.sas @li mp_cntlout.sas
@li mp_filtercheck.sas @li mp_filtercheck.sas
@li mp_filtergenerate.sas @li mp_filtergenerate.sas
@li mp_validatecol.sas
@version 9.2 @version 9.2
@author 4GL Apps Ltd. @author 4GL Apps Ltd.
@@ -77,18 +77,53 @@ data _null_;
put (_all_)(=); put (_all_)(=);
run; 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 _ds $34;
set work.iwant;
_libds=libds;
_col=col;
%mp_validatecol(_libds,LIBDS,is_libds)
/* permit the format-catalog form: LIBREF.CATALOGNAME-FC */
if is_libds=0 then do;
_ds=scan(_libds,1,'-');
%mp_validatecol(_ds,LIBDS,is_libds)
end;
%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 is_fmt=0;
%let startrow=1; %let startrow=1;
%let rows=4000; %let rows=4000;
%put &=libds;
%put &=col2;
%mp_abort(iftrue= (%mf_verifymacvars(libds col2)=0) %mp_abort(iftrue= (%mf_verifymacvars(libds col2)=0)
,mac=&_program..sas ,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,.)) %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 'SECREVRESULT: ' test_result ' - ' test_description;
run;
+58 -5
View File
@@ -11,16 +11,16 @@
@li filter - the filter RK if used @li filter - the filter RK if used
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li mf_verifymacvars.sas
@li mf_getuser.sas @li mf_getuser.sas
@li mf_existfeature.sas @li mf_existfeature.sas
@li dc_assignlib.sas @li dc_assignlib.sas
@li mp_ds2cards.sas
@li mp_ds2csv.sas
@li mp_abort.sas @li mp_abort.sas
@li mp_binarycopy.sas @li mp_binarycopy.sas
@li mp_cntlout.sas @li mp_cntlout.sas
@li mp_ds2cards.sas
@li mp_ds2csv.sas
@li mp_streamfile.sas @li mp_streamfile.sas
@li mp_validatecol.sas
@li mpe_filtermaster.sas @li mpe_filtermaster.sas
@@ -37,9 +37,62 @@
%let user=%mf_getuser(); %let user=%mf_getuser();
%let is_fmt=0; %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 _ds $34 _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;
%mp_validatecol(_table,LIBDS,is_libds)
/* permit the format-catalog form: LIBREF.CATALOGNAME-FC */
if is_libds=0 then do;
_ds=scan(_table,1,'-');
%mp_validatecol(_ds,LIBDS,is_libds)
end;
/* 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 ,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 */ %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 'SECREVRESULT: ' test_result ' - ' test_description;
run;
+26 -2
View File
@@ -26,8 +26,8 @@
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li dc_assignlib.sas @li dc_assignlib.sas
@li mf_getvalue.sas
@li mp_filterstore.sas @li mp_filterstore.sas
@li mp_validatecol.sas
@li removecolsfromwork.sas @li removecolsfromwork.sas
@version 9.2 @version 9.2
@@ -40,7 +40,31 @@
%mpeinit() %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.
*/
%let ds=;
%let is_libds=0;
data _null_;
length _ds $64;
set work.iwant;
_ds=filter_table;
%mp_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,.)) %dc_assignlib(WRITE,%scan(&ds,1,.))
%mp_filterstore( %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 'SECREVRESULT: ' test_result ' - ' test_description;
run;